diff --git a/.agents/docs/conventions.md b/.agents/docs/conventions.md index 0b8cbbd..ce35765 100644 --- a/.agents/docs/conventions.md +++ b/.agents/docs/conventions.md @@ -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`. diff --git a/.agents/docs/testing-strategy.md b/.agents/docs/testing-strategy.md index feb11e3..0f9f2c7 100644 --- a/.agents/docs/testing-strategy.md +++ b/.agents/docs/testing-strategy.md @@ -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 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 0102fb8..1b9db81 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -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 @@ -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 @@ -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 diff --git a/README.md b/README.md index fcfa6b9..1b45116 100644 --- a/README.md +++ b/README.md @@ -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"); @@ -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. diff --git a/src/lib.rs b/src/lib.rs index 0cf1432..ce8877b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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"); @@ -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` diff --git a/src/sugar_path.rs b/src/sugar_path.rs index d36d47a..044a03c 100644 --- a/src/sugar_path.rs +++ b/src/sugar_path.rs @@ -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 /// /// ``` @@ -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 diff --git a/src/sugar_path_buf.rs b/src/sugar_path_buf.rs index de4f933..8a5bd7b 100644 --- a/src/sugar_path_buf.rs +++ b/src/sugar_path_buf.rs @@ -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 /// @@ -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; diff --git a/tests/path_identity.rs b/tests/path_identity.rs index d646b4b..d071c9f 100644 --- a/tests/path_identity.rs +++ b/tests/path_identity.rs @@ -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 { @@ -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)] @@ -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); + } +} diff --git a/tests/public_normalize_model.rs b/tests/public_normalize_model.rs index 613eb2b..2e937f4 100644 --- a/tests/public_normalize_model.rs +++ b/tests/public_normalize_model.rs @@ -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(); @@ -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(), + ); } } diff --git a/tests/wasm_wasi_contracts.rs b/tests/wasm_wasi_contracts.rs index a550d77..14c1c24 100644 --- a/tests/wasm_wasi_contracts.rs +++ b/tests/wasm_wasi_contracts.rs @@ -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] @@ -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"); }