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
2 changes: 1 addition & 1 deletion .agents/docs/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

When a test specifies emitted spelling, compare the result through `assert_eq_str!` or compare its `OsStr` representation exactly. Standard `Path` equality can hide distinctions that this crate deliberately exposes, including a trailing separator and Windows drive-letter case. Use direct `Path` equality only when the test is about standard path identity rather than SugarPath's exact output.

Public normalization is idempotent in encoded representation, but it does not promise one spelling for every pair of values that `Path` considers equal. It preserves one trailing separator on a non-root path and preserves the input spelling of Windows drive letters. For example, `foo` and `foo/` can compare equal as `Path` values while normalizing to different exact spellings. Keep both the trailing-separator and drive-spelling cases in [`tests/path_identity.rs`](../../tests/path_identity.rs).
Public normalization is idempotent in encoded representation, but it does not promise one spelling for every pair of values that `Path` considers equal. It preserves one trailing separator on a non-root path and preserves the input spelling of Windows drive letters. On Unix, `.` and `./` compare equal as `Path` values while remaining distinct stable normalized spellings; `foo` and `foo/` behave the same way. Keep the direct equality trap, trailing-separator, drive-spelling, and prefix-like Windows idempotence cases in [`tests/path_identity.rs`](../../tests/path_identity.rs).

Resolution has a separate exact-output rule. Absolutization and relative calculation strip non-root trailing separators, and equal relative inputs return an empty path rather than the `.` emitted by normalizing an empty path. Test these operations independently instead of deriving their expected spelling by calling public `normalize`.

Expand Down
2 changes: 1 addition & 1 deletion .agents/docs/testing-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ The matrix is interaction-aware. A platform root kind must be crossed with encod

## Normalization coverage checkpoint

- An independent public normalization model resolves its own root and component stack without calling SugarPath or `std::path::components`, then checks exact non-consuming and consuming results plus the non-consuming `Cow` contract for 6,560 Unix and 14,040 Windows inputs. The corpus crosses relative and rooted forms, drive-relative Windows paths, clean, redundant, forward-slash Windows and trailing separators, parents, current-directory components, dot-prefixed ordinary names, case-sensitive spelling, multibyte names, and Unix backslashes as ordinary bytes.
- An independent public normalization model resolves its own root and component stack without calling SugarPath or `std::path::components`, then checks exact non-consuming and consuming results, their exact idempotence, and the non-consuming `Cow` contract for 6,560 Unix and 14,040 Windows inputs. The corpus crosses relative and rooted forms, drive-relative Windows paths, clean, redundant, forward-slash Windows and trailing separators, parents, current-directory components, dot-prefixed ordinary names, case-sensitive spelling, multibyte names, and Unix backslashes as ordinary bytes. Direct tests prove that standard `Path` equality treats `.` and the native dot-plus-separator spelling as equal while SugarPath preserves their distinct exact outputs, Windows prefix-like ordinary components remain exactly idempotent across both normalization APIs, and WASIp1 checks the same fixed-point property through raw bytes.

## Relative-path coverage checkpoint

Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@ jobs:
impl_sugar_path::relative_str_tests::production_dispatch_and_suffix_validation_match_short_path_oracle
impl_sugar_path::normalization_classifier_tests::unix_classifier_matches_full_normalizer_for_short_arbitrary_bytes
public_normalize_matches_the_bounded_independent_model
normalization_is_exactly_idempotent
public_relative_with_matches_the_bounded_independent_model
unix::absolute_relative_common_prefix_compares_invalid_encoding_exactly
utf8_string_receivers_borrow_only_from_the_receiver
unix_path_equality_does_not_define_normalized_spelling
absolute_paths_do_not_require_current_directory
cwd_independent_relative_inputs_do_not_read_the_current_directory
current_directory_is_cached_only_when_requested
Expand All @@ -78,11 +80,14 @@ jobs:
windows_relative_with_has_fixed_context_results
impl_sugar_path::normalization_classifier_tests::windows_classifier_matches_full_normalizer_for_short_arbitrary_wide_units
public_normalize_matches_the_bounded_independent_model
normalization_is_exactly_idempotent
public_relative_with_matches_the_bounded_independent_model
encoded_root_identifiers_compare_only_ascii_case_and_exact_native_units
absolutization_preserves_encoded_root_identifiers
windows::absolute_relative_common_prefix_compares_invalid_encoding_exactly
utf8_string_receivers_borrow_only_from_the_receiver
windows_path_equality_does_not_define_normalized_spelling
windows_prefix_like_normal_components_remain_exactly_idempotent
current_directory_is_cached_only_when_requested
pure_relative_calls_initialize_and_observe_cwd_policy
requested_cached_current_dir_configuration_is_active
Expand All @@ -93,9 +98,11 @@ jobs:
absolute_relative_paths_cross_neon_block_boundaries_exactly
impl_sugar_path::normalization_classifier_tests::unix_classifier_matches_full_normalizer_for_short_arbitrary_bytes
public_normalize_matches_the_bounded_independent_model
normalization_is_exactly_idempotent
public_relative_with_matches_the_bounded_independent_model
unix::absolute_relative_common_prefix_compares_invalid_encoding_exactly
utf8_string_receivers_borrow_only_from_the_receiver
unix_path_equality_does_not_define_normalized_spelling
absolute_paths_do_not_require_current_directory
cwd_independent_relative_inputs_do_not_read_the_current_directory
current_directory_is_cached_only_when_requested
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ let input = PathBuf::from("workspace")

let normalized = input.normalize();
let expected = Path::new("workspace").join("dist").join("assets");
assert_eq!(&*normalized, expected);
assert_eq!(normalized.as_os_str(), expected.as_os_str());

// The receiver is the target: target.relative(base).
let relative = normalized.relative("workspace");
Expand Down Expand Up @@ -65,6 +65,8 @@ SugarPath rewrites path components only. It does not touch the filesystem, check

[`normalize()`] removes `.` components and redundant native separators, resolves `..` against preceding normal components, and preserves one trailing separator on a non-root path. An empty path normalizes to `.`.

Normalization is exactly idempotent in the host-native encoded representation: normalizing a result again does not change its Unix or WASIp1 bytes or its Windows wide units. This does not assign one spelling to every pair that standard `Path` comparison considers equal. On Unix, `Path::new(".") == Path::new("./")`, but their `OsStr` spellings differ; because SugarPath preserves one trailing separator, `.` and `./` remain distinct stable normalized outputs. Windows drive-letter spelling is likewise preserved. Compare `as_os_str()` or the native encoded representation when exact output spelling matters.

### Host-native syntax

Parsing always follows the compilation target's `std::path` rules. There is no caller-selected POSIX/Windows mode, and Windows syntax is not parsed on Unix. On Unix, `\` is an ordinary path byte. On Windows, `/` is normally a separator, but `/` inside a verbatim path component is literal and is left unchanged.
Expand Down
11 changes: 10 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
//!
//! let normalized = input.normalize();
//! let expected = Path::new("workspace").join("dist").join("assets");
//! assert_eq!(&*normalized, expected);
//! assert_eq!(normalized.as_os_str(), expected.as_os_str());
//!
//! // The receiver is the target: target.relative(base).
//! let relative = normalized.relative("workspace");
Expand Down Expand Up @@ -68,6 +68,15 @@
//! non-root path. [`SugarPath::relative`] returns an empty path for equal inputs
//! and removes a target's non-root trailing separator.
//!
//! Normalization is exactly idempotent in the host-native encoded
//! representation: normalizing a result again does not change its Unix or
//! WASIp1 bytes or its Windows wide units. This does not assign one spelling to
//! every pair that standard [`Path`](std::path::Path) comparison considers
//! equal. For example, `.` and `./` compare equal as Unix paths, but remain
//! distinct stable normalized spellings because one trailing separator is
//! preserved. Compare [`Path::as_os_str`](std::path::Path::as_os_str) or the
//! native encoded representation when exact output spelling matters.
//!
//! # Ownership and native encoding
//!
//! Borrowed [`Cow`](std::borrow::Cow) results never depend on a `base` or `cwd`
Expand Down
26 changes: 25 additions & 1 deletion src/sugar_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ pub trait SugarPath: private::Sealed {
/// current-directory result may borrow the static `.` path; other results
/// that require a new buffer are owned.
///
/// The exact host-native encoded result is idempotent: normalizing it again
/// preserves the same Unix or WASIp1 bytes or Windows wide units. Standard
/// [`Path`] equality compares components and may hide exact spelling
/// differences, so this does not imply one spelling for every pair of equal
/// `Path` values.
///
/// # Examples
///
/// ```
Expand All @@ -63,7 +69,25 @@ pub trait SugarPath: private::Sealed {
///
/// let input = PathBuf::from("workspace").join("src").join("..").join("dist");
/// let expected = Path::new("workspace").join("dist");
/// assert_eq!(&*input.normalize(), expected);
/// assert_eq!(input.normalize().as_os_str(), expected.as_os_str());
/// ```
///
/// Exact spelling is observable:
///
/// ```
/// # #[cfg(target_family = "unix")]
/// # {
/// use std::path::Path;
/// use sugar_path::SugarPath;
///
/// assert_eq!(Path::new("."), Path::new("./"));
///
/// let dot = Path::new(".").normalize();
/// let dot_slash = Path::new("./").normalize();
/// assert_ne!(dot.as_os_str(), dot_slash.as_os_str());
/// assert_eq!(dot.normalize().as_os_str(), dot.as_os_str());
/// assert_eq!(dot_slash.normalize().as_os_str(), dot_slash.as_os_str());
/// # }
/// ```
///
/// # Windows
Expand Down
6 changes: 4 additions & 2 deletions src/sugar_path_buf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ pub trait SugarPathBuf: private::Sealed {
/// Lexically normalizes this path while reusing its allocation when possible.
///
/// The result has the same component, native-separator, drive-spelling, and
/// trailing-separator semantics as [`crate::SugarPath::normalize`].
/// trailing-separator semantics as [`crate::SugarPath::normalize`]. Its exact
/// host-native encoded representation is likewise idempotent on Unix,
/// WASIp1, and Windows.
///
/// # Examples
///
Expand All @@ -33,7 +35,7 @@ pub trait SugarPathBuf: private::Sealed {
///
/// let input = PathBuf::from("workspace").join("src").join("..").join("dist");
/// let expected = Path::new("workspace").join("dist");
/// assert_eq!(input.into_normalized(), expected);
/// assert_eq!(input.into_normalized().as_os_str(), expected.as_os_str());
/// ```
#[must_use]
fn into_normalized(self) -> PathBuf;
Expand Down
70 changes: 62 additions & 8 deletions tests/path_identity.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,48 @@
#![cfg(any(unix, windows))]

use std::path::Path;
use std::path::{Path, PathBuf};

use sugar_path::SugarPath;
use sugar_path::{SugarPath, SugarPathBuf};

fn assert_exact_normalization(input: &str, expected: &str) {
let expected = Path::new(expected);
let normalized = Path::new(input).normalize();
assert_eq!(
normalized.as_os_str(),
expected.as_os_str(),
"normalizing {input:?} produced the wrong exact spelling",
);
assert_eq!(
normalized.normalize().as_os_str(),
normalized.as_os_str(),
"normalizing {input:?} a second time changed its representation",
);

let consumed = PathBuf::from(input).into_normalized();
assert_eq!(
consumed.as_os_str(),
expected.as_os_str(),
"consuming normalization of {input:?} produced the wrong exact spelling",
);
assert_eq!(
consumed.clone().into_normalized().as_os_str(),
consumed.as_os_str(),
"consuming normalization of {input:?} was not exactly idempotent",
);
}

fn assert_path_equal_spellings_remain_distinct(left: &str, right: &str) {
let left_path = Path::new(left);
let right_path = Path::new(right);
assert_eq!(left_path, right_path, "the fixture must compare equal as standard Path values");
assert_ne!(
left_path.as_os_str(),
right_path.as_os_str(),
"the fixture must retain distinct native spellings",
);
assert_exact_normalization(left, left);
assert_exact_normalization(right, right);
}

fn assert_normalization_is_idempotent(paths: &[&str]) {
for path in paths {
Expand Down Expand Up @@ -101,12 +141,10 @@ fn unix_node_style_trailing_spelling_is_exact() {

#[cfg(unix)]
#[test]
fn path_equal_trailing_spellings_may_normalize_differently() {
let plain = Path::new("foo");
let trailing = Path::new("foo/");
assert_eq!(plain, trailing);
assert_eq!(plain.normalize().as_os_str(), Path::new("foo").as_os_str());
assert_eq!(trailing.normalize().as_os_str(), Path::new("foo/").as_os_str());
fn unix_path_equality_does_not_define_normalized_spelling() {
for (plain, trailing) in [(".", "./"), ("foo", "foo/")] {
assert_path_equal_spellings_remain_distinct(plain, trailing);
}
}

#[cfg(windows)]
Expand All @@ -131,3 +169,19 @@ fn windows_node_style_trailing_and_drive_spelling_is_exact() {
assert_eq!(Path::new(input).normalize().as_os_str(), Path::new(expected).as_os_str());
}
}

#[cfg(windows)]
#[test]
fn windows_path_equality_does_not_define_normalized_spelling() {
for (left, right) in [(".", r".\"), ("foo", r"foo\"), (r"C:\foo", r"c:\foo")] {
assert_path_equal_spellings_remain_distinct(left, right);
}
}

#[cfg(windows)]
#[test]
fn windows_prefix_like_normal_components_remain_exactly_idempotent() {
for (input, expected) in [("...:/..", "."), ("..:/../", r".\")] {
assert_exact_normalization(input, expected);
}
}
16 changes: 16 additions & 0 deletions tests/public_normalize_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,14 @@ fn public_normalize_matches_the_bounded_independent_model() {
expected.as_os_str(),
non_consuming.as_os_str(),
);
assert_eq!(
non_consuming.normalize().as_os_str(),
non_consuming.as_os_str(),
"non-consuming idempotence: platform={}; mode={}; case={case:?}; input={input:?}; normalized={:?}",
platform_name(),
mode.name,
non_consuming.as_os_str(),
);

let input_is_expected = receiver.as_os_str() == expected.as_os_str();
let expected_is_static_dot = expected.as_os_str() == Path::new(".").as_os_str();
Expand Down Expand Up @@ -381,6 +389,14 @@ fn public_normalize_matches_the_bounded_independent_model() {
expected.as_os_str(),
consumed.as_os_str(),
);
assert_eq!(
consumed.clone().into_normalized().as_os_str(),
consumed.as_os_str(),
"consuming idempotence: platform={}; mode={}; case={case:?}; input={input:?}; normalized={:?}",
platform_name(),
mode.name,
consumed.as_os_str(),
);
}
}

Expand Down
33 changes: 25 additions & 8 deletions tests/wasm_wasi_contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,33 @@ use std::{

use sugar_path::{SugarPath, SugarPathBuf};

fn assert_normalizes_exactly(input: &Path, expected: &[u8]) {
let normalized = input.normalize();
assert_eq!(normalized.as_os_str().as_bytes(), expected);
assert_eq!(normalized.normalize().as_os_str().as_bytes(), expected);

let consumed = input.to_path_buf().into_normalized();
assert_eq!(consumed.as_os_str().as_bytes(), expected);
assert_eq!(consumed.into_normalized().as_os_str().as_bytes(), expected);
}

#[test]
fn normalize_preserves_wasi_spelling_and_native_encoding() {
assert_eq!(Path::new("workspace/src/lib.rs").normalize(), Path::new("workspace/src/lib.rs"));
assert_eq!(
Path::new("workspace/./src/../dist/assets/").normalize(),
Path::new("workspace/dist/assets/"),
let dot = Path::new(".");
let dot_slash = Path::new("./");
assert_eq!(dot, dot_slash);
assert_ne!(dot.as_os_str().as_bytes(), dot_slash.as_os_str().as_bytes());
assert_normalizes_exactly(dot, b".");
assert_normalizes_exactly(dot_slash, b"./");

assert_normalizes_exactly(Path::new("workspace/src/lib.rs"), b"workspace/src/lib.rs");
assert_normalizes_exactly(
Path::new("workspace/./src/../dist/assets/"),
b"workspace/dist/assets/",
);

let invalid = PathBuf::from(OsString::from_vec(b"workspace/invalid-\x80/./file".to_vec()));
assert_eq!(invalid.normalize().as_os_str().as_bytes(), b"workspace/invalid-\x80/file");
assert_normalizes_exactly(&invalid, b"workspace/invalid-\x80/file");
}

#[test]
Expand Down Expand Up @@ -65,13 +82,13 @@ fn slash_policies_cover_valid_and_invalid_wasi_encoding() {
#[test]
fn consuming_and_string_apis_preserve_wasi_results() {
assert_eq!(
PathBuf::from("workspace/./src/../dist/lib.rs").into_normalized(),
Path::new("workspace/dist/lib.rs"),
PathBuf::from("workspace/./src/../dist/lib.rs").into_normalized().as_os_str().as_bytes(),
b"workspace/dist/lib.rs",
);
assert_eq!(PathBuf::from("workspace/src/lib.rs").into_slash(), "workspace/src/lib.rs");

let owned = String::from("workspace/src/lib.rs");
assert_eq!(owned.as_path(), Path::new("workspace/src/lib.rs"));
assert_eq!(owned.normalize(), Path::new("workspace/src/lib.rs"));
assert_eq!(owned.normalize().as_os_str().as_bytes(), b"workspace/src/lib.rs");
assert_eq!(owned.to_slash(), "workspace/src/lib.rs");
}