From 1d0b4fa172c0ea15c64f169223e17ce505531a86 Mon Sep 17 00:00:00 2001 From: devfive Date: Sun, 30 Aug 2026 16:07:04 +0900 Subject: [PATCH 1/6] Require 100 percent line coverage --- .changepacks/changepack_log_coverage-100.json | 1 + .github/workflows/CI.yml | 4 +- .../src/router_codegen/export.rs | 39 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 .changepacks/changepack_log_coverage-100.json diff --git a/.changepacks/changepack_log_coverage-100.json b/.changepacks/changepack_log_coverage-100.json new file mode 100644 index 00000000..2627ebf5 --- /dev/null +++ b/.changepacks/changepack_log_coverage-100.json @@ -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"} diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index c0d0d9a3..57e4accd 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -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 @@ -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: diff --git a/crates/vespera_macro/src/router_codegen/export.rs b/crates/vespera_macro/src/router_codegen/export.rs index ddc99160..62ee94a3 100644 --- a/crates/vespera_macro/src/router_codegen/export.rs +++ b/crates/vespera_macro/src/router_codegen/export.rs @@ -352,6 +352,33 @@ 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")] + 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(), @@ -414,6 +441,18 @@ 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"); + } + fn schema_metadata( name: &str, definition: &str, From 3965b35c5a83caeed1f385cbf6b7fde452c13dad Mon Sep 17 00:00:00 2001 From: devfive Date: Sun, 30 Aug 2026 16:17:20 +0900 Subject: [PATCH 2/6] Stabilize export prefix coverage spans --- crates/vespera_macro/src/router_codegen/export.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/vespera_macro/src/router_codegen/export.rs b/crates/vespera_macro/src/router_codegen/export.rs index 62ee94a3..89040ae2 100644 --- a/crates/vespera_macro/src/router_codegen/export.rs +++ b/crates/vespera_macro/src/router_codegen/export.rs @@ -123,16 +123,18 @@ pub fn schema_namespace_from_prefix(prefix: &str) -> String { .filter(|word| !word.is_empty()) { let mut chars = word.chars(); - if let Some(first) = chars.next() { - namespace.extend(first.to_uppercase()); - namespace.extend(chars); - } + let first = chars + .next() + .expect("schema namespace words are filtered to be non-empty"); + namespace.extend(first.to_uppercase()); + namespace.extend(chars); } namespace } /// Apply the normalized prefix to collected route metadata exactly once. /// Both router generation and OpenAPI generation consume this same metadata. +#[inline(never)] pub fn apply_export_prefix(metadata: &mut CollectedMetadata, prefix: &str) { if prefix.is_empty() { return; From 887804f7610ddf5971b7c6f5cc64ae11e684030a Mon Sep 17 00:00:00 2001 From: devfive Date: Sun, 30 Aug 2026 16:25:31 +0900 Subject: [PATCH 3/6] Align coverage with executable spans --- crates/vespera_macro/src/router_codegen/export.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/vespera_macro/src/router_codegen/export.rs b/crates/vespera_macro/src/router_codegen/export.rs index 89040ae2..abe96368 100644 --- a/crates/vespera_macro/src/router_codegen/export.rs +++ b/crates/vespera_macro/src/router_codegen/export.rs @@ -123,17 +123,15 @@ pub fn schema_namespace_from_prefix(prefix: &str) -> String { .filter(|word| !word.is_empty()) { let mut chars = word.chars(); - let first = chars - .next() - .expect("schema namespace words are filtered to be non-empty"); + let first = chars.next().expect("filtered non-empty word"); namespace.extend(first.to_uppercase()); namespace.extend(chars); } 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. #[inline(never)] pub fn apply_export_prefix(metadata: &mut CollectedMetadata, prefix: &str) { if prefix.is_empty() { From b1dcf3601238ba2a56e5c89d15dfabadffc9cacd Mon Sep 17 00:00:00 2001 From: devfive Date: Sun, 30 Aug 2026 16:35:39 +0900 Subject: [PATCH 4/6] Cover empty prefix iteration paths --- .../src/router_codegen/export.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/vespera_macro/src/router_codegen/export.rs b/crates/vespera_macro/src/router_codegen/export.rs index abe96368..98318f7b 100644 --- a/crates/vespera_macro/src/router_codegen/export.rs +++ b/crates/vespera_macro/src/router_codegen/export.rs @@ -120,19 +120,18 @@ 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(); - let first = chars.next().expect("filtered non-empty word"); - namespace.extend(first.to_uppercase()); - namespace.extend(chars); + if let Some(first) = chars.next() { + namespace.extend(first.to_uppercase()); + namespace.extend(chars); + } } namespace } // Apply the normalized prefix to collected route metadata exactly once. // Both router generation and OpenAPI generation consume this same metadata. -#[inline(never)] pub fn apply_export_prefix(metadata: &mut CollectedMetadata, prefix: &str) { if prefix.is_empty() { return; @@ -372,6 +371,7 @@ mod tests { #[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, @@ -453,6 +453,15 @@ mod tests { 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, From 52db5488c526819bd133059a48290766ede54040 Mon Sep 17 00:00:00 2001 From: devfive Date: Sun, 30 Aug 2026 16:46:06 +0900 Subject: [PATCH 5/6] Cover OpenAPI namespace edge paths --- .../src/router_codegen/export.rs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/crates/vespera_macro/src/router_codegen/export.rs b/crates/vespera_macro/src/router_codegen/export.rs index 98318f7b..1d7e0e3d 100644 --- a/crates/vespera_macro/src/router_codegen/export.rs +++ b/crates/vespera_macro/src/router_codegen/export.rs @@ -594,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`") + ); + } } From ddc8a83c7b22e052349606420b90138a1459c7ee Mon Sep 17 00:00:00 2001 From: devfive Date: Sun, 30 Aug 2026 17:39:56 +0900 Subject: [PATCH 6/6] Gate branch coverage test failures --- .github/workflows/CI.yml | 20 ++++++++++---------- crates/vespera_macro/src/collector/tests.rs | 3 ++- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 57e4accd..8d8752cf 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -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 @@ -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 diff --git a/crates/vespera_macro/src/collector/tests.rs b/crates/vespera_macro/src/collector/tests.rs index 22369916..04bc0223 100644 --- a/crates/vespera_macro/src/collector/tests.rs +++ b/crates/vespera_macro/src/collector/tests.rs @@ -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();