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/// Normalize a path, removing things like `.` and `..`.
202///
203/// Source: <https://github.com/rust-lang/cargo/blob/b48c41aedbd69ee3990d62a0e2006edbb506a480/crates/cargo-util/src/paths.rs#L76C1-L109C2>
204///
205/// CAUTION: Assumes that the path is already absolute.
206///
207/// CAUTION: This does not resolve symlinks (unlike
208/// [`std::fs::canonicalize`]). This may cause incorrect or surprising
209/// behavior at times. This should be used carefully. Unfortunately,
210/// [`std::fs::canonicalize`] can be hard to use correctly, since it can often
211/// fail, or on Windows returns annoying device paths.
212///
213/// # Errors
214///
215/// When a relative path is provided with `..` components that extend beyond the base directory.
216/// For example, `./a/../../b` cannot be normalized because it escapes the base directory.
217pub fn normalize_absolute_path(path: &Path) -> Result<PathBuf, std::io::Error> {
218    let mut components = path.components().peekable();
219    let mut ret = components
220        .next_if_map_mut(|component| match component {
221            Component::Prefix(..) => Some(PathBuf::from(component.as_os_str())),
222            _ => None,
223        })
224        .unwrap_or_default();
225
226    for component in components {
227        match component {
228            Component::Prefix(..) => unreachable!(),
229            Component::RootDir => {
230                ret.push(component.as_os_str());
231            }
232            Component::CurDir => {}
233            Component::ParentDir => {
234                if !ret.pop() {
235                    return Err(std::io::Error::new(
236                        std::io::ErrorKind::InvalidInput,
237                        format!(
238                            "cannot normalize a relative path beyond the base directory: {}",
239                            path.display()
240                        ),
241                    ));
242                }
243            }
244            Component::Normal(c) => {
245                ret.push(c);
246            }
247        }
248    }
249    Ok(ret)
250}
251
252/// Returns `false` if [`Path::components`] discarded any bytes from `path`, without allocating.
253///
254/// [`Path::components`] silently strips interior `.` segments, repeated separators, and
255/// trailing separators. If the `path` length differs from the computed byte length from
256/// `path.components().collect()`, the path isn't normalized (or there is a special case we handle
257/// here, in which case we perform a redundant normalization pass later).
258fn path_equals_components(path: &Path) -> bool {
259    // We count the length in bytes; the encoding scheme doesn't matter as we count bytes in
260    // both expected and the input path
261    let mut expected_len = 0;
262    let mut next_needs_separator = false;
263    for component in path.components() {
264        let bytes = component.as_os_str().as_encoded_bytes();
265        // `PathBuf::push` inserts a separator between components unless the previous one
266        // already ends in one, or the new component is itself the root (which embeds it).
267        if next_needs_separator && !matches!(component, Component::RootDir) {
268            // Assumption: forward and backwards slashes encode with the same length.
269            expected_len += Path::new("/").as_os_str().as_encoded_bytes().len();
270        }
271        expected_len += bytes.len();
272        next_needs_separator = match component {
273            // The root dir is the slash.
274            Component::RootDir => false,
275            // Prefix has `RootDir` after it if it requires a slash.
276            Component::Prefix(_) => false,
277            _ => true,
278        };
279    }
280    expected_len == path.as_os_str().as_encoded_bytes().len()
281}
282
283/// Normalize a [`Cow`] path, removing `.`, `..`, repeated separators (`//`), and trailing slashes.
284///
285/// Paths that point to the current directory (`.` or `.\.`) are normalized to the empty path.
286///
287/// When the path is already normalized, returns it as-is without allocating.
288pub fn normalize_path<'path>(path: impl Into<Cow<'path, Path>>) -> Cow<'path, Path> {
289    let path = path.into();
290    // A path with leading `.` or `..` is not normalized.
291    if path
292        .components()
293        .any(|component| matches!(component, Component::ParentDir | Component::CurDir))
294    {
295        return Cow::Owned(normalized(&path));
296    }
297
298    // A path with non-leading `.`, repeated separators (`//`) or trailing slashes is not
299    // normalized.
300    if !path_equals_components(&path) {
301        return Cow::Owned(normalized(&path));
302    }
303
304    // Fast path: already normalized, return as-is.
305    path
306}
307
308/// Normalize `path`, returning it when it remains strictly under a non-empty `root`.
309///
310/// This comparison is lexical and does not resolve symlinks. The returned path is normalized, and
311/// equality with `root` is rejected.
312pub fn normalize_path_under(path: impl AsRef<Path>, root: impl AsRef<Path>) -> Option<PathBuf> {
313    let path = normalize_path(path.as_ref()).into_owned();
314    let root = normalize_path(root.as_ref());
315
316    if root.as_os_str().is_empty() || path.as_path() == root.as_ref() {
317        None
318    } else {
319        path.starts_with(root.as_ref()).then_some(path)
320    }
321}
322
323/// Normalize a [`Path`].
324///
325/// Unlike [`normalize_absolute_path`], this works with relative paths and does never error.
326///
327/// Note that we can theoretically go beyond the root dir here (e.g. `/usr/../../foo` becomes
328/// `/../foo`), but that's not a (correctness) problem, we will fail later with a file not found
329/// error with a path computed from the user's input.
330///
331/// # Examples
332///
333/// In: `../../workspace-git-path-dep-test/packages/c/../../packages/d`
334/// Out: `../../workspace-git-path-dep-test/packages/d`
335///
336/// In: `workspace-git-path-dep-test/packages/c/../../packages/d`
337/// Out: `workspace-git-path-dep-test/packages/d`
338///
339/// In: `./a/../../b`
340fn normalized(path: &Path) -> PathBuf {
341    let mut normalized = PathBuf::new();
342    for component in path.components() {
343        match component {
344            Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
345                // Preserve filesystem roots and regular path components.
346                normalized.push(component);
347            }
348            Component::ParentDir => {
349                match normalized.components().next_back() {
350                    None | Some(Component::ParentDir | Component::RootDir) => {
351                        // Preserve leading and above-root `..`
352                        normalized.push(component);
353                    }
354                    Some(Component::Normal(_) | Component::Prefix(_) | Component::CurDir) => {
355                        // Remove inner `..`
356                        normalized.pop();
357                    }
358                }
359            }
360            Component::CurDir => {
361                // Remove `.`
362            }
363        }
364    }
365    normalized
366}
367
368/// Compute a path describing `path` relative to `base`.
369///
370/// `lib/python/site-packages/foo/__init__.py` and `lib/python/site-packages` -> `foo/__init__.py`
371/// `lib/marker.txt` and `lib/python/site-packages` -> `../../marker.txt`
372/// `bin/foo_launcher` and `lib/python/site-packages` -> `../../../bin/foo_launcher`
373///
374/// Returns `Err` if there is no relative path between `path` and `base` (for example, if the paths
375/// are on different drives on Windows).
376pub fn relative_to(
377    path: impl AsRef<Path>,
378    base: impl AsRef<Path>,
379) -> Result<PathBuf, std::io::Error> {
380    // Normalize both paths, to avoid intermediate `..` components.
381    let path = normalize_path(path.as_ref());
382    let base = normalize_path(base.as_ref());
383
384    // Find the longest common prefix, and also return the path stripped from that prefix
385    let (stripped, common_prefix) = base
386        .ancestors()
387        .find_map(|ancestor| {
388            // Simplifying removes the UNC path prefix on windows.
389            dunce::simplified(&path)
390                .strip_prefix(dunce::simplified(ancestor))
391                .ok()
392                .map(|stripped| (stripped, ancestor))
393        })
394        .ok_or_else(|| {
395            std::io::Error::other(format!(
396                "Trivial strip failed: {} vs. {}",
397                path.simplified_display(),
398                base.simplified_display()
399            ))
400        })?;
401
402    // go as many levels up as required
403    let levels_up = base.components().count() - common_prefix.components().count();
404    let up = std::iter::repeat_n("..", levels_up).collect::<PathBuf>();
405
406    Ok(up.join(stripped))
407}
408
409/// Find the root of the nearest Git repository containing `path`.
410///
411/// A `.git` directory or file is treated as a repository marker to support both regular
412/// repositories and linked worktrees.
413pub fn find_git_repository_root(path: &Path) -> Option<&Path> {
414    // TODO: Consider supporting GIT_CEILING_DIRECTORIES here.
415    path.ancestors()
416        .find(|ancestor| ancestor.join(".git").exists())
417}
418
419/// Try to compute a path relative to `base` if `should_relativize` is true, otherwise return
420/// the absolute path. Falls back to absolute if relativization fails.
421pub fn try_relative_to_if(
422    path: impl AsRef<Path>,
423    base: impl AsRef<Path>,
424    should_relativize: bool,
425) -> Result<PathBuf, std::io::Error> {
426    if should_relativize {
427        relative_to(&path, &base).or_else(|_| std::path::absolute(path.as_ref()))
428    } else {
429        std::path::absolute(path.as_ref())
430    }
431}
432
433/// Convert a [`Path`] to a Windows `verbatim` path (prefixed with `\\?\`) when possible to bypass
434/// Win32 path normalization such as [`MAX_PATH`] and removed trailing characters (dot, space).
435/// Other characters as defined by [`Path.GetInvalidFileNameChars`] are still prohibited. This
436/// function will attempt to perform path normalization similar to Win32 default normalization
437/// without triggering the existing Win32 limitations.
438///
439/// Only [`Prefix::UNC`] and [`Prefix::Disk`] conversion compatible components are supported.
440///   * [`Prefix::UNC`] `\\server\share` becomes `\\?\UNC\server\share`
441///   * [`Prefix::Disk`] `DriveLetter:` becomes `\\?\DriveLetter:`
442///
443/// Other representations do not yield a `verbatim` path. The following cases are returned as-is:
444///   * Non-Windows systems.
445///   * Device paths such as those starting with `\\.\`.
446///   * Paths already prefixed with `\\?\` or `\\?\UNC\`.
447///
448/// WARNING: Adding the `\\?\` prefix effectively skips Win32 default path normalization. Even
449/// though it allows operations on paths that are normally unavailable, it can also be used to
450/// create entries that can potentially lead to further issues with operations that expect
451/// normalization such as symbolic links, junctions or reparse points.
452///
453/// [`MAX_PATH`]: https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation
454/// [`Path.GetInvalidFileNameChars`]: https://learn.microsoft.com/en-us/dotnet/api/system.io.path.getinvalidfilenamechars
455///
456/// See:
457///   * <https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file>
458///   * <https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats>
459pub fn verbatim_path(path: &Path) -> Cow<'_, Path> {
460    if !cfg!(windows) {
461        return Cow::Borrowed(path);
462    }
463
464    // Attempt to resolve a fully qualified path just like Win32 path normalization would.
465    // std::path::absolute calls GetFullPathNameW which defeats the purpose of this function
466    // as it results in Win32 default path normalization.
467    let resolved_path = if path.is_relative() {
468        Cow::Owned(CWD.join(path))
469    } else {
470        Cow::Borrowed(path)
471    };
472
473    // Fast Path: we only support verbatim conversion for Prefix::UNC and Prefix::Disk
474    if let Some(Component::Prefix(prefix)) = resolved_path.components().next() {
475        match prefix.kind() {
476            Prefix::UNC(..) | Prefix::Disk(_) => {},
477            // return as-is as there's no verbatim equivalent for `\\.\device`
478            Prefix::DeviceNS(_)
479            // return as-is as its already verbatim
480            | Prefix::Verbatim(_)
481            | Prefix::VerbatimDisk(_)
482            | Prefix::VerbatimUNC(..) => return Cow::Borrowed(path)
483        }
484    }
485
486    // Resolve relative directory components while avoiding default Win32 path normalization
487    let normalized_path = normalized(&resolved_path);
488
489    let mut components = normalized_path.components();
490    let Some(Component::Prefix(prefix)) = components.next() else {
491        return Cow::Borrowed(path);
492    };
493
494    match prefix.kind() {
495        // `DriveLetter:` -> `\\?\DriveLetter:`
496        Prefix::Disk(_) => {
497            let mut result = OsString::from(r"\\?\");
498            result.push(normalized_path.as_os_str()); // e.g. "C:"
499            Cow::Owned(PathBuf::from(result))
500        }
501        // `\\server\share` -> `\\?\UNC\server\share`
502        Prefix::UNC(server, share) => {
503            let mut result = OsString::from(r"\\?\UNC\");
504            result.push(server);
505            result.push(r"\");
506            result.push(share);
507            for component in components {
508                match component {
509                    Component::RootDir => {} // being cautious
510                    Component::Prefix(_) => {
511                        debug_assert!(false, "prefix already consumed");
512                    }
513                    Component::CurDir | Component::ParentDir => {
514                        debug_assert!(false, "path already normalized");
515                    }
516                    Component::Normal(_) => {
517                        result.push(r"\");
518                        result.push(component.as_os_str());
519                    }
520                }
521            }
522            Cow::Owned(PathBuf::from(result))
523        }
524        Prefix::DeviceNS(_)
525        | Prefix::Verbatim(_)
526        | Prefix::VerbatimDisk(_)
527        | Prefix::VerbatimUNC(..) => {
528            debug_assert!(false, "skipped via fast path");
529            Cow::Borrowed(path)
530        }
531    }
532}
533
534/// A path that can be serialized and deserialized in a portable way by converting Windows-style
535/// backslashes to forward slashes, and using a `.` for an empty path.
536///
537/// This implementation assumes that the path is valid UTF-8; otherwise, it won't roundtrip.
538#[derive(Debug, Clone, PartialEq, Eq)]
539pub struct PortablePath<'a>(&'a Path);
540
541#[derive(Debug, Clone, PartialEq, Eq)]
542pub struct PortablePathBuf(Box<Path>);
543
544#[cfg(feature = "schemars")]
545impl schemars::JsonSchema for PortablePathBuf {
546    fn schema_name() -> Cow<'static, str> {
547        Cow::Borrowed("PortablePathBuf")
548    }
549
550    fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
551        PathBuf::json_schema(_gen)
552    }
553}
554
555impl AsRef<Path> for PortablePath<'_> {
556    fn as_ref(&self) -> &Path {
557        self.0
558    }
559}
560
561impl<'a, T> From<&'a T> for PortablePath<'a>
562where
563    T: AsRef<Path> + ?Sized,
564{
565    fn from(path: &'a T) -> Self {
566        PortablePath(path.as_ref())
567    }
568}
569
570impl std::fmt::Display for PortablePath<'_> {
571    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
572        let path = self.0.to_slash_lossy();
573        if path.is_empty() {
574            write!(f, ".")
575        } else {
576            write!(f, "{path}")
577        }
578    }
579}
580
581impl std::fmt::Display for PortablePathBuf {
582    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
583        let path = self.0.to_slash_lossy();
584        if path.is_empty() {
585            write!(f, ".")
586        } else {
587            write!(f, "{path}")
588        }
589    }
590}
591
592impl From<&str> for PortablePathBuf {
593    fn from(path: &str) -> Self {
594        if path == "." {
595            Self(PathBuf::new().into_boxed_path())
596        } else {
597            Self(PathBuf::from(path).into_boxed_path())
598        }
599    }
600}
601
602impl From<PortablePathBuf> for Box<Path> {
603    fn from(portable: PortablePathBuf) -> Self {
604        portable.0
605    }
606}
607
608impl From<Box<Path>> for PortablePathBuf {
609    fn from(path: Box<Path>) -> Self {
610        Self(path)
611    }
612}
613
614impl<'a> From<&'a Path> for PortablePathBuf {
615    fn from(path: &'a Path) -> Self {
616        Box::<Path>::from(path).into()
617    }
618}
619
620#[cfg(feature = "serde")]
621impl serde::Serialize for PortablePathBuf {
622    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
623    where
624        S: serde::ser::Serializer,
625    {
626        self.to_string().serialize(serializer)
627    }
628}
629
630#[cfg(feature = "serde")]
631impl serde::Serialize for PortablePath<'_> {
632    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
633    where
634        S: serde::ser::Serializer,
635    {
636        self.to_string().serialize(serializer)
637    }
638}
639
640#[cfg(feature = "serde")]
641impl<'de> serde::de::Deserialize<'de> for PortablePathBuf {
642    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
643    where
644        D: serde::de::Deserializer<'de>,
645    {
646        let s = <Cow<'_, str>>::deserialize(deserializer)?;
647        if s == "." {
648            Ok(Self(PathBuf::new().into_boxed_path()))
649        } else {
650            Ok(Self(PathBuf::from(s.as_ref()).into_boxed_path()))
651        }
652    }
653}
654
655impl AsRef<Path> for PortablePathBuf {
656    fn as_ref(&self) -> &Path {
657        &self.0
658    }
659}
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664
665    #[test]
666    fn test_find_git_repository_root() -> std::io::Result<()> {
667        let temp_dir = tempfile::tempdir()?;
668
669        let repository = temp_dir.path().join("repository");
670        let nested = repository.join("packages/project");
671        fs_err::create_dir_all(repository.join(".git"))?;
672        fs_err::create_dir_all(&nested)?;
673        assert_eq!(
674            find_git_repository_root(&nested),
675            Some(repository.as_path())
676        );
677
678        let worktree = temp_dir.path().join("worktree");
679        let nested = worktree.join("packages/project");
680        fs_err::create_dir_all(&nested)?;
681        fs_err::write(worktree.join(".git"), "gitdir: ../repository/.git")?;
682        assert_eq!(find_git_repository_root(&nested), Some(worktree.as_path()));
683
684        Ok(())
685    }
686
687    #[test]
688    fn test_normalize_url() {
689        if cfg!(windows) {
690            assert_eq!(
691                normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
692                "C:\\Users\\ferris\\wheel-0.42.0.tar.gz"
693            );
694        } else {
695            assert_eq!(
696                normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
697                "/C:/Users/ferris/wheel-0.42.0.tar.gz"
698            );
699        }
700
701        if cfg!(windows) {
702            assert_eq!(
703                normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
704                ".\\ferris\\wheel-0.42.0.tar.gz"
705            );
706        } else {
707            assert_eq!(
708                normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
709                "./ferris/wheel-0.42.0.tar.gz"
710            );
711        }
712
713        if cfg!(windows) {
714            assert_eq!(
715                normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
716                ".\\wheel cache\\wheel-0.42.0.tar.gz"
717            );
718        } else {
719            assert_eq!(
720                normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
721                "./wheel cache/wheel-0.42.0.tar.gz"
722            );
723        }
724    }
725
726    #[test]
727    fn test_normalize_path() {
728        let path = Path::new("/a/b/../c/./d");
729        let normalized = normalize_absolute_path(path).unwrap();
730        assert_eq!(normalized, Path::new("/a/c/d"));
731
732        let path = Path::new("/a/../c/./d");
733        let normalized = normalize_absolute_path(path).unwrap();
734        assert_eq!(normalized, Path::new("/c/d"));
735
736        // This should be an error.
737        let path = Path::new("/a/../../c/./d");
738        let err = normalize_absolute_path(path).unwrap_err();
739        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
740    }
741
742    #[test]
743    fn test_relative_to() {
744        assert_eq!(
745            relative_to(
746                Path::new("/home/ferris/carcinization/lib/python/site-packages/foo/__init__.py"),
747                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
748            )
749            .unwrap(),
750            Path::new("foo/__init__.py")
751        );
752        assert_eq!(
753            relative_to(
754                Path::new("/home/ferris/carcinization/lib/marker.txt"),
755                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
756            )
757            .unwrap(),
758            Path::new("../../marker.txt")
759        );
760        assert_eq!(
761            relative_to(
762                Path::new("/home/ferris/carcinization/bin/foo_launcher"),
763                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
764            )
765            .unwrap(),
766            Path::new("../../../bin/foo_launcher")
767        );
768    }
769
770    #[test]
771    fn test_normalize_relative() {
772        let cases = [
773            (
774                "../../workspace-git-path-dep-test/packages/c/../../packages/d",
775                "../../workspace-git-path-dep-test/packages/d",
776            ),
777            (
778                "workspace-git-path-dep-test/packages/c/../../packages/d",
779                "workspace-git-path-dep-test/packages/d",
780            ),
781            ("./a/../../b", "../b"),
782            ("/usr/../../foo", "/../foo"),
783            // Interior `.` segments (stripped by `Path::components`).
784            ("foo/./bar", "foo/bar"),
785            ("/a/./b/./c", "/a/b/c"),
786            ("./foo/bar", "foo/bar"),
787            (".", ""),
788            ("./.", ""),
789            ("foo/.", "foo"),
790            // Repeated separators (also stripped by `Path::components`).
791            ("foo//bar", "foo/bar"),
792            ("/a///b//c", "/a/b/c"),
793            // Mixed `.` and `..`.
794            ("foo/./../bar", "bar"),
795            ("foo/bar/./../baz", "foo/baz"),
796            // Already-normalized paths.
797            ("foo/bar", "foo/bar"),
798            ("/a/b/c", "/a/b/c"),
799            ("", ""),
800        ];
801        for (input, expected) in cases {
802            assert_eq!(
803                normalize_path(Path::new(input)),
804                Path::new(expected),
805                "input: {input:?}"
806            );
807        }
808
809        // Verify the fast path: already-normalized inputs are returned borrowed.
810        for already_normalized in ["foo/bar", "/a/b/c", "foo", "/", ""] {
811            let path = Path::new(already_normalized);
812            assert!(
813                matches!(normalize_path(path), Cow::Borrowed(_)),
814                "expected borrowed for {already_normalized:?}"
815            );
816        }
817    }
818
819    #[test]
820    fn test_normalize_path_under() {
821        assert_eq!(
822            normalize_path_under("scripts/script", "scripts"),
823            Some(PathBuf::from("scripts/script"))
824        );
825        assert_eq!(
826            normalize_path_under("/scripts/script", "/scripts"),
827            Some(PathBuf::from("/scripts/script"))
828        );
829        assert_eq!(
830            normalize_path_under("scripts/nested/../script", "scripts"),
831            Some(PathBuf::from("scripts/script"))
832        );
833        assert_eq!(
834            normalize_path_under("/scripts/nested/../script", "/scripts"),
835            Some(PathBuf::from("/scripts/script"))
836        );
837        assert_eq!(normalize_path_under("scripts/.", "scripts"), None);
838        assert_eq!(normalize_path_under("/scripts/.", "/scripts"), None);
839        assert_eq!(normalize_path_under("scripts/../script", "scripts"), None);
840        assert_eq!(normalize_path_under("/scripts/../script", "/scripts"), None);
841        assert_eq!(normalize_path_under("scripts/script", "."), None);
842        assert_eq!(normalize_path_under("scripts/script", ""), None);
843    }
844
845    #[test]
846    fn test_normalize_trailing_path_separator() {
847        let cases = [
848            (
849                "/home/ferris/projects/python/",
850                "/home/ferris/projects/python",
851            ),
852            ("python/", "python"),
853            ("/", "/"),
854            ("foo/bar/", "foo/bar"),
855            ("foo//", "foo"),
856        ];
857        for (input, expected) in cases {
858            assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
859        }
860    }
861
862    #[test]
863    #[cfg(windows)]
864    fn test_normalize_windows() {
865        let cases = [
866            (
867                r"C:\Users\Ferris\projects\python\",
868                r"C:\Users\Ferris\projects\python",
869            ),
870            (r"C:\foo\.\bar", r"C:\foo\bar"),
871            (r"C:\foo\\bar", r"C:\foo\bar"),
872            (r"C:\foo\bar\..\baz", r"C:\foo\baz"),
873            (r"foo\.\bar", r"foo\bar"),
874            (r"C:foo", r"C:foo"),
875            (r"C:\foo", r"C:\foo"),
876            (r"C:\\foo", r"C:\foo"),
877            (r"\\?\C:foo", r"\\?\C:foo"),
878            (r"\\?\C:\foo", r"\\?\C:\foo"),
879            (r"\\?\C:\\foo", r"\\?\C:\foo"),
880            (r"\\server\share\foo", r"\\server\share\foo"),
881        ];
882        for (input, expected) in cases {
883            assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
884        }
885    }
886
887    #[cfg(windows)]
888    #[test]
889    fn test_verbatim_path() {
890        let relative_path = format!(r"\\?\{}\path\to\logging.", CWD.simplified_display());
891        let relative_root = format!(
892            r"\\?\{}\path\to\logging.",
893            CWD.components()
894                .next()
895                .expect("expected a drive letter prefix")
896                .simplified_display()
897        );
898        let cases = [
899            // Non-Verbatim disk
900            (r"C:\path\to\logging.", r"\\?\C:\path\to\logging."),
901            (r"C:\path\to\.\logging.", r"\\?\C:\path\to\logging."),
902            (r"C:\path\to\..\to\logging.", r"\\?\C:\path\to\logging."),
903            (r"C:/path/to/../to/./logging.", r"\\?\C:\path\to\logging."),
904            (r"C:path\to\..\to\logging.", r"\\?\C:path\to\logging."), // @TODO(samypr100) we do not support expanding drive-relative paths
905            (r".\path\to\.\logging.", relative_path.as_str()),
906            (r"path\to\..\to\logging.", relative_path.as_str()),
907            (r"./path/to/logging.", relative_path.as_str()),
908            (r"\path\to\logging.", relative_root.as_str()),
909            // Non-Verbatim UNC
910            (
911                r"\\127.0.0.1\c$\path\to\logging.",
912                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
913            ),
914            (
915                r"\\127.0.0.1\c$\path\to\.\logging.",
916                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
917            ),
918            (
919                r"\\127.0.0.1\c$\path\to\..\to\logging.",
920                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
921            ),
922            (
923                r"//127.0.0.1/c$/path/to/../to/./logging.",
924                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
925            ),
926            // Verbatim Disk
927            (r"\\?\C:\path\to\logging.", r"\\?\C:\path\to\logging."),
928            // Verbatim UNC
929            (
930                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
931                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
932            ),
933            // Device Namespace
934            (r"\\.\PhysicalDrive0", r"\\.\PhysicalDrive0"),
935            (r"\\.\NUL", r"\\.\NUL"),
936        ];
937
938        for (input, expected) in cases {
939            assert_eq!(verbatim_path(Path::new(input)), Path::new(expected));
940        }
941    }
942
943    #[test]
944    #[cfg(unix)]
945    fn test_path_escape_for_python() {
946        use std::ffi::OsStr;
947        use std::os::unix::ffi::OsStrExt;
948
949        // A *nix path can contain any byte except NUL.
950        // Each expected value is intentionally Python pastable for verification.
951        for (range, expected) in [
952            (
953                1..=0x5e,
954                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")"##,
955            ),
956            (
957                0x5f..=0xa3,
958                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")"#,
959            ),
960            (
961                0xa4..=0xd1,
962                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")"#,
963            ),
964            (
965                0xd2..=u8::MAX,
966                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")"#,
967            ),
968        ] {
969            let bytes = b"/foo/"
970                .iter()
971                .copied()
972                .chain(range)
973                .chain(b"/bar".iter().copied())
974                .collect::<Box<[_]>>();
975            let path = Path::new(OsStr::from_bytes(&bytes));
976            assert_eq!(path.escape_for_python(), expected);
977        }
978    }
979
980    #[cfg(windows)]
981    #[test]
982    fn test_path_escape_for_python() {
983        use std::os::windows::ffi::OsStringExt;
984
985        // Exhaustive testing for windows is impractical, this has ASCII range (including NUL and
986        // control chars), surrogate pairs, and unpaired surrogates.
987        let path_osstr = OsString::from_wide(&[
988            0x5c, 0x5c, 0x3f, 0x5c, 0x43, 0x3a, 0x5c, 0x63, 0x61, 0x66, 0x00e9, 0x5c, 0xd83d,
989            0xde00, 0x5c, 0xd800, 0x78, 0xdc00, 0x22, 0x09, 0x0a, 0x0d,
990        ]);
991        let path = Path::new(&path_osstr);
992        // This should be copy-pastable into a python interpreter and reproduce the path.
993        assert_eq!(
994            path.escape_for_python(),
995            r#""\\\\?\\C:\\café\\😀\\\ud800x\udc00\"\t\n\r""#
996        );
997    }
998}