Skip to main content

uv_fs/
path.rs

1use std::borrow::Cow;
2use std::ffi::OsString;
3use std::path::{Component, Path, PathBuf, Prefix};
4use std::sync::LazyLock;
5
6use either::Either;
7use path_slash::PathExt;
8
9/// The current working directory.
10#[expect(clippy::print_stderr)]
11pub static CWD: LazyLock<PathBuf> = LazyLock::new(|| {
12    std::env::current_dir().unwrap_or_else(|_e| {
13        eprintln!("Current directory does not exist");
14        std::process::exit(1);
15    })
16});
17
18pub trait Simplified {
19    /// Simplify a [`Path`].
20    ///
21    /// On Windows, this will strip the `\\?\` prefix from paths. On other platforms, it's a no-op.
22    fn simplified(&self) -> &Path;
23
24    /// Render a [`Path`] for display.
25    ///
26    /// On Windows, this will strip the `\\?\` prefix from paths. On other platforms, it's
27    /// equivalent to [`std::path::Display`].
28    fn simplified_display(&self) -> impl std::fmt::Display;
29
30    /// Canonicalize a path, stripping the `\\?\` prefix on Windows when possible.
31    fn simple_canonicalize(&self) -> std::io::Result<PathBuf>;
32
33    /// Render a [`Path`] for user-facing display.
34    ///
35    /// Like [`simplified_display`], but relativizes the path against the current working directory.
36    fn user_display(&self) -> impl std::fmt::Display;
37
38    /// Render a [`Path`] for user-facing display, where the [`Path`] is relative to a base path.
39    ///
40    /// If the [`Path`] is not relative to the base path, will attempt to relativize the path
41    /// against the current working directory.
42    fn user_display_from(&self, base: impl AsRef<Path>) -> impl std::fmt::Display;
43
44    /// Render a [`Path`] for user-facing display using a portable representation.
45    ///
46    /// Like [`user_display`], but uses a portable representation for relative paths.
47    fn portable_display(&self) -> impl std::fmt::Display;
48}
49
50impl<T: AsRef<Path>> Simplified for T {
51    fn simplified(&self) -> &Path {
52        dunce::simplified(self.as_ref())
53    }
54
55    fn simplified_display(&self) -> impl std::fmt::Display {
56        dunce::simplified(self.as_ref()).display()
57    }
58
59    fn simple_canonicalize(&self) -> std::io::Result<PathBuf> {
60        dunce::canonicalize(self.as_ref())
61    }
62
63    fn user_display(&self) -> impl std::fmt::Display {
64        let path = dunce::simplified(self.as_ref());
65
66        // If current working directory is root, display the path as-is.
67        if CWD.ancestors().nth(1).is_none() {
68            return path.display();
69        }
70
71        // Attempt to strip the current working directory, then the canonicalized current working
72        // directory, in case they differ.
73        let path = path.strip_prefix(CWD.simplified()).unwrap_or(path);
74
75        if path.as_os_str() == "" {
76            // Avoid printing an empty string for the current directory
77            return Path::new(".").display();
78        }
79
80        path.display()
81    }
82
83    fn user_display_from(&self, base: impl AsRef<Path>) -> impl std::fmt::Display {
84        let path = dunce::simplified(self.as_ref());
85
86        // If current working directory is root, display the path as-is.
87        if CWD.ancestors().nth(1).is_none() {
88            return path.display();
89        }
90
91        // Attempt to strip the base, then the current working directory, then the canonicalized
92        // current working directory, in case they differ.
93        let path = path
94            .strip_prefix(base.as_ref())
95            .unwrap_or_else(|_| path.strip_prefix(CWD.simplified()).unwrap_or(path));
96
97        if path.as_os_str() == "" {
98            // Avoid printing an empty string for the current directory
99            return Path::new(".").display();
100        }
101
102        path.display()
103    }
104
105    fn portable_display(&self) -> impl std::fmt::Display {
106        let path = dunce::simplified(self.as_ref());
107
108        // Attempt to strip the current working directory, then the canonicalized current working
109        // directory, in case they differ.
110        let path = path.strip_prefix(CWD.simplified()).unwrap_or(path);
111
112        // Use a portable representation for relative paths.
113        path.to_slash()
114            .map(Either::Left)
115            .unwrap_or_else(|| Either::Right(path.display()))
116    }
117}
118
119pub trait PythonExt {
120    /// Render a [`Path`] as a Python expression that evaluates to a [`str`].
121    ///
122    /// On Unix, paths are arbitrary byte strings, so this uses [`os.fsdecode()`] to produce a
123    /// surrogate-escaped `str`. On Windows, paths are encoded as UTF-16, so this returns a `str`
124    /// literal that preserves the original UTF-16 code units.
125    ///
126    /// [`str`]: https://docs.python.org/3/library/stdtypes.html#text-sequence-type-str
127    /// [`os.fsdecode()`]: https://docs.python.org/3/library/os.html#os.fsdecode
128    fn escape_for_python(&self) -> String;
129}
130
131impl<T: AsRef<Path>> PythonExt for T {
132    fn escape_for_python(&self) -> String {
133        escape_path_for_python(self)
134    }
135}
136
137/// Serialize a path as a Python expression that evaluates to a `str`.
138///
139/// Note: Due to the fun quirks *nix paths, how python handles them, and expectations of things that
140/// might consume those paths, this produces an expression wrapped in `__import__("os").fsdecode()`.
141#[cfg(unix)]
142fn escape_path_for_python<P: AsRef<Path>>(path: P) -> String {
143    use std::os::unix::ffi::OsStrExt;
144    format!(
145        r#"__import__("os").fsdecode(b"{}")"#,
146        path.as_ref().as_os_str().as_bytes().escape_ascii()
147    )
148}
149
150/// Serialize a path as a Python expression that evaluates to a `str`.
151#[cfg(windows)]
152fn escape_path_for_python<P: AsRef<Path>>(path: P) -> String {
153    use std::fmt::Write;
154    use std::os::windows::ffi::OsStrExt;
155
156    let mut literal = String::new();
157    literal.push('"');
158    for character in char::decode_utf16(path.as_ref().as_os_str().encode_wide()) {
159        match character {
160            Ok(character) if character.is_ascii() => {
161                literal.extend((character as u8).escape_ascii().map(char::from));
162            }
163            // `is_control` also covers the non-ASCII C1 range (`U+0080..=U+009F`).
164            Ok(character) if character.is_control() => {
165                let _ = write!(literal, r"\u{:04x}", u32::from(character));
166            }
167            Ok(character) => literal.push(character),
168            Err(error) => {
169                let _ = write!(literal, r"\u{:04x}", error.unpaired_surrogate());
170            }
171        }
172    }
173    literal.push('"');
174    literal
175}
176
177/// Normalize the `path` component of a URL for use as a file path.
178///
179/// For example, on Windows, transforms `C:\Users\ferris\wheel-0.42.0.tar.gz` to
180/// `/C:/Users/ferris/wheel-0.42.0.tar.gz`.
181///
182/// On other platforms, this is a no-op.
183pub fn normalize_url_path(path: &str) -> Cow<'_, str> {
184    // Apply percent-decoding to the URL.
185    let path = percent_encoding::percent_decode_str(path)
186        .decode_utf8()
187        .unwrap_or(Cow::Borrowed(path));
188
189    // Return the path.
190    if cfg!(windows) {
191        Cow::Owned(
192            path.strip_prefix('/')
193                .unwrap_or(&path)
194                .replace('/', std::path::MAIN_SEPARATOR_STR),
195        )
196    } else {
197        path
198    }
199}
200
201/// Return `true` if `path` is an absolute Windows drive path, regardless of the host platform.
202pub fn is_windows_absolute_path(path: &str) -> bool {
203    let [drive, b':', separator, ..] = path.as_bytes() else {
204        return false;
205    };
206    drive.is_ascii_alphabetic() && matches!(separator, b'/' | b'\\')
207}
208
209/// Normalize a path, removing things like `.` and `..`.
210///
211/// Source: <https://github.com/rust-lang/cargo/blob/b48c41aedbd69ee3990d62a0e2006edbb506a480/crates/cargo-util/src/paths.rs#L76C1-L109C2>
212///
213/// CAUTION: Assumes that the path is already absolute.
214///
215/// CAUTION: This does not resolve symlinks (unlike
216/// [`std::fs::canonicalize`]). This may cause incorrect or surprising
217/// behavior at times. This should be used carefully. Unfortunately,
218/// [`std::fs::canonicalize`] can be hard to use correctly, since it can often
219/// fail, or on Windows returns annoying device paths.
220///
221/// # Errors
222///
223/// When a relative path is provided with `..` components that extend beyond the base directory.
224/// For example, `./a/../../b` cannot be normalized because it escapes the base directory.
225pub fn normalize_absolute_path(path: &Path) -> Result<PathBuf, std::io::Error> {
226    let mut components = path.components().peekable();
227    let mut ret = components
228        .next_if_map_mut(|component| match component {
229            Component::Prefix(..) => Some(PathBuf::from(component.as_os_str())),
230            _ => None,
231        })
232        .unwrap_or_default();
233
234    for component in components {
235        match component {
236            Component::Prefix(..) => unreachable!(),
237            Component::RootDir => {
238                ret.push(component.as_os_str());
239            }
240            Component::CurDir => {}
241            Component::ParentDir => {
242                if !ret.pop() {
243                    return Err(std::io::Error::new(
244                        std::io::ErrorKind::InvalidInput,
245                        format!(
246                            "cannot normalize a relative path beyond the base directory: {}",
247                            path.display()
248                        ),
249                    ));
250                }
251            }
252            Component::Normal(c) => {
253                ret.push(c);
254            }
255        }
256    }
257    Ok(ret)
258}
259
260/// Returns `false` if [`Path::components`] discarded any bytes from `path`, without allocating.
261///
262/// [`Path::components`] silently strips interior `.` segments, repeated separators, and
263/// trailing separators. If the `path` length differs from the computed byte length from
264/// `path.components().collect()`, the path isn't normalized (or there is a special case we handle
265/// here, in which case we perform a redundant normalization pass later).
266fn path_equals_components(path: &Path) -> bool {
267    // We count the length in bytes; the encoding scheme doesn't matter as we count bytes in
268    // both expected and the input path
269    let mut expected_len = 0;
270    let mut next_needs_separator = false;
271    for component in path.components() {
272        let bytes = component.as_os_str().as_encoded_bytes();
273        // `PathBuf::push` inserts a separator between components unless the previous one
274        // already ends in one, or the new component is itself the root (which embeds it).
275        if next_needs_separator && !matches!(component, Component::RootDir) {
276            // Assumption: forward and backwards slashes encode with the same length.
277            expected_len += Path::new("/").as_os_str().as_encoded_bytes().len();
278        }
279        expected_len += bytes.len();
280        next_needs_separator = match component {
281            // The root dir is the slash.
282            Component::RootDir => false,
283            // Prefix has `RootDir` after it if it requires a slash.
284            Component::Prefix(_) => false,
285            _ => true,
286        };
287    }
288    expected_len == path.as_os_str().as_encoded_bytes().len()
289}
290
291/// Normalize a [`Cow`] path, removing `.`, `..`, repeated separators (`//`), and trailing slashes.
292///
293/// Paths that point to the current directory (`.` or `.\.`) are normalized to the empty path.
294///
295/// When the path is already normalized, returns it as-is without allocating.
296pub fn normalize_path<'path>(path: impl Into<Cow<'path, Path>>) -> Cow<'path, Path> {
297    let path = path.into();
298    // A path with leading `.` or `..` is not normalized.
299    if path
300        .components()
301        .any(|component| matches!(component, Component::ParentDir | Component::CurDir))
302    {
303        return Cow::Owned(normalized(&path));
304    }
305
306    // A path with non-leading `.`, repeated separators (`//`) or trailing slashes is not
307    // normalized.
308    if !path_equals_components(&path) {
309        return Cow::Owned(normalized(&path));
310    }
311
312    // Fast path: already normalized, return as-is.
313    path
314}
315
316/// Normalize `path`, returning it when it remains strictly under a non-empty `root`.
317///
318/// This comparison is lexical and does not resolve symlinks. The returned path is normalized, and
319/// equality with `root` is rejected.
320pub fn normalize_path_under(path: impl AsRef<Path>, root: impl AsRef<Path>) -> Option<PathBuf> {
321    let path = normalize_path(path.as_ref()).into_owned();
322    let root = normalize_path(root.as_ref());
323
324    if root.as_os_str().is_empty() || path.as_path() == root.as_ref() {
325        None
326    } else {
327        path.starts_with(root.as_ref()).then_some(path)
328    }
329}
330
331/// Normalize a [`Path`].
332///
333/// Unlike [`normalize_absolute_path`], this works with relative paths and does never error.
334///
335/// Note that we can theoretically go beyond the root dir here (e.g. `/usr/../../foo` becomes
336/// `/../foo`), but that's not a (correctness) problem, we will fail later with a file not found
337/// error with a path computed from the user's input.
338///
339/// # Examples
340///
341/// In: `../../workspace-git-path-dep-test/packages/c/../../packages/d`
342/// Out: `../../workspace-git-path-dep-test/packages/d`
343///
344/// In: `workspace-git-path-dep-test/packages/c/../../packages/d`
345/// Out: `workspace-git-path-dep-test/packages/d`
346///
347/// In: `./a/../../b`
348fn normalized(path: &Path) -> PathBuf {
349    let mut normalized = PathBuf::new();
350    for component in path.components() {
351        match component {
352            Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
353                // Preserve filesystem roots and regular path components.
354                normalized.push(component);
355            }
356            Component::ParentDir => {
357                match normalized.components().next_back() {
358                    None | Some(Component::ParentDir | Component::RootDir) => {
359                        // Preserve leading and above-root `..`
360                        normalized.push(component);
361                    }
362                    Some(Component::Normal(_) | Component::Prefix(_) | Component::CurDir) => {
363                        // Remove inner `..`
364                        normalized.pop();
365                    }
366                }
367            }
368            Component::CurDir => {
369                // Remove `.`
370            }
371        }
372    }
373    normalized
374}
375
376/// Compute a path describing `path` relative to `base`.
377///
378/// `lib/python/site-packages/foo/__init__.py` and `lib/python/site-packages` -> `foo/__init__.py`
379/// `lib/marker.txt` and `lib/python/site-packages` -> `../../marker.txt`
380/// `bin/foo_launcher` and `lib/python/site-packages` -> `../../../bin/foo_launcher`
381///
382/// Returns `Err` if there is no relative path between `path` and `base` (for example, if the paths
383/// are on different drives on Windows).
384pub fn relative_to(
385    path: impl AsRef<Path>,
386    base: impl AsRef<Path>,
387) -> Result<PathBuf, std::io::Error> {
388    // Normalize both paths, to avoid intermediate `..` components.
389    let path = normalize_path(path.as_ref());
390    let base = normalize_path(base.as_ref());
391
392    // Find the longest common prefix, and also return the path stripped from that prefix
393    let (stripped, common_prefix) = base
394        .ancestors()
395        .find_map(|ancestor| {
396            // Simplifying removes the UNC path prefix on windows.
397            dunce::simplified(&path)
398                .strip_prefix(dunce::simplified(ancestor))
399                .ok()
400                .map(|stripped| (stripped, ancestor))
401        })
402        .ok_or_else(|| {
403            std::io::Error::other(format!(
404                "Trivial strip failed: `{}` vs. `{}`",
405                path.simplified_display(),
406                base.simplified_display()
407            ))
408        })?;
409
410    // go as many levels up as required
411    let levels_up = base.components().count() - common_prefix.components().count();
412    let up = std::iter::repeat_n("..", levels_up).collect::<PathBuf>();
413
414    Ok(up.join(stripped))
415}
416
417/// Find the root of the nearest Git repository containing `path`.
418///
419/// A `.git` directory or file is treated as a repository marker to support both regular
420/// repositories and linked worktrees.
421pub fn find_git_repository_root(path: &Path) -> Option<&Path> {
422    // TODO: Consider supporting GIT_CEILING_DIRECTORIES here.
423    path.ancestors()
424        .find(|ancestor| ancestor.join(".git").exists())
425}
426
427/// Try to compute a path relative to `base` if `should_relativize` is true, otherwise return
428/// the absolute path. Falls back to absolute if relativization fails.
429pub fn try_relative_to_if(
430    path: impl AsRef<Path>,
431    base: impl AsRef<Path>,
432    should_relativize: bool,
433) -> Result<PathBuf, std::io::Error> {
434    if should_relativize {
435        relative_to(&path, &base).or_else(|_| std::path::absolute(path.as_ref()))
436    } else {
437        std::path::absolute(path.as_ref())
438    }
439}
440
441/// Convert a [`Path`] to a Windows `verbatim` path (prefixed with `\\?\`) when possible to bypass
442/// Win32 path normalization such as [`MAX_PATH`] and removed trailing characters (dot, space).
443/// Other characters as defined by [`Path.GetInvalidFileNameChars`] are still prohibited. This
444/// function will attempt to perform path normalization similar to Win32 default normalization
445/// without triggering the existing Win32 limitations.
446///
447/// Only [`Prefix::UNC`] and [`Prefix::Disk`] conversion compatible components are supported.
448///   * [`Prefix::UNC`] `\\server\share` becomes `\\?\UNC\server\share`
449///   * [`Prefix::Disk`] `DriveLetter:` becomes `\\?\DriveLetter:`
450///
451/// Other representations do not yield a `verbatim` path. The following cases are returned as-is:
452///   * Non-Windows systems.
453///   * Device paths such as those starting with `\\.\`.
454///   * Paths already prefixed with `\\?\` or `\\?\UNC\`.
455///
456/// WARNING: Adding the `\\?\` prefix effectively skips Win32 default path normalization. Even
457/// though it allows operations on paths that are normally unavailable, it can also be used to
458/// create entries that can potentially lead to further issues with operations that expect
459/// normalization such as symbolic links, junctions or reparse points.
460///
461/// [`MAX_PATH`]: https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation
462/// [`Path.GetInvalidFileNameChars`]: https://learn.microsoft.com/en-us/dotnet/api/system.io.path.getinvalidfilenamechars
463///
464/// See:
465///   * <https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file>
466///   * <https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats>
467pub fn verbatim_path(path: &Path) -> Cow<'_, Path> {
468    if !cfg!(windows) {
469        return Cow::Borrowed(path);
470    }
471
472    // Attempt to resolve a fully qualified path just like Win32 path normalization would.
473    // std::path::absolute calls GetFullPathNameW which defeats the purpose of this function
474    // as it results in Win32 default path normalization.
475    let resolved_path = if path.is_relative() {
476        Cow::Owned(CWD.join(path))
477    } else {
478        Cow::Borrowed(path)
479    };
480
481    // Fast Path: we only support verbatim conversion for Prefix::UNC and Prefix::Disk
482    if let Some(Component::Prefix(prefix)) = resolved_path.components().next() {
483        match prefix.kind() {
484            Prefix::UNC(..) | Prefix::Disk(_) => {},
485            // return as-is as there's no verbatim equivalent for `\\.\device`
486            Prefix::DeviceNS(_)
487            // return as-is as its already verbatim
488            | Prefix::Verbatim(_)
489            | Prefix::VerbatimDisk(_)
490            | Prefix::VerbatimUNC(..) => return Cow::Borrowed(path)
491        }
492    }
493
494    // Resolve relative directory components while avoiding default Win32 path normalization
495    let normalized_path = normalized(&resolved_path);
496
497    let mut components = normalized_path.components();
498    let Some(Component::Prefix(prefix)) = components.next() else {
499        return Cow::Borrowed(path);
500    };
501
502    match prefix.kind() {
503        // `DriveLetter:` -> `\\?\DriveLetter:`
504        Prefix::Disk(_) => {
505            let mut result = OsString::from(r"\\?\");
506            result.push(normalized_path.as_os_str()); // e.g. "C:"
507            Cow::Owned(PathBuf::from(result))
508        }
509        // `\\server\share` -> `\\?\UNC\server\share`
510        Prefix::UNC(server, share) => {
511            let mut result = OsString::from(r"\\?\UNC\");
512            result.push(server);
513            result.push(r"\");
514            result.push(share);
515            for component in components {
516                match component {
517                    Component::RootDir => {} // being cautious
518                    Component::Prefix(_) => {
519                        debug_assert!(false, "prefix already consumed");
520                    }
521                    Component::CurDir | Component::ParentDir => {
522                        debug_assert!(false, "path already normalized");
523                    }
524                    Component::Normal(_) => {
525                        result.push(r"\");
526                        result.push(component.as_os_str());
527                    }
528                }
529            }
530            Cow::Owned(PathBuf::from(result))
531        }
532        Prefix::DeviceNS(_)
533        | Prefix::Verbatim(_)
534        | Prefix::VerbatimDisk(_)
535        | Prefix::VerbatimUNC(..) => {
536            debug_assert!(false, "skipped via fast path");
537            Cow::Borrowed(path)
538        }
539    }
540}
541
542/// A path that can be serialized and deserialized in a portable way by converting Windows-style
543/// backslashes to forward slashes, and using a `.` for an empty path.
544///
545/// This implementation assumes that the path is valid UTF-8; otherwise, it won't roundtrip.
546#[derive(Debug, Clone, PartialEq, Eq)]
547pub struct PortablePath<'a>(&'a Path);
548
549#[derive(Debug, Clone, PartialEq, Eq)]
550pub struct PortablePathBuf(Box<Path>);
551
552#[cfg(feature = "schemars")]
553impl schemars::JsonSchema for PortablePathBuf {
554    fn schema_name() -> Cow<'static, str> {
555        Cow::Borrowed("PortablePathBuf")
556    }
557
558    fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
559        PathBuf::json_schema(_gen)
560    }
561}
562
563impl AsRef<Path> for PortablePath<'_> {
564    fn as_ref(&self) -> &Path {
565        self.0
566    }
567}
568
569impl<'a, T> From<&'a T> for PortablePath<'a>
570where
571    T: AsRef<Path> + ?Sized,
572{
573    fn from(path: &'a T) -> Self {
574        PortablePath(path.as_ref())
575    }
576}
577
578impl std::fmt::Display for PortablePath<'_> {
579    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
580        let path = self.0.to_slash_lossy();
581        if path.is_empty() {
582            write!(f, ".")
583        } else {
584            write!(f, "{path}")
585        }
586    }
587}
588
589impl std::fmt::Display for PortablePathBuf {
590    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
591        let path = self.0.to_slash_lossy();
592        if path.is_empty() {
593            write!(f, ".")
594        } else {
595            write!(f, "{path}")
596        }
597    }
598}
599
600impl From<&str> for PortablePathBuf {
601    fn from(path: &str) -> Self {
602        if path == "." {
603            Self(PathBuf::new().into_boxed_path())
604        } else {
605            Self(PathBuf::from(path).into_boxed_path())
606        }
607    }
608}
609
610impl From<PortablePathBuf> for Box<Path> {
611    fn from(portable: PortablePathBuf) -> Self {
612        portable.0
613    }
614}
615
616impl From<Box<Path>> for PortablePathBuf {
617    fn from(path: Box<Path>) -> Self {
618        Self(path)
619    }
620}
621
622impl<'a> From<&'a Path> for PortablePathBuf {
623    fn from(path: &'a Path) -> Self {
624        Box::<Path>::from(path).into()
625    }
626}
627
628#[cfg(feature = "serde")]
629impl serde::Serialize for PortablePathBuf {
630    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
631    where
632        S: serde::ser::Serializer,
633    {
634        self.to_string().serialize(serializer)
635    }
636}
637
638#[cfg(feature = "serde")]
639impl serde::Serialize for PortablePath<'_> {
640    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
641    where
642        S: serde::ser::Serializer,
643    {
644        self.to_string().serialize(serializer)
645    }
646}
647
648#[cfg(feature = "serde")]
649impl<'de> serde::de::Deserialize<'de> for PortablePathBuf {
650    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
651    where
652        D: serde::de::Deserializer<'de>,
653    {
654        let s = <Cow<'_, str>>::deserialize(deserializer)?;
655        if s == "." {
656            Ok(Self(PathBuf::new().into_boxed_path()))
657        } else {
658            Ok(Self(PathBuf::from(s.as_ref()).into_boxed_path()))
659        }
660    }
661}
662
663impl AsRef<Path> for PortablePathBuf {
664    fn as_ref(&self) -> &Path {
665        &self.0
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use std::assert_matches;
672
673    use super::*;
674
675    #[test]
676    fn test_find_git_repository_root() -> std::io::Result<()> {
677        let temp_dir = tempfile::tempdir()?;
678
679        let repository = temp_dir.path().join("repository");
680        let nested = repository.join("packages/project");
681        fs_err::create_dir_all(repository.join(".git"))?;
682        fs_err::create_dir_all(&nested)?;
683        assert_eq!(
684            find_git_repository_root(&nested),
685            Some(repository.as_path())
686        );
687
688        let worktree = temp_dir.path().join("worktree");
689        let nested = worktree.join("packages/project");
690        fs_err::create_dir_all(&nested)?;
691        fs_err::write(worktree.join(".git"), "gitdir: ../repository/.git")?;
692        assert_eq!(find_git_repository_root(&nested), Some(worktree.as_path()));
693
694        Ok(())
695    }
696
697    #[test]
698    fn test_normalize_url() {
699        if cfg!(windows) {
700            assert_eq!(
701                normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
702                "C:\\Users\\ferris\\wheel-0.42.0.tar.gz"
703            );
704        } else {
705            assert_eq!(
706                normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
707                "/C:/Users/ferris/wheel-0.42.0.tar.gz"
708            );
709        }
710
711        if cfg!(windows) {
712            assert_eq!(
713                normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
714                ".\\ferris\\wheel-0.42.0.tar.gz"
715            );
716        } else {
717            assert_eq!(
718                normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
719                "./ferris/wheel-0.42.0.tar.gz"
720            );
721        }
722
723        if cfg!(windows) {
724            assert_eq!(
725                normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
726                ".\\wheel cache\\wheel-0.42.0.tar.gz"
727            );
728        } else {
729            assert_eq!(
730                normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
731                "./wheel cache/wheel-0.42.0.tar.gz"
732            );
733        }
734    }
735
736    #[test]
737    fn test_normalize_path() {
738        let path = Path::new("/a/b/../c/./d");
739        let normalized = normalize_absolute_path(path).unwrap();
740        assert_eq!(normalized, Path::new("/a/c/d"));
741
742        let path = Path::new("/a/../c/./d");
743        let normalized = normalize_absolute_path(path).unwrap();
744        assert_eq!(normalized, Path::new("/c/d"));
745
746        // This should be an error.
747        let path = Path::new("/a/../../c/./d");
748        let err = normalize_absolute_path(path).unwrap_err();
749        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
750    }
751
752    #[test]
753    fn test_relative_to() {
754        assert_eq!(
755            relative_to(
756                Path::new("/home/ferris/carcinization/lib/python/site-packages/foo/__init__.py"),
757                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
758            )
759            .unwrap(),
760            Path::new("foo/__init__.py")
761        );
762        assert_eq!(
763            relative_to(
764                Path::new("/home/ferris/carcinization/lib/marker.txt"),
765                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
766            )
767            .unwrap(),
768            Path::new("../../marker.txt")
769        );
770        assert_eq!(
771            relative_to(
772                Path::new("/home/ferris/carcinization/bin/foo_launcher"),
773                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
774            )
775            .unwrap(),
776            Path::new("../../../bin/foo_launcher")
777        );
778    }
779
780    #[test]
781    fn test_normalize_relative() {
782        let cases = [
783            (
784                "../../workspace-git-path-dep-test/packages/c/../../packages/d",
785                "../../workspace-git-path-dep-test/packages/d",
786            ),
787            (
788                "workspace-git-path-dep-test/packages/c/../../packages/d",
789                "workspace-git-path-dep-test/packages/d",
790            ),
791            ("./a/../../b", "../b"),
792            ("/usr/../../foo", "/../foo"),
793            // Interior `.` segments (stripped by `Path::components`).
794            ("foo/./bar", "foo/bar"),
795            ("/a/./b/./c", "/a/b/c"),
796            ("./foo/bar", "foo/bar"),
797            (".", ""),
798            ("./.", ""),
799            ("foo/.", "foo"),
800            // Repeated separators (also stripped by `Path::components`).
801            ("foo//bar", "foo/bar"),
802            ("/a///b//c", "/a/b/c"),
803            // Mixed `.` and `..`.
804            ("foo/./../bar", "bar"),
805            ("foo/bar/./../baz", "foo/baz"),
806            // Already-normalized paths.
807            ("foo/bar", "foo/bar"),
808            ("/a/b/c", "/a/b/c"),
809            ("", ""),
810        ];
811        for (input, expected) in cases {
812            assert_eq!(
813                normalize_path(Path::new(input)),
814                Path::new(expected),
815                "input: {input:?}"
816            );
817        }
818
819        // Verify the fast path: already-normalized inputs are returned borrowed.
820        for already_normalized in ["foo/bar", "/a/b/c", "foo", "/", ""] {
821            let path = Path::new(already_normalized);
822            assert_matches!(
823                normalize_path(path),
824                Cow::Borrowed(_),
825                "expected borrowed for {already_normalized:?}"
826            );
827        }
828    }
829
830    #[test]
831    fn test_normalize_path_under() {
832        assert_eq!(
833            normalize_path_under("scripts/script", "scripts"),
834            Some(PathBuf::from("scripts/script"))
835        );
836        assert_eq!(
837            normalize_path_under("/scripts/script", "/scripts"),
838            Some(PathBuf::from("/scripts/script"))
839        );
840        assert_eq!(
841            normalize_path_under("scripts/nested/../script", "scripts"),
842            Some(PathBuf::from("scripts/script"))
843        );
844        assert_eq!(
845            normalize_path_under("/scripts/nested/../script", "/scripts"),
846            Some(PathBuf::from("/scripts/script"))
847        );
848        assert_eq!(normalize_path_under("scripts/.", "scripts"), None);
849        assert_eq!(normalize_path_under("/scripts/.", "/scripts"), None);
850        assert_eq!(normalize_path_under("scripts/../script", "scripts"), None);
851        assert_eq!(normalize_path_under("/scripts/../script", "/scripts"), None);
852        assert_eq!(normalize_path_under("scripts/script", "."), None);
853        assert_eq!(normalize_path_under("scripts/script", ""), None);
854    }
855
856    #[test]
857    fn test_normalize_trailing_path_separator() {
858        let cases = [
859            (
860                "/home/ferris/projects/python/",
861                "/home/ferris/projects/python",
862            ),
863            ("python/", "python"),
864            ("/", "/"),
865            ("foo/bar/", "foo/bar"),
866            ("foo//", "foo"),
867        ];
868        for (input, expected) in cases {
869            assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
870        }
871    }
872
873    #[test]
874    #[cfg(windows)]
875    fn test_normalize_windows() {
876        let cases = [
877            (
878                r"C:\Users\Ferris\projects\python\",
879                r"C:\Users\Ferris\projects\python",
880            ),
881            (r"C:\foo\.\bar", r"C:\foo\bar"),
882            (r"C:\foo\\bar", r"C:\foo\bar"),
883            (r"C:\foo\bar\..\baz", r"C:\foo\baz"),
884            (r"foo\.\bar", r"foo\bar"),
885            (r"C:foo", r"C:foo"),
886            (r"C:\foo", r"C:\foo"),
887            (r"C:\\foo", r"C:\foo"),
888            (r"\\?\C:foo", r"\\?\C:foo"),
889            (r"\\?\C:\foo", r"\\?\C:\foo"),
890            (r"\\?\C:\\foo", r"\\?\C:\foo"),
891            (r"\\server\share\foo", r"\\server\share\foo"),
892        ];
893        for (input, expected) in cases {
894            assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
895        }
896    }
897
898    #[cfg(windows)]
899    #[test]
900    fn test_verbatim_path() {
901        let relative_path = format!(r"\\?\{}\path\to\logging.", CWD.simplified_display());
902        let relative_root = format!(
903            r"\\?\{}\path\to\logging.",
904            CWD.components()
905                .next()
906                .expect("expected a drive letter prefix")
907                .simplified_display()
908        );
909        let cases = [
910            // Non-Verbatim disk
911            (r"C:\path\to\logging.", r"\\?\C:\path\to\logging."),
912            (r"C:\path\to\.\logging.", r"\\?\C:\path\to\logging."),
913            (r"C:\path\to\..\to\logging.", r"\\?\C:\path\to\logging."),
914            (r"C:/path/to/../to/./logging.", r"\\?\C:\path\to\logging."),
915            (r"C:path\to\..\to\logging.", r"\\?\C:path\to\logging."), // @TODO(samypr100) we do not support expanding drive-relative paths
916            (r".\path\to\.\logging.", relative_path.as_str()),
917            (r"path\to\..\to\logging.", relative_path.as_str()),
918            (r"./path/to/logging.", relative_path.as_str()),
919            (r"\path\to\logging.", relative_root.as_str()),
920            // Non-Verbatim UNC
921            (
922                r"\\127.0.0.1\c$\path\to\logging.",
923                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
924            ),
925            (
926                r"\\127.0.0.1\c$\path\to\.\logging.",
927                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
928            ),
929            (
930                r"\\127.0.0.1\c$\path\to\..\to\logging.",
931                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
932            ),
933            (
934                r"//127.0.0.1/c$/path/to/../to/./logging.",
935                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
936            ),
937            // Verbatim Disk
938            (r"\\?\C:\path\to\logging.", r"\\?\C:\path\to\logging."),
939            // Verbatim UNC
940            (
941                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
942                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
943            ),
944            // Device Namespace
945            (r"\\.\PhysicalDrive0", r"\\.\PhysicalDrive0"),
946            (r"\\.\NUL", r"\\.\NUL"),
947        ];
948
949        for (input, expected) in cases {
950            assert_eq!(verbatim_path(Path::new(input)), Path::new(expected));
951        }
952    }
953
954    #[test]
955    #[cfg(unix)]
956    fn test_path_escape_for_python() {
957        use std::ffi::OsStr;
958        use std::os::unix::ffi::OsStrExt;
959
960        // A *nix path can contain any byte except NUL.
961        // Each expected value is intentionally Python pastable for verification.
962        for (range, expected) in [
963            (
964                1..=0x5e,
965                r##"__import__("os").fsdecode(b"/foo/\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f !\"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^/bar")"##,
966            ),
967            (
968                0x5f..=0xa3,
969                r#"__import__("os").fsdecode(b"/foo/_`abcdefghijklmnopqrstuvwxyz{|}~\x7f\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f\xa0\xa1\xa2\xa3/bar")"#,
970            ),
971            (
972                0xa4..=0xd1,
973                r#"__import__("os").fsdecode(b"/foo/\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf\xd0\xd1/bar")"#,
974            ),
975            (
976                0xd2..=u8::MAX,
977                r#"__import__("os").fsdecode(b"/foo/\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff/bar")"#,
978            ),
979        ] {
980            let bytes = b"/foo/"
981                .iter()
982                .copied()
983                .chain(range)
984                .chain(b"/bar".iter().copied())
985                .collect::<Box<[_]>>();
986            let path = Path::new(OsStr::from_bytes(&bytes));
987            assert_eq!(path.escape_for_python(), expected);
988        }
989    }
990
991    #[cfg(windows)]
992    #[test]
993    fn test_path_escape_for_python() {
994        use std::os::windows::ffi::OsStringExt;
995
996        // Exhaustive testing for windows is impractical, this has ASCII range (including NUL and
997        // control chars), surrogate pairs, and unpaired surrogates.
998        let path_osstr = OsString::from_wide(&[
999            0x5c, 0x5c, 0x3f, 0x5c, 0x43, 0x3a, 0x5c, 0x63, 0x61, 0x66, 0x00e9, 0x5c, 0xd83d,
1000            0xde00, 0x5c, 0xd800, 0x78, 0xdc00, 0x22, 0x09, 0x0a, 0x0d,
1001        ]);
1002        let path = Path::new(&path_osstr);
1003        // This should be copy-pastable into a python interpreter and reproduce the path.
1004        assert_eq!(
1005            path.escape_for_python(),
1006            r#""\\\\?\\C:\\café\\😀\\\ud800x\udc00\"\t\n\r""#
1007        );
1008    }
1009}