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/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ An explicit cwd must be absolute only when the operation needs it. An absolute r

## The cached current directory is process-lifetime state

With `cached_current_dir`, the first operation that actually needs the ordinary process cwd initializes a `OnceLock`, and later operations reuse that path. Absolute inputs and cwd-independent relative pairs do not initialize the cache. A later `std::env::set_current_dir` after initialization is invisible to this provider. Enable the feature only when the process treats cwd as stable, or use `absolutize_with` and `relative_with` for changing or externally managed cwd state.
With `cached_current_dir`, the first operation that actually needs the ordinary process cwd initializes a `OnceLock`, and later operations reuse that path. Absolute inputs and cwd-independent relative pairs do not initialize the cache. A later `std::env::set_current_dir`, directory removal, or permission change after initialization is invisible to this provider; it continues lexical resolution from the successful snapshot without revalidating that path. Enable the feature only when the process treats cwd as stable, or use `absolutize_with` and `relative_with` for changing or externally managed cwd state.

Windows drive-relative ambient resolution is a separate case: `C:foo` needs drive C's remembered cwd, not merely the process's single cached cwd. It goes through `std::path::absolute`, so the `cached_current_dir` feature must not substitute an unrelated drive context.

Expand Down Expand Up @@ -64,6 +64,7 @@ There is no public fused relative-to-string method. The strict owned-string pipe
- [Exact normalization identity tests](../../tests/path_identity.rs)
- [Explicit-cwd absolutization tests](../../tests/absolutize_with.rs)
- [Ambient cwd avoidance tests](../../tests/absolutize_without_cwd.rs)
- [Unavailable cwd after a successful lookup](../../tests/cwd_unavailable_after_cache.rs)
- [Slash-conversion tests](../../tests/to_slash.rs)
- [Consuming ownership tests](../../tests/owned_api.rs)
- [Relative-to-slash composition tests](../../tests/relative_to_slash.rs)
Expand Down
4 changes: 3 additions & 1 deletion .agents/docs/testing-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ 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.
- Absolute paths with native-invalid normal components prove that equal raw encoding cancels and distinct encoding with the same lossy rendering does not, through `relative`, `try_relative`, and `relative_with` on Unix and Windows. Unix explicit-cwd rows also prove that relative resolution preserves native-invalid cwd components for borrowed and owned context arguments.
- Windows drive-relative rows apply the same exact comparison before shared-context cancellation and preserve invalid-wide components while resolving against explicit borrowed and owned cwd arguments.
- 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 `Path`, `str`, and `String` calls, and proves that a valid explicit cwd still succeeds.
- Unavailable Unix cwd coverage pins exact successful output and `Cow` variants for cwd-independent fallible calls, compares dependent fallible calls with the direct `current_dir` error, preserves strict panic checks for `Path`, `str`, and `String` calls, and proves that a valid explicit cwd still succeeds.
- A Unix child process successfully reads cwd before removing that directory, then requires default mode to expose the subsequent ambient error while `cached_current_dir` continues producing exact owned results from the successful process-lifetime snapshot.
- Known-UTF-8 `str` and `String` receivers prove exact clean-trailing and dirty normalization contracts, receiver-only borrowing, explicit and ambient relative context forwarding, and cached-cwd behavior on Unix and Windows. Unix deleted-cwd rows also prove cwd-independent success and strict cwd-dependent panics for string receivers.
- A Linux child process starts in a real cwd containing an invalid native byte and requires `absolutize`, `try_absolutize`, `relative`, and `try_relative` to preserve that byte exactly under both cwd policies. Linux supplies this executable filesystem case because macOS rejects the invalid-byte directory name; explicit-cwd tests continue to cover native-invalid Unix composition independently of the host filesystem.

Expand Down Expand Up @@ -71,6 +72,7 @@ Any change to a public contract, platform branch, native-encoding comparison, cl
- [Independent bounded public normalization model](../../tests/public_normalize_model.rs)
- [Relative ownership and lifetime contracts](../../tests/relative_borrowing.rs)
- [Unavailable-cwd relative behavior](../../tests/relative_without_cwd.rs)
- [Unavailable cwd after a successful lookup](../../tests/cwd_unavailable_after_cache.rs)
- [Known-UTF-8 string receiver ownership](../../tests/string_receiver_contracts.rs)
- [Native-invalid ambient cwd preservation](../../tests/non_utf8_ambient_cwd.rs)
- [Native-invalid exact comparison](../../tests/invalid_encoding.rs)
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ jobs:
current_directory_is_cached_only_when_requested
pure_relative_calls_initialize_and_observe_cwd_policy
failed_cwd_lookup_does_not_poison_later_relative_calls
cwd_unavailable_after_successful_lookup_observes_cache_policy
non_utf8_ambient_cwd_is_preserved
requested_cached_current_dir_configuration_is_active
- os: windows-latest
Expand Down Expand Up @@ -108,6 +109,7 @@ jobs:
current_directory_is_cached_only_when_requested
pure_relative_calls_initialize_and_observe_cwd_policy
failed_cwd_lookup_does_not_poison_later_relative_calls
cwd_unavailable_after_successful_lookup_observes_cache_policy
requested_cached_current_dir_configuration_is_active
runs-on: ${{ matrix.os }}
steps:
Expand Down
31 changes: 26 additions & 5 deletions tests/absolutize_without_cwd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use std::{
borrow::Cow,
env, fs,
env, fs, io,
path::Path,
process::Command,
time::{SystemTime, UNIX_EPOCH},
Expand All @@ -12,11 +12,16 @@ use sugar_path::SugarPath;

const CHILD_ENV: &str = "SUGAR_PATH_TEST_WITHOUT_CWD";

fn assert_same_error(actual: &io::Error, expected: &io::Error, context: &str) {
assert_eq!(actual.kind(), expected.kind(), "{context} error kind");
assert_eq!(actual.raw_os_error(), expected.raw_os_error(), "{context} raw OS error");
}

#[test]
fn absolute_paths_do_not_require_current_directory() {
if let Some(doomed) = env::var_os(CHILD_ENV) {
fs::remove_dir(&doomed).expect("remove the child's current directory");
assert!(env::current_dir().is_err());
let cwd_error = env::current_dir().expect_err("the process cwd is unavailable");

let clean = Path::new("/sugar-path/file.js");
let clean_output = clean.absolutize();
Expand All @@ -34,9 +39,21 @@ fn absolute_paths_do_not_require_current_directory() {
assert!(matches!(dirty_try, Cow::Owned(_)));
assert_eq!(dirty_try.as_os_str(), Path::new("/file.js").as_os_str());

assert!(Path::new("relative.js").try_absolutize().is_err());
assert!("relative.js".try_absolutize().is_err());
assert!(String::from("relative.js").try_absolutize().is_err());
assert_same_error(
&Path::new("relative.js").try_absolutize().expect_err("relative Path should need cwd"),
&cwd_error,
"relative Path try_absolutize",
);
assert_same_error(
&"relative.js".try_absolutize().expect_err("relative str should need cwd"),
&cwd_error,
"relative str try_absolutize",
);
assert_same_error(
&String::from("relative.js").try_absolutize().expect_err("relative String should need cwd"),
&cwd_error,
"relative String try_absolutize",
);
assert!(std::panic::catch_unwind(|| Path::new("relative.js").absolutize()).is_err());
assert!(
std::panic::catch_unwind(|| {
Expand All @@ -46,6 +63,10 @@ fn absolute_paths_do_not_require_current_directory() {
.is_err(),
);

let explicit = Path::new("relative.js").absolutize_with(Path::new("/explicit-cwd"));
assert_eq!(explicit.as_ref(), Path::new("/explicit-cwd/relative.js"));
assert!(matches!(explicit, Cow::Owned(_)));

let clean_string = String::from("/sugar-path/string.js");
let clean_string_output =
clean_string.try_absolutize().expect("clean absolute String should not read cwd");
Expand Down
118 changes: 118 additions & 0 deletions tests/cwd_unavailable_after_cache.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#![cfg(unix)]

use std::{
borrow::Cow,
env, fs, io,
panic::{AssertUnwindSafe, catch_unwind},
path::{Path, PathBuf},
process::Command,
time::{SystemTime, UNIX_EPOCH},
};

use sugar_path::SugarPath;

const CHILD_ENV: &str = "SUGAR_PATH_CWD_UNAVAILABLE_AFTER_CACHE";

fn assert_same_error(actual: &io::Error, expected: &io::Error, context: &str) {
assert_eq!(actual.kind(), expected.kind(), "{context} error kind");
assert_eq!(actual.raw_os_error(), expected.raw_os_error(), "{context} raw OS error");
}

fn assert_owned(output: Cow<'_, Path>, expected: &Path, context: &str) {
assert_eq!(output.as_ref(), expected, "{context} value");
assert!(matches!(output, Cow::Owned(_)), "{context} should own");
}

#[test]
fn cwd_unavailable_after_successful_lookup_observes_cache_policy() {
if let Some(root) = env::var_os(CHILD_ENV) {
let root = PathBuf::from(root);
let doomed = root.join("doomed");
assert_eq!(env::current_dir().expect("read the child's initial cwd"), doomed);
let anchor = root.join("anchor");

assert_owned(
Path::new("initial.js").try_absolutize().expect("initialize cwd state"),
&doomed.join("initial.js"),
"initial cwd lookup",
);
fs::remove_dir(&doomed).expect("remove the child's current directory");
let cwd_error = env::current_dir().expect_err("the process cwd is unavailable");

let absolute = Path::new("later.js").try_absolutize();
let relative = Path::new("later.js").try_relative(&anchor);
if cfg!(feature = "cached_current_dir") {
assert_owned(
absolute.expect("cached absolutize should succeed"),
&doomed.join("later.js"),
"cached try_absolutize",
);
assert_owned(
relative.expect("cached relative should succeed"),
Path::new("../doomed/later.js"),
"cached try_relative",
);
assert_owned(
Path::new("later.js").absolutize(),
&doomed.join("later.js"),
"cached absolutize",
);
assert_owned(
Path::new("later.js").relative(&anchor),
Path::new("../doomed/later.js"),
"cached relative",
);
} else {
assert_same_error(
&absolute.expect_err("default absolutize should fail"),
&cwd_error,
"default try_absolutize",
);
assert_same_error(
&relative.expect_err("default relative should fail"),
&cwd_error,
"default try_relative",
);
assert!(
catch_unwind(|| Path::new("later.js").absolutize()).is_err(),
"default absolutize should panic",
);
assert!(
catch_unwind(AssertUnwindSafe(|| Path::new("later.js").relative(&anchor))).is_err(),
"default relative should panic",
);
}
return;
}

let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock is after the Unix epoch")
.as_nanos();
let root =
env::temp_dir().join(format!("sugar-path-cwd-unavailable-{}-{unique}", std::process::id()));
fs::create_dir_all(root.join("doomed")).expect("create the child cwd");
let root = fs::canonicalize(root).expect("resolve the temporary root spelling");
let doomed = root.join("doomed");

let output = Command::new(env::current_exe().expect("find the integration-test executable"))
.args([
"--exact",
"cwd_unavailable_after_successful_lookup_observes_cache_policy",
"--nocapture",
])
.env(CHILD_ENV, &root)
.current_dir(&doomed)
.output()
.expect("run the integration test in a child process");

if root.exists() {
fs::remove_dir_all(&root).expect("clean up the temporary root");
}
assert!(
output.status.success(),
"child test failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
33 changes: 27 additions & 6 deletions tests/relative_without_cwd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use std::{
borrow::Cow,
env, fs,
env, fs, io,
panic::{AssertUnwindSafe, catch_unwind},
path::Path,
process::Command,
Expand All @@ -13,11 +13,16 @@ use sugar_path::SugarPath;

const CHILD_ENV: &str = "SUGAR_PATH_RELATIVE_WITHOUT_CWD";

fn assert_same_error(actual: &io::Error, expected: &io::Error, context: &str) {
assert_eq!(actual.kind(), expected.kind(), "{context} error kind");
assert_eq!(actual.raw_os_error(), expected.raw_os_error(), "{context} raw OS error");
}

#[test]
fn cwd_independent_relative_inputs_do_not_read_the_current_directory() {
if let Some(doomed) = env::var_os(CHILD_ENV) {
fs::remove_dir(&doomed).expect("remove the child's current directory");
assert!(env::current_dir().is_err());
let cwd_error = env::current_dir().expect_err("the process cwd is unavailable");

let dirty =
Path::new("./dist/assets/./temp/../index.js").relative(Path::new("dist/./chunks/../chunks"));
Expand All @@ -34,11 +39,27 @@ fn cwd_independent_relative_inputs_do_not_read_the_current_directory() {
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(),
assert_same_error(
&Path::new("../../dist/assets/index.js")
.try_relative(Path::new("../dist/chunks"))
.expect_err("unequal leading parents should need cwd"),
&cwd_error,
"relative Path try_relative",
);
assert_same_error(
&"../../dist/assets/index.js"
.try_relative("../dist/chunks")
.expect_err("unequal leading parents should need cwd for str"),
&cwd_error,
"relative str try_relative",
);
assert_same_error(
&String::from("../../dist/assets/index.js")
.try_relative("../dist/chunks")
.expect_err("unequal leading parents should need cwd for String"),
&cwd_error,
"relative String try_relative",
);
assert!("../../dist/assets/index.js".try_relative("../dist/chunks").is_err());
assert!(String::from("../../dist/assets/index.js").try_relative("../dist/chunks").is_err(),);

let absolute_string = String::from("/workspace/src");
let absolute_relative =
Expand Down