From 215bdd0e1e0ee921715a2d0dc1e0c60862621293 Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Tue, 14 Jul 2026 17:13:46 +0800 Subject: [PATCH 1/4] test: enforce native relative coverage --- .agents/docs/README.md | 3 +- .agents/docs/architecture.md | 2 +- .agents/docs/testing-strategy.md | 54 ++++++++++++ .github/workflows/test.yaml | 41 +++++++++- src/impl_sugar_path.rs | 29 ++++--- tests/relative_borrowing.rs | 85 ++++++++++++++++++- tests/relative_lexical.rs | 136 +++++++++++++++++++++++++++++-- tests/relative_without_cwd.rs | 34 +++++--- 8 files changed, 349 insertions(+), 35 deletions(-) create mode 100644 .agents/docs/testing-strategy.md diff --git a/.agents/docs/README.md b/.agents/docs/README.md index 2c0d53f..07861df 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -6,6 +6,7 @@ Use this map to load only the context that bears on the work in front of you. Al - [Project goal and non-goals](./goal.md) — the intended library shape, the performance promise, and the boundary between lexical path work and filesystem resolution. - [Public API redesign](./api-redesign.md) — the settled decision, implementation, and native allocation evidence for the breaking surface. +- [Semantic test strategy](./testing-strategy.md) — the finite behavior partitions, branch-entry requirements, native CI guarantees, and audited coverage gaps. - [Public API and usage](../../README.md) — the supported operations, examples, features, and platform support exposed to users. ## Code routes @@ -26,4 +27,4 @@ Use this map to load only the context that bears on the work in front of you. Al - Performance optimization scope or a non-UTF-8 performance tradeoff → [UTF-8 is the default performance target](./performance-strategy.md#utf-8-is-the-default-performance-target). - Historical Windows-GNU Docker/Wine reproduction → [Local Docker validation is opt-in](./conventions.md#local-docker-validation-is-opt-in) and [Windows GNU local execution gate](../../benchmarks/windows-gnu.md#local-execution-gate). - Performance strategy, baseline changes, Rolldown workloads, allocation work, or API performance → [Performance strategy](./performance-strategy.md). -- Test fixtures or assertions → [Testing conventions](./conventions.md). +- Test fixtures, assertions, generated cases, or platform test coverage → [Testing conventions](./conventions.md) and [Semantic test strategy](./testing-strategy.md). diff --git a/.agents/docs/architecture.md b/.agents/docs/architecture.md index f7cccbf..a1a0153 100644 --- a/.agents/docs/architecture.md +++ b/.agents/docs/architecture.md @@ -48,7 +48,7 @@ A same-root Windows relation can also be unrepresentable as a standalone relativ Windows root-relative paths share an unknown current drive, so two such inputs can relate without reading cwd. Drive-relative paths carry per-drive cwd context. Ambient absolutization delegates that resolution to `std::path::absolute` and then preserves the input drive-letter spelling. With an explicit cwd on the same drive, the cwd can resolve the input and the result still uses the input drive spelling. An explicit cwd on another drive contains no information about the input drive's remembered cwd, so the normalized drive-relative value is returned instead of fabricating a root or reading ambient state. `relative_with` can still cancel this unknown context when target and base have the same drive and the same unresolved leading-parent count; otherwise it returns the normalized target. -The Unix, Windows MSVC, and Windows GNU CI targets are the durable integration check. A shared-code change is incomplete if it only reasons from the host platform's interpretation of path strings. +Native Linux x86_64 GNU, Windows x86_64 MSVC, and macOS ARM64 are the continuous integration targets. Each job verifies its Rust host before testing, macOS additionally requires the `neon` target feature, and each platform requires a registered target-specific sentinel so a `cfg` change cannot silently turn the relevant coverage into zero tests. Windows GNU remains an opt-in historical reproduction target rather than a continuous CI target. A shared-code change is incomplete if it only reasons from the developer host's interpretation of path strings. ## Current-directory access is isolated diff --git a/.agents/docs/testing-strategy.md b/.agents/docs/testing-strategy.md new file mode 100644 index 0000000..24f62fa --- /dev/null +++ b/.agents/docs/testing-strategy.md @@ -0,0 +1,54 @@ +# Semantic test strategy + +## Goal + +SugarPath treats complete semantic coverage as a finite set of behavior partitions, not as literal enumeration of the unbounded set of possible path strings. Every public method must be represented across the partitions that can change its output, error policy, ownership result, native encoding, or execution branch, and bounded deterministic generation must cover combinations inside the high-risk parsing and relative-path partitions. + +## Required behavior partitions + +| Dimension | Required partitions | +| --- | --- | +| Public surface | Borrowed `Path`, known-UTF-8 `str` and deref receivers, and consuming `PathBuf` methods | +| Platform | Linux and macOS Unix semantics, macOS ARM64 NEON dispatch, and Windows disk, root-relative, drive-relative, UNC, verbatim, device, and generic namespace forms | +| Spelling | Empty, current directory, leading and interior parents, clean and dirty separators, trailing separators, roots, deep paths, and multibyte components | +| Context | Cwd-independent, ambient-cwd-dependent, explicit borrowed cwd, explicit owned cwd, unused invalid cwd, unavailable ambient cwd, and cached cwd | +| Encoding | Valid UTF-8 and native-invalid Unix bytes or Windows wide units, compared in their native representation | +| Result contract | Exact spelling, strict/fallible/lossy policy, `Cow` borrowed or owned variant and borrow source, owned-buffer reuse where promised, round trip where representable, and panic or error behavior | + +The matrix is interaction-aware. A platform root kind must be crossed with encoding when root comparison consumes native encoding, and a fast path must be crossed with clean and dirty spelling when its classifier selects between borrowing and rebuilding. Unrelated dimensions do not require a blind Cartesian product when their independence is already enforced by a narrower test. + +## Enforcement rules + +- Literal expected-output tables pin public behavior. One public method may be compared with another to test parity, but it is not an independent oracle; the covered partition must also have a literal expectation or a separate test oracle. +- Generated tests must call the production dispatch as well as any private helper under review. A helper-only exhaustive test cannot prove that the public path still selects that helper. +- Platform-gated coverage must not silently disappear. CI verifies the exact Rust host for Linux, Windows, and macOS ARM64, requires `neon` on macOS, and checks that a target-specific sentinel test is registered after the all-feature suite. +- Exact native spelling and encoding are asserted directly. `Path` equality and lossy conversion are insufficient when trailing separators, drive spelling, or invalid encoding are observable. +- `Cow` and consuming APIs assert ownership separately from value equality. A test that checks only output text does not protect the allocation-facing contract. + +## Relative-path coverage checkpoint + +- `relative_with` has fixed Unix and Windows expected-output matrices for relative, absolute, mixed-context, dirty, equal, root-clamped, trailing-separator, root-relative, drive-relative, and different-drive results, using both borrowed and owned cwd arguments. +- Cwd-independent rows pin the same literal result through `relative`, `try_relative`, and both explicit-cwd argument forms while proving that an unused non-absolute cwd is not validated. +- `try_relative` and `relative_with` assert borrowed descendant and equal suffixes, owned upward and dirty results, owned cwd-resolved results, and receiver-only borrowing when base and cwd are owned temporaries. +- On macOS and Linux, all 224,676 pairs from the bounded short absolute spelling set plus the multibyte set are checked through the production `relative_str` dispatch and the suffix-validation helper against the slow component oracle. +- Unavailable Unix cwd coverage pins exact successful output and `Cow` variants for cwd-independent fallible calls, preserves the ambient error and panic checks for dependent calls, and proves that a valid explicit cwd still succeeds. + +## Audited follow-up gaps + +- Add systematic `try_absolutize` value and `Cow` parity, exact Windows root-relative absolutization, and invalid-native ambient absolutization cases. +- Cross the full borrowed normalization corpus with `PathBuf::into_normalized`, including Windows prefix and trailing-separator forms and valid multibyte names. +- Complete the slash receiver-policy matrix for valid `str`, `Path`, and `PathBuf`, strict, fallible, and lossy conversion, non-normalizing spelling, `Cow` behavior, owned reuse, invalid recovery, and consuming strict panic. +- Add cached-cwd coverage through relative calculation and a failed-initialization retry case before changing current-directory state handling. +- Add direct Windows root-identifier tests for non-ASCII and invalid-wide comparison in UNC, device, and generic verbatim prefixes. + +## Change rule + +Any change to a public contract, platform branch, native-encoding comparison, classifier, ownership path, or cwd access path must identify its affected matrix rows, add or update the closest executable evidence, and keep this record current. A new optimized branch is incomplete until a test proves that production dispatch enters it on the intended native CI target and a semantic oracle or fixed matrix covers its fallback boundary. + +## Durable evidence + +- [Native CI target and sentinel checks](../../.github/workflows/test.yaml) +- [Fixed explicit and fallible relative matrices](../../tests/relative_lexical.rs) +- [Relative ownership and lifetime contracts](../../tests/relative_borrowing.rs) +- [Unavailable-cwd relative behavior](../../tests/relative_without_cwd.rs) +- [Production dispatch and short-path oracle](../../src/impl_sugar_path.rs) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 9ad21b0..f49d39d 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -48,11 +48,23 @@ jobs: run: cargo doc --locked --workspace --all-features --no-deps test: - name: Test ${{ matrix.os }} + name: Test ${{ matrix.expected_host }} strategy: fail-fast: false matrix: - os: [windows-latest, ubuntu-latest, macos-latest] + include: + - os: ubuntu-latest + expected_host: x86_64-unknown-linux-gnu + required_target_feature: "" + expected_test: impl_sugar_path::relative_str_tests::production_dispatch_and_suffix_validation_match_short_path_oracle + - os: windows-latest + expected_host: x86_64-pc-windows-msvc + required_target_feature: "" + expected_test: windows_relative_with_has_fixed_context_results + - os: macos-latest + expected_host: aarch64-apple-darwin + required_target_feature: neon + expected_test: absolute_relative_paths_cross_neon_block_boundaries_exactly runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v5 @@ -61,6 +73,21 @@ jobs: with: toolchain: 1.97.0 components: clippy + - name: Verify native Rust target + env: + EXPECTED_HOST: ${{ matrix.expected_host }} + REQUIRED_TARGET_FEATURE: ${{ matrix.required_target_feature }} + run: | + actual_host="$(rustc -vV | sed -n 's/^host: //p')" + if [[ "$actual_host" != "$EXPECTED_HOST" ]]; then + echo "expected Rust host $EXPECTED_HOST, found $actual_host" + exit 1 + fi + if [[ -n "$REQUIRED_TARGET_FEATURE" ]] \ + && ! rustc --print cfg | grep -Fqx "target_feature=\"$REQUIRED_TARGET_FEATURE\""; then + echo "Rust host $EXPECTED_HOST is missing target feature $REQUIRED_TARGET_FEATURE" + exit 1 + fi # Clippy stays on each OS: platform-gated imports only fail under that target. - name: Clippy run: cargo clippy --locked --workspace --all-targets --all-features -- -D warnings @@ -68,6 +95,16 @@ jobs: run: cargo test --locked -p sugar_path - name: Test all features run: cargo test --locked --workspace --all-features + - name: Verify platform-specific test registration + env: + EXPECTED_TEST: ${{ matrix.expected_test }} + run: | + cargo test --locked -p sugar_path --all-features -- --list > target/all-feature-tests.txt + if ! grep -Fq "$EXPECTED_TEST: test" target/all-feature-tests.txt; then + echo "expected platform-specific test was not registered: $EXPECTED_TEST" + cat target/all-feature-tests.txt + exit 1 + fi # Continuous gate: Linux + Windows only. The tracker always measures the # cached_current_dir production config via `cargo allocs`. diff --git a/src/impl_sugar_path.rs b/src/impl_sugar_path.rs index 1206972..26eeba3 100644 --- a/src/impl_sugar_path.rs +++ b/src/impl_sugar_path.rs @@ -1878,23 +1878,32 @@ fn normalize_parts(path: &str) -> StrVec<'_> { mod relative_str_tests { use std::borrow::Cow; - use super::{needs_relative_normalization, relative_str_slow, relative_str_suffix_validated}; + use super::{ + needs_relative_normalization, relative_str, relative_str_slow, relative_str_suffix_validated, + }; - fn assert_suffix_validation_matches_full_normalization(target: &str, base: &str) { + fn assert_dispatch_and_suffix_validation_match_full_normalization(target: &str, base: &str) { + let dispatched = relative_str(target, base); let target = target.trim_end_matches('/'); let base = base.trim_end_matches('/'); - let actual = relative_str_suffix_validated(target, base); + let suffix_validated = relative_str_suffix_validated(target, base); let expected = relative_str_slow(target, base); - assert_eq!(actual, expected, "target {target:?}, base {base:?}"); + assert_eq!(dispatched, expected, "production dispatch: target {target:?}, base {base:?}"); + assert_eq!(suffix_validated, expected, "suffix validation: target {target:?}, base {base:?}"); let dirty = needs_relative_normalization(target) || needs_relative_normalization(base); let base_is_component_prefix = target.strip_prefix(base).is_some_and(|suffix| suffix.is_empty() || suffix.starts_with('/')); let should_borrow = !dirty && base_is_component_prefix; assert_eq!( - matches!(actual, Cow::Borrowed(_)), + matches!(dispatched, Cow::Borrowed(_)), + should_borrow, + "target {target:?}, base {base:?} returned the wrong Cow variant through production dispatch", + ); + assert_eq!( + matches!(suffix_validated, Cow::Borrowed(_)), should_borrow, - "target {target:?}, base {base:?} returned the wrong Cow variant", + "target {target:?}, base {base:?} returned the wrong Cow variant through suffix validation", ); } @@ -1924,21 +1933,21 @@ mod relative_str_tests { } #[test] - fn suffix_only_validation_matches_full_normalization_for_short_paths() { + fn production_dispatch_and_suffix_validation_match_short_path_oracle() { let paths = short_absolute_spellings(); for target in &paths { for base in &paths { - assert_suffix_validation_matches_full_normalization(target, base); + assert_dispatch_and_suffix_validation_match_full_normalization(target, base); } } } #[test] - fn suffix_only_validation_handles_multibyte_prefixes_and_mismatches() { + fn production_dispatch_and_suffix_validation_handle_multibyte_paths() { let paths = ["/é", "/ê", "/é/a", "/ê/a", "/猫", "/猫/src", "/猫/../src/a", "/猫/../src/b"]; for target in paths { for base in paths { - assert_suffix_validation_matches_full_normalization(target, base); + assert_dispatch_and_suffix_validation_match_full_normalization(target, base); } } } diff --git a/tests/relative_borrowing.rs b/tests/relative_borrowing.rs index 3f33224..8ab78ac 100644 --- a/tests/relative_borrowing.rs +++ b/tests/relative_borrowing.rs @@ -1,4 +1,7 @@ -use std::{borrow::Cow, path::Path}; +use std::{ + borrow::Cow, + path::{Path, PathBuf}, +}; use sugar_path::SugarPath; @@ -12,6 +15,86 @@ fn assert_borrows_from_target(target: &Path, relative: &Path) { assert!(relative_start >= target_start && relative_end <= target_end); } +fn assert_cow_variant(target: &Path, relative: Cow<'_, Path>, should_borrow: bool, label: &str) { + assert_eq!(matches!(&relative, Cow::Borrowed(_)), should_borrow, "{label}: target {target:?}"); + if should_borrow { + assert_borrows_from_target(target, &relative); + } +} + +#[test] +fn fallible_and_explicit_relative_preserve_output_and_cow_contracts() { + #[cfg(target_family = "unix")] + let cases = [ + ("/workspace/project/src/index.js", "/workspace/project", "src/index.js", true), + ("/workspace/project", "/workspace/project", "", true), + ("/workspace/project/src", "/workspace/project/dist", "../src", false), + ("/workspace/project/./src", "/workspace/project", "src", false), + ]; + #[cfg(target_family = "windows")] + let cases = [ + (r"C:\workspace\project\src\index.js", r"C:\workspace\project", r"src\index.js", true), + (r"C:\workspace\project", r"C:\workspace\project", "", true), + (r"C:\workspace\project\src", r"C:\workspace\project\dist", r"..\src", false), + (r"C:\workspace\project\.\src", r"C:\workspace\project", "src", false), + (r"D:\workspace\project", r"C:\workspace\project", r"D:\workspace\project", false), + ]; + + for (target, base, expected, should_borrow) in cases { + let target = Path::new(target); + let fallible = target.try_relative(base).expect("absolute inputs do not need cwd"); + assert_eq!(fallible.as_os_str(), Path::new(expected).as_os_str()); + assert_cow_variant(target, fallible, should_borrow, "try_relative"); + + let explicit = target.relative_with(base, Path::new("not/absolute")); + assert_eq!(explicit.as_os_str(), Path::new(expected).as_os_str()); + assert_cow_variant(target, explicit, should_borrow, "relative_with"); + } +} + +#[test] +fn owned_context_arguments_never_supply_relative_borrows() { + #[cfg(target_family = "unix")] + let (target, base, cwd, expected) = + ("src/index.js", PathBuf::from("dist"), PathBuf::from("/workspace/project"), "../src/index.js"); + #[cfg(target_family = "windows")] + let (target, base, cwd, expected) = ( + r"src\index.js", + PathBuf::from("dist"), + PathBuf::from(r"C:\workspace\project"), + r"..\src\index.js", + ); + + fn relative_with_owned_context<'a>( + target: &'a Path, + base: PathBuf, + cwd: PathBuf, + ) -> Cow<'a, Path> { + target.relative_with(base, cwd) + } + + let target = Path::new(target); + let relative = relative_with_owned_context(target, base, cwd); + assert_eq!(relative.as_os_str(), Path::new(expected).as_os_str()); + assert_cow_variant(target, relative, false, "cwd-resolved relative_with"); + + #[cfg(target_family = "unix")] + let (target, base, expected) = + ("/workspace/project/src/index.js", "/workspace/project", "src/index.js"); + #[cfg(target_family = "windows")] + let (target, base, expected) = + (r"C:\workspace\project\src\index.js", r"C:\workspace\project", r"src\index.js"); + + let target = Path::new(target); + let relative = relative_with_owned_context( + target, + PathBuf::from(base), + PathBuf::from("unused/nonabsolute/cwd"), + ); + assert_eq!(relative.as_os_str(), Path::new(expected).as_os_str()); + assert_cow_variant(target, relative, true, "clean relative_with with owned context"); +} + #[cfg(target_family = "unix")] #[test] fn unix_descendants_borrow_the_target_suffix() { diff --git a/tests/relative_lexical.rs b/tests/relative_lexical.rs index f0de3a4..b59dadd 100644 --- a/tests/relative_lexical.rs +++ b/tests/relative_lexical.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use sugar_path::SugarPath; -fn oracle_with_cwd(target: &Path, base: &Path, cwd: &Path) -> PathBuf { +fn relative_with_cwd(target: &Path, base: &Path, cwd: &Path) -> PathBuf { target.relative_with(base, cwd).into_owned() } @@ -17,6 +17,128 @@ fn assert_exact(base: &str, target: &str, expected: &str) { ); } +fn assert_explicit_context_matrix(cwd: &Path, cases: &[(&str, &str, &str)]) { + for &(target, base, expected) in cases { + let target = Path::new(target); + let expected = Path::new(expected); + + let borrowed_cwd = target.relative_with(base, cwd); + assert_eq!( + borrowed_cwd.as_os_str(), + expected.as_os_str(), + "borrowed cwd: target {target:?}, base {base:?}, cwd {cwd:?}", + ); + + let owned_cwd = target.relative_with(base, cwd.to_owned()); + assert_eq!( + owned_cwd.as_os_str(), + expected.as_os_str(), + "owned cwd: target {target:?}, base {base:?}, cwd {cwd:?}", + ); + } +} + +fn assert_cwd_independent_contexts(cases: &[(&str, &str, &str)]) { + for &(target, base, expected) in cases { + let target = Path::new(target); + let expected = Path::new(expected); + + let ambient = target.relative(base); + assert_eq!( + ambient.as_os_str(), + expected.as_os_str(), + "ambient: target {target:?}, base {base:?}", + ); + + let fallible = target.try_relative(base).expect("cwd-independent relative must not fail"); + assert_eq!( + fallible.as_os_str(), + expected.as_os_str(), + "fallible: target {target:?}, base {base:?}", + ); + + let explicit_borrowed = target.relative_with(base, Path::new("not/absolute")); + assert_eq!( + explicit_borrowed.as_os_str(), + expected.as_os_str(), + "explicit borrowed unused cwd: target {target:?}, base {base:?}", + ); + + let explicit_owned = target.relative_with(base, PathBuf::from("not/absolute")); + assert_eq!( + explicit_owned.as_os_str(), + expected.as_os_str(), + "explicit owned unused cwd: target {target:?}, base {base:?}", + ); + } +} + +#[cfg(unix)] +#[test] +fn unix_relative_with_has_fixed_context_results() { + assert_explicit_context_matrix( + Path::new("/workspace/project"), + &[ + ("src/lib.rs", ".", "src/lib.rs"), + (".", "src", ".."), + ("../shared/pkg", "src", "../../shared/pkg"), + ("src", "../shared", "../project/src"), + ("/opt/pkg", "src", "../../../opt/pkg"), + ("src", "/opt/pkg", "../../workspace/project/src"), + ("./dist/./temp/../assets/", "dist/assets", ""), + ("../../../../target/", ".", "../../target"), + ("/workspace/project/dist/", "/workspace/project", "dist"), + ("/workspace//project/./dist", "/workspace/project/chunks/..", "dist"), + ], + ); +} + +#[cfg(windows)] +#[test] +fn windows_relative_with_has_fixed_context_results() { + assert_explicit_context_matrix( + Path::new(r"C:\workspace\project"), + &[ + (r"src\lib.rs", ".", r"src\lib.rs"), + (".", "src", ".."), + (r"..\shared\pkg", "src", r"..\..\shared\pkg"), + ("src", r"..\shared", r"..\project\src"), + (r"C:\opt\pkg", "src", r"..\..\..\opt\pkg"), + ("src", r"C:\opt\pkg", r"..\..\workspace\project\src"), + (r".\dist\.\temp\..\assets\", r"dist\assets", ""), + (r"..\..\..\..\target\", ".", r"..\..\target"), + (r"\workspace\project\dist\", r"\workspace\project", "dist"), + (r"C:/workspace//project/./dist", r"C:\workspace\project\chunks\..", "dist"), + (r"D:\target\", "src", r"D:\target"), + (r"C:src", r"C:dist", r"..\src"), + (r"D:src", r"D:dist", r"..\src"), + (r"D:src", r"C:dist", r"D:src"), + ], + ); +} + +#[test] +fn cwd_independent_variants_have_fixed_results_and_ignore_explicit_cwd() { + #[cfg(unix)] + let cases = [ + ("/workspace/project/src", "/workspace/project", "src"), + ("/workspace/project/src/./index", "/workspace//project", "src/index"), + ("../../dist/assets", "../../dist/chunks", "../assets"), + ("foo/..", "./", ""), + ]; + #[cfg(windows)] + let cases = [ + (r"C:\workspace\project\src", r"C:\workspace\project", "src"), + (r"C:\workspace\project\src\.\index", r"C:\workspace\\project", r"src\index"), + (r"..\..\dist\assets", r"..\..\dist\chunks", r"..\assets"), + (r"\workspace\project\src", r"\workspace\project", "src"), + (r"C:dist\assets", r"c:dist\chunks", r"..\assets"), + (r"foo\..", r".\", ""), + ]; + + assert_cwd_independent_contexts(&cases); +} + #[cfg(unix)] #[test] fn unix_lexical_relative_matrix_has_exact_stable_results() { @@ -80,10 +202,10 @@ fn equal_unresolved_parent_counts_are_cwd_independent() { for base in *group { let actual = Path::new(target).relative(Path::new(base)); for cwd in CWDS { - let expected = oracle_with_cwd(Path::new(target), Path::new(base), Path::new(cwd)); + let explicit = relative_with_cwd(Path::new(target), Path::new(base), Path::new(cwd)); assert_eq!( actual.as_os_str(), - expected.as_os_str(), + explicit.as_os_str(), "target {target:?}, base {base:?}, cwd {cwd:?}", ); } @@ -110,8 +232,8 @@ fn unequal_unresolved_parent_counts_use_the_cwd_dependent_fallback() { for (target, base) in cases { let actual = Path::new(target).relative(Path::new(base)); - let expected = oracle_with_cwd(Path::new(target), Path::new(base), &cwd); - assert_eq!(actual.as_os_str(), expected.as_os_str(), "target {target:?}, base {base:?}"); + let explicit = relative_with_cwd(Path::new(target), Path::new(base), &cwd); + assert_eq!(actual.as_os_str(), explicit.as_os_str(), "target {target:?}, base {base:?}"); } } @@ -143,9 +265,9 @@ fn deep_unequal_parent_miss_uses_the_cwd_dependent_fallback() { let cwd = std::env::current_dir().expect("read cwd"); let base = deep_relative_path(1, &["chunks"]); let target = deep_relative_path(2, &["assets", "index.js"]); - let expected = oracle_with_cwd(&target, &base, &cwd); + let explicit = relative_with_cwd(&target, &base, &cwd); - assert_eq!(target.relative(base).as_os_str(), expected.as_os_str()); + assert_eq!(target.relative(base).as_os_str(), explicit.as_os_str()); } /// Ambient `relative` for Windows drive-relative inputs must use `try_absolutize` diff --git a/tests/relative_without_cwd.rs b/tests/relative_without_cwd.rs index 6e76933..39892f9 100644 --- a/tests/relative_without_cwd.rs +++ b/tests/relative_without_cwd.rs @@ -1,6 +1,7 @@ #![cfg(unix)] use std::{ + borrow::Cow, env, fs, panic::{AssertUnwindSafe, catch_unwind}, path::Path, @@ -18,23 +19,30 @@ fn cwd_independent_relative_inputs_do_not_read_the_current_directory() { fs::remove_dir(&doomed).expect("remove the child's current directory"); assert!(env::current_dir().is_err()); - assert_eq!( - Path::new("./dist/assets/./temp/../index.js") - .relative(Path::new("dist/./chunks/../chunks")) - .as_os_str(), - Path::new("../assets/index.js").as_os_str(), - ); - assert_eq!( - Path::new("../../dist/assets/index.js").relative(Path::new("../../dist/chunks")).as_os_str(), - Path::new("../assets/index.js").as_os_str(), - ); - assert!( - Path::new("../../dist/assets/index.js").try_relative(Path::new("../../dist/chunks")).is_ok(), - ); + let dirty = + Path::new("./dist/assets/./temp/../index.js").relative(Path::new("dist/./chunks/../chunks")); + assert_eq!(dirty.as_os_str(), Path::new("../assets/index.js").as_os_str()); + assert!(matches!(dirty, Cow::Owned(_))); + + let target = Path::new("../../dist/assets/index.js"); + let base = Path::new("../../dist/chunks"); + let relative = target.relative(base); + assert_eq!(relative.as_os_str(), Path::new("../assets/index.js").as_os_str()); + assert!(matches!(relative, Cow::Owned(_))); + + let fallible = target.try_relative(base).expect("equal leading parents do not need cwd"); + assert_eq!(fallible.as_os_str(), Path::new("../assets/index.js").as_os_str()); + assert!(matches!(fallible, Cow::Owned(_))); + assert!( Path::new("../../dist/assets/index.js").try_relative(Path::new("../dist/chunks")).is_err(), ); + let explicit = Path::new("../../dist/assets/index.js") + .relative_with(Path::new("../dist/chunks"), Path::new("/")); + assert_eq!(explicit.as_os_str(), Path::new("../assets/index.js").as_os_str()); + assert!(matches!(explicit, Cow::Owned(_))); + let cwd_dependent = catch_unwind(AssertUnwindSafe(|| { Path::new("../../dist/assets/index.js").relative(Path::new("../dist/chunks")) })); From 1f6421824a99d6207995c1420b8467d1fc715cfd Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Tue, 14 Jul 2026 19:06:16 +0800 Subject: [PATCH 2/4] docs: update semantic coverage status --- .agents/docs/testing-strategy.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.agents/docs/testing-strategy.md b/.agents/docs/testing-strategy.md index 24f62fa..0ee57c2 100644 --- a/.agents/docs/testing-strategy.md +++ b/.agents/docs/testing-strategy.md @@ -33,13 +33,15 @@ The matrix is interaction-aware. A platform root kind must be crossed with encod - On macOS and Linux, all 224,676 pairs from the bounded short absolute spelling set plus the multibyte set are checked through the production `relative_str` dispatch and the suffix-validation helper against the slow component oracle. - Unavailable Unix cwd coverage pins exact successful output and `Cow` variants for cwd-independent fallible calls, preserves the ambient error and panic checks for dependent calls, and proves that a valid explicit cwd still succeeds. -## Audited follow-up gaps +## Follow-up coverage status -- Add systematic `try_absolutize` value and `Cow` parity, exact Windows root-relative absolutization, and invalid-native ambient absolutization cases. -- Cross the full borrowed normalization corpus with `PathBuf::into_normalized`, including Windows prefix and trailing-separator forms and valid multibyte names. -- Complete the slash receiver-policy matrix for valid `str`, `Path`, and `PathBuf`, strict, fallible, and lossy conversion, non-normalizing spelling, `Cow` behavior, owned reuse, invalid recovery, and consuming strict panic. -- Add cached-cwd coverage through relative calculation and a failed-initialization retry case before changing current-directory state handling. -- Add direct Windows root-identifier tests for non-ASCII and invalid-wide comparison in UNC, device, and generic verbatim prefixes. +The remaining audit partitions are implemented in independent test-only Draft PRs so each contract can be reviewed and merged separately: + +- Slash receiver and policy coverage, including native-invalid recovery without making its storage identity a semantic requirement: [Draft PR #53](https://github.com/hyfdev/sugar_path/pull/53). +- Non-ASCII and invalid-wide Windows root identifiers across UNC, device, and generic verbatim prefixes: [Draft PR #54](https://github.com/hyfdev/sugar_path/pull/54). +- Systematic `try_absolutize` parity, exact Windows root-relative results, and native-invalid ambient absolutization: [Draft PR #55](https://github.com/hyfdev/sugar_path/pull/55). +- Cached-cwd relative behavior and failed-initialization retry: [Draft PR #56](https://github.com/hyfdev/sugar_path/pull/56). +- Independent fixed oracles for consuming normalization across Unix, every Windows prefix kind, trailing separators, and native-invalid encoding: [Draft PR #57](https://github.com/hyfdev/sugar_path/pull/57). ## Change rule From 1f5f7a1e06f91caba8b4a041fe41d24f2871e11b Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Tue, 14 Jul 2026 21:30:07 +0800 Subject: [PATCH 3/4] docs: update coverage merge status --- .agents/docs/testing-strategy.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/docs/testing-strategy.md b/.agents/docs/testing-strategy.md index 0ee57c2..de0ae3f 100644 --- a/.agents/docs/testing-strategy.md +++ b/.agents/docs/testing-strategy.md @@ -35,13 +35,13 @@ The matrix is interaction-aware. A platform root kind must be crossed with encod ## Follow-up coverage status -The remaining audit partitions are implemented in independent test-only Draft PRs so each contract can be reviewed and merged separately: +The remaining audit partitions are implemented in independent test-only PRs so each contract can be reviewed and merged separately: - Slash receiver and policy coverage, including native-invalid recovery without making its storage identity a semantic requirement: [Draft PR #53](https://github.com/hyfdev/sugar_path/pull/53). - Non-ASCII and invalid-wide Windows root identifiers across UNC, device, and generic verbatim prefixes: [Draft PR #54](https://github.com/hyfdev/sugar_path/pull/54). - Systematic `try_absolutize` parity, exact Windows root-relative results, and native-invalid ambient absolutization: [Draft PR #55](https://github.com/hyfdev/sugar_path/pull/55). -- Cached-cwd relative behavior and failed-initialization retry: [Draft PR #56](https://github.com/hyfdev/sugar_path/pull/56). -- Independent fixed oracles for consuming normalization across Unix, every Windows prefix kind, trailing separators, and native-invalid encoding: [Draft PR #57](https://github.com/hyfdev/sugar_path/pull/57). +- Cached-cwd relative behavior and failed-initialization retry: [merged PR #56](https://github.com/hyfdev/sugar_path/pull/56). +- Independent fixed oracles for consuming normalization across Unix, every Windows prefix kind, trailing separators, and native-invalid encoding: [merged PR #57](https://github.com/hyfdev/sugar_path/pull/57). ## Change rule From bc6952871ff7da59e1531ef2621198ba0cc5aaaf Mon Sep 17 00:00:00 2001 From: Yunfei He Date: Tue, 14 Jul 2026 21:33:56 +0800 Subject: [PATCH 4/4] ci: assert tested feature configurations --- .agents/docs/testing-strategy.md | 2 ++ .github/workflows/test.yaml | 4 ++++ tests/feature_configuration.rs | 20 ++++++++++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 tests/feature_configuration.rs diff --git a/.agents/docs/testing-strategy.md b/.agents/docs/testing-strategy.md index de0ae3f..d5bed9a 100644 --- a/.agents/docs/testing-strategy.md +++ b/.agents/docs/testing-strategy.md @@ -22,6 +22,7 @@ The matrix is interaction-aware. A platform root kind must be crossed with encod - Literal expected-output tables pin public behavior. One public method may be compared with another to test parity, but it is not an independent oracle; the covered partition must also have a literal expectation or a separate test oracle. - Generated tests must call the production dispatch as well as any private helper under review. A helper-only exhaustive test cannot prove that the public path still selects that helper. - Platform-gated coverage must not silently disappear. CI verifies the exact Rust host for Linux, Windows, and macOS ARM64, requires `neon` on macOS, and checks that a target-specific sentinel test is registered after the all-feature suite. +- Default and all-feature coverage must remain distinct. CI declares the expected `cached_current_dir` state for each test command, and an executable sentinel rejects workspace feature unification in the default run. - Exact native spelling and encoding are asserted directly. `Path` equality and lossy conversion are insufficient when trailing separators, drive spelling, or invalid encoding are observable. - `Cow` and consuming APIs assert ownership separately from value equality. A test that checks only output text does not protect the allocation-facing contract. @@ -50,6 +51,7 @@ Any change to a public contract, platform branch, native-encoding comparison, cl ## Durable evidence - [Native CI target and sentinel checks](../../.github/workflows/test.yaml) +- [Default/all-feature configuration sentinel](../../tests/feature_configuration.rs) - [Fixed explicit and fallible relative matrices](../../tests/relative_lexical.rs) - [Relative ownership and lifetime contracts](../../tests/relative_borrowing.rs) - [Unavailable-cwd relative behavior](../../tests/relative_without_cwd.rs) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index f49d39d..34129f1 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -92,8 +92,12 @@ jobs: - name: Clippy run: cargo clippy --locked --workspace --all-targets --all-features -- -D warnings - name: Test default features + env: + SUGAR_PATH_EXPECT_CACHED_CURRENT_DIR: "0" run: cargo test --locked -p sugar_path - name: Test all features + env: + SUGAR_PATH_EXPECT_CACHED_CURRENT_DIR: "1" run: cargo test --locked --workspace --all-features - name: Verify platform-specific test registration env: diff --git a/tests/feature_configuration.rs b/tests/feature_configuration.rs new file mode 100644 index 0000000..7217e22 --- /dev/null +++ b/tests/feature_configuration.rs @@ -0,0 +1,20 @@ +const EXPECTED_CACHED_CURRENT_DIR: &str = "SUGAR_PATH_EXPECT_CACHED_CURRENT_DIR"; + +#[test] +fn requested_cached_current_dir_configuration_is_active() { + let Some(expected) = std::env::var_os(EXPECTED_CACHED_CURRENT_DIR) else { + return; + }; + let expected = expected.to_str().expect("feature expectation must be valid UTF-8"); + let expected = match expected { + "0" => false, + "1" => true, + value => panic!("{EXPECTED_CACHED_CURRENT_DIR} must be 0 or 1, found {value:?}"), + }; + + assert_eq!( + cfg!(feature = "cached_current_dir"), + expected, + "cached_current_dir feature did not match the requested CI configuration", + ); +}