Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changepacks/changepack_log_coverage-100.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"changes":{"crates/vespera_macro/Cargo.toml":"Patch"},"note":"Cover every export prefix branch and require 100% Rust line coverage in CI.","date":"2026-08-30T07:06:00.000Z"}
24 changes: 12 additions & 12 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ jobs:
# tests, which let a never-passing doctest land unnoticed —
# run them explicitly before the (slow) coverage step.
run: cargo test --workspace --doc
- name: Test
- name: Test and enforce 100% line coverage
run: |
# rust coverage issue
echo 'max_width = 100000' > .rustfmt.toml
Expand All @@ -56,7 +56,7 @@ jobs:
echo 'merge_derives = true' >> .rustfmt.toml
echo 'use_small_heuristics = "Default"' >> .rustfmt.toml
cargo fmt
cargo tarpaulin --out Lcov Stdout --engine llvm
cargo tarpaulin --out Lcov Stdout --engine llvm --fail-under 100
- name: Upload to codecov.io
uses: codecov/codecov-action@v7
with:
Expand All @@ -65,8 +65,10 @@ jobs:
files: lcov.info
if: github.ref == 'refs/heads/main'

# OBSERVATIONAL ONLY — this job must never gate, and its percentage must
# never become a threshold. Rust's branch instrumentation is still unstable
# OBSERVATIONAL PERCENTAGE ONLY — this job never gates on the percentage.
# "observational" applies only to the percentage: instrumented test failures
# still gate CI. Rust's branch instrumentation is unstable and therefore has
# no percentage threshold.
# (rust-lang/rust#79649), and rust-lang/rust#124118 lists as NOT yet
# supported: match arms and or-patterns, the `?` operator, `.await`, and
# any branch introduced by macro expansion — "the current implementation
Expand All @@ -83,22 +85,20 @@ jobs:
rust-branch-coverage:
name: Rust branch coverage (observational)
runs-on: ubuntu-latest
continue-on-error: true
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@nightly
with:
toolchain: nightly-2026-08-29
components: llvm-tools-preview
- uses: taiki-e/install-action@cargo-llvm-cov
- name: Run instrumented tests
# Allowed to fail. This pins a MOVING nightly, and toolchain drift
# breaks tests that assert compiler output — the trybuild UI suite
# blesses its .stderr files against stable, so a nightly diagnostic
# reword fails it with nothing actually broken. The profraw data is
# still written, so the report step below runs regardless.
continue-on-error: true
run: cargo llvm-cov --branch --workspace --no-fail-fast --no-report
# Pinned nightly keeps branch instrumentation reproducible. The
# trybuild UI suite is blessed against stable, whose diagnostic
# rendering can differ with nothing actually broken, so that
# stable-specific harness alone is skipped here and still runs in Test.
run: cargo llvm-cov --branch --workspace --no-fail-fast --no-report -- --skip ui_diagnostics
- name: Summarise branch coverage
run: |
# `--branch` belongs on the instrumented RUN above, not here: the
Expand Down
3 changes: 2 additions & 1 deletion crates/vespera_macro/src/collector/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -526,7 +526,8 @@ fn test_collect_metadata_file_read_error_permissions() {

assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("failed to read route file"));
assert!(error_msg.contains("cannot read or parse"));
assert!(error_msg.contains("unreadable.rs"));

let permissions = fs::Permissions::from_mode(0o644);
fs::set_permissions(&file_path, permissions).ok();
Expand Down
90 changes: 87 additions & 3 deletions crates/vespera_macro/src/router_codegen/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,6 @@ pub fn schema_namespace_from_prefix(prefix: &str) -> String {
for word in segments[start..]
.iter()
.flat_map(|segment| segment.split(|ch: char| !ch.is_alphanumeric()))
.filter(|word| !word.is_empty())
{
let mut chars = word.chars();
if let Some(first) = chars.next() {
Expand All @@ -131,8 +130,8 @@ pub fn schema_namespace_from_prefix(prefix: &str) -> String {
namespace
}

/// Apply the normalized prefix to collected route metadata exactly once.
/// Both router generation and OpenAPI generation consume this same metadata.
// Apply the normalized prefix to collected route metadata exactly once.
// Both router generation and OpenAPI generation consume this same metadata.
pub fn apply_export_prefix(metadata: &mut CollectedMetadata, prefix: &str) {
if prefix.is_empty() {
return;
Expand Down Expand Up @@ -352,6 +351,34 @@ mod tests {
);
}

#[rstest::rstest]
#[case("/api media", "must be a URL path")]
#[case("/api?version=1", "must be a URL path")]
#[case("/api#section", "must be a URL path")]
#[case("/api//users", "must not contain empty path segments")]
#[case("/---", "must contain at least one alphanumeric character")]
fn normalize_prefix_rejects_each_invalid_shape(#[case] raw: &str, #[case] expected: &str) {
let prefix = LitStr::new(raw, proc_macro2::Span::call_site());

let error = normalize_prefix(&prefix).expect_err("invalid prefix must be rejected");

assert!(error.to_string().contains(expected));
}

#[rstest::rstest]
#[case("", "")]
#[case("/", "")]
#[case("/api", "Api")]
#[case("/api/media-library", "MediaLibrary")]
#[case("/api/v1/user_profile", "V1UserProfile")]
#[case("/api/-media--library-", "MediaLibrary")]
fn schema_namespace_covers_empty_api_and_composite_prefixes(
#[case] prefix: &str,
#[case] expected: &str,
) {
assert_eq!(schema_namespace_from_prefix(prefix), expected);
}

fn route_metadata(path: &str) -> crate::metadata::RouteMetadata {
crate::metadata::RouteMetadata {
method: "get".to_string(),
Expand Down Expand Up @@ -414,6 +441,27 @@ mod tests {
assert_eq!(serde_json::to_vec(&metadata).unwrap(), before);
}

#[test]
fn prefix_replaces_root_route_and_extends_nested_route() {
let mut metadata = CollectedMetadata::new();
metadata.routes.push(route_metadata("/"));
metadata.routes.push(route_metadata("/users"));

apply_export_prefix(&mut metadata, "/api/admin");

assert_eq!(metadata.routes[0].path, "/api/admin");
assert_eq!(metadata.routes[1].path, "/api/admin/users");
}

#[test]
fn nonempty_prefix_leaves_empty_route_collection_empty() {
let mut metadata = CollectedMetadata::new();

apply_export_prefix(&mut metadata, "/api/admin");

assert!(metadata.routes.is_empty());
}

fn schema_metadata(
name: &str,
definition: &str,
Expand Down Expand Up @@ -546,4 +594,40 @@ mod tests {

assert_eq!(serde_json::to_vec(&openapi).unwrap(), before);
}

#[test]
fn nonempty_namespace_without_schema_components_is_a_noop() {
let (metadata, mut openapi) = schema_doc("/items", "struct Item { id: i32 }");
openapi.components = None;
let before = serde_json::to_vec(&openapi).unwrap();

namespace_export_schemas(&mut openapi, &metadata, "Media").unwrap();

assert_eq!(serde_json::to_vec(&openapi).unwrap(), before);
}

#[test]
fn generated_schema_namespace_rejects_an_explicit_name_collision() {
let (mut metadata, mut openapi) = schema_doc("/items", "struct Item { id: i32 }");
metadata.structs.push(schema_metadata(
"MediaItem",
"struct MediaItem { id: i32 }",
true,
));
let schemas = openapi
.components
.as_mut()
.and_then(|components| components.schemas.as_mut())
.unwrap();
schemas.insert("MediaItem".to_string(), schemas["Item"].clone());

let error = namespace_export_schemas(&mut openapi, &metadata, "Media")
.expect_err("generated names must not replace explicit schemas");

assert!(
error
.to_string()
.contains("schema namespace `Media` maps `Item` to existing component `MediaItem`")
);
}
}
Loading