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
3 changes: 2 additions & 1 deletion .agents/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
2 changes: 1 addition & 1 deletion .agents/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
58 changes: 58 additions & 0 deletions .agents/docs/testing-strategy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# 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.
- 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.

## 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.

## Follow-up coverage status

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: [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

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)
- [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)
- [Production dispatch and short-path oracle](../../src/impl_sugar_path.rs)
45 changes: 43 additions & 2 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -61,13 +73,42 @@ 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
- 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:
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`.
Expand Down
29 changes: 19 additions & 10 deletions src/impl_sugar_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
}

Expand Down Expand Up @@ -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);
}
}
}
Expand Down
20 changes: 20 additions & 0 deletions tests/feature_configuration.rs
Original file line number Diff line number Diff line change
@@ -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",
);
}
85 changes: 84 additions & 1 deletion tests/relative_borrowing.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use std::{borrow::Cow, path::Path};
use std::{
borrow::Cow,
path::{Path, PathBuf},
};

use sugar_path::SugarPath;

Expand All @@ -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() {
Expand Down
Loading
Loading