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 without a `\\?\` prefix on Windows.
31    /// For a path that can't be canonicalized (e.g. on network drive or RAM drive on Windows),
32    /// this will return the absolute path if it exists.
33    fn simple_canonicalize(&self) -> std::io::Result<PathBuf>;
34
35    /// Render a [`Path`] for user-facing display.
36    ///
37    /// Like [`simplified_display`], but relativizes the path against the current working directory.
38    fn user_display(&self) -> impl std::fmt::Display;
39
40    /// Render a [`Path`] for user-facing display, where the [`Path`] is relative to a base path.
41    ///
42    /// If the [`Path`] is not relative to the base path, will attempt to relativize the path
43    /// against the current working directory.
44    fn user_display_from(&self, base: impl AsRef<Path>) -> impl std::fmt::Display;
45
46    /// Render a [`Path`] for user-facing display using a portable representation.
47    ///
48    /// Like [`user_display`], but uses a portable representation for relative paths.
49    fn portable_display(&self) -> impl std::fmt::Display;
50}
51
52impl<T: AsRef<Path>> Simplified for T {
53    fn simplified(&self) -> &Path {
54        dunce::simplified(self.as_ref())
55    }
56
57    fn simplified_display(&self) -> impl std::fmt::Display {
58        dunce::simplified(self.as_ref()).display()
59    }
60
61    fn simple_canonicalize(&self) -> std::io::Result<PathBuf> {
62        dunce::canonicalize(self.as_ref())
63    }
64
65    fn user_display(&self) -> impl std::fmt::Display {
66        let path = dunce::simplified(self.as_ref());
67
68        // If current working directory is root, display the path as-is.
69        if CWD.ancestors().nth(1).is_none() {
70            return path.display();
71        }
72
73        // Attempt to strip the current working directory, then the canonicalized current working
74        // directory, in case they differ.
75        let path = path.strip_prefix(CWD.simplified()).unwrap_or(path);
76
77        if path.as_os_str() == "" {
78            // Avoid printing an empty string for the current directory
79            return Path::new(".").display();
80        }
81
82        path.display()
83    }
84
85    fn user_display_from(&self, base: impl AsRef<Path>) -> impl std::fmt::Display {
86        let path = dunce::simplified(self.as_ref());
87
88        // If current working directory is root, display the path as-is.
89        if CWD.ancestors().nth(1).is_none() {
90            return path.display();
91        }
92
93        // Attempt to strip the base, then the current working directory, then the canonicalized
94        // current working directory, in case they differ.
95        let path = path
96            .strip_prefix(base.as_ref())
97            .unwrap_or_else(|_| path.strip_prefix(CWD.simplified()).unwrap_or(path));
98
99        if path.as_os_str() == "" {
100            // Avoid printing an empty string for the current directory
101            return Path::new(".").display();
102        }
103
104        path.display()
105    }
106
107    fn portable_display(&self) -> impl std::fmt::Display {
108        let path = dunce::simplified(self.as_ref());
109
110        // Attempt to strip the current working directory, then the canonicalized current working
111        // directory, in case they differ.
112        let path = path.strip_prefix(CWD.simplified()).unwrap_or(path);
113
114        // Use a portable representation for relative paths.
115        path.to_slash()
116            .map(Either::Left)
117            .unwrap_or_else(|| Either::Right(path.display()))
118    }
119}
120
121pub trait PythonExt {
122    /// Escape a [`Path`] for use in Python code.
123    fn escape_for_python(&self) -> String;
124}
125
126impl<T: AsRef<Path>> PythonExt for T {
127    fn escape_for_python(&self) -> String {
128        self.as_ref()
129            .to_string_lossy()
130            .replace('\\', "\\\\")
131            .replace('"', "\\\"")
132    }
133}
134
135/// Normalize the `path` component of a URL for use as a file path.
136///
137/// For example, on Windows, transforms `C:\Users\ferris\wheel-0.42.0.tar.gz` to
138/// `/C:/Users/ferris/wheel-0.42.0.tar.gz`.
139///
140/// On other platforms, this is a no-op.
141pub fn normalize_url_path(path: &str) -> Cow<'_, str> {
142    // Apply percent-decoding to the URL.
143    let path = percent_encoding::percent_decode_str(path)
144        .decode_utf8()
145        .unwrap_or(Cow::Borrowed(path));
146
147    // Return the path.
148    if cfg!(windows) {
149        Cow::Owned(
150            path.strip_prefix('/')
151                .unwrap_or(&path)
152                .replace('/', std::path::MAIN_SEPARATOR_STR),
153        )
154    } else {
155        path
156    }
157}
158
159/// Normalize a path, removing things like `.` and `..`.
160///
161/// Source: <https://github.com/rust-lang/cargo/blob/b48c41aedbd69ee3990d62a0e2006edbb506a480/crates/cargo-util/src/paths.rs#L76C1-L109C2>
162///
163/// CAUTION: Assumes that the path is already absolute.
164///
165/// CAUTION: This does not resolve symlinks (unlike
166/// [`std::fs::canonicalize`]). This may cause incorrect or surprising
167/// behavior at times. This should be used carefully. Unfortunately,
168/// [`std::fs::canonicalize`] can be hard to use correctly, since it can often
169/// fail, or on Windows returns annoying device paths.
170///
171/// # Errors
172///
173/// When a relative path is provided with `..` components that extend beyond the base directory.
174/// For example, `./a/../../b` cannot be normalized because it escapes the base directory.
175pub fn normalize_absolute_path(path: &Path) -> Result<PathBuf, std::io::Error> {
176    let mut components = path.components().peekable();
177    let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().copied() {
178        components.next();
179        PathBuf::from(c.as_os_str())
180    } else {
181        PathBuf::new()
182    };
183
184    for component in components {
185        match component {
186            Component::Prefix(..) => unreachable!(),
187            Component::RootDir => {
188                ret.push(component.as_os_str());
189            }
190            Component::CurDir => {}
191            Component::ParentDir => {
192                if !ret.pop() {
193                    return Err(std::io::Error::new(
194                        std::io::ErrorKind::InvalidInput,
195                        format!(
196                            "cannot normalize a relative path beyond the base directory: {}",
197                            path.display()
198                        ),
199                    ));
200                }
201            }
202            Component::Normal(c) => {
203                ret.push(c);
204            }
205        }
206    }
207    Ok(ret)
208}
209
210/// Returns `false` if [`Path::components`] discarded any bytes from `path`, without allocating.
211///
212/// [`Path::components`] silently strips interior `.` segments, repeated separators, and
213/// trailing separators. If the `path` length differs from the computed byte length from
214/// `path.components().collect()`, the path isn't normalized (or there is a special case we handle
215/// here, in which case we perform a redundant normalization pass later).
216fn path_equals_components(path: &Path) -> bool {
217    // We count the length in bytes; the encoding scheme doesn't matter as we count bytes in
218    // both expected and the input path
219    let mut expected_len = 0;
220    let mut next_needs_separator = false;
221    for component in path.components() {
222        let bytes = component.as_os_str().as_encoded_bytes();
223        // `PathBuf::push` inserts a separator between components unless the previous one
224        // already ends in one, or the new component is itself the root (which embeds it).
225        if next_needs_separator && !matches!(component, Component::RootDir) {
226            // Assumption: forward and backwards slashes encode with the same length.
227            expected_len += Path::new("/").as_os_str().as_encoded_bytes().len();
228        }
229        expected_len += bytes.len();
230        next_needs_separator = match component {
231            // The root dir is the slash.
232            Component::RootDir => false,
233            // Prefix has `RootDir` after it if it requires a slash.
234            Component::Prefix(_) => false,
235            _ => true,
236        };
237    }
238    expected_len == path.as_os_str().as_encoded_bytes().len()
239}
240
241/// Normalize a [`Cow`] path, removing `.`, `..`, repeated separators (`//`), and trailing slashes.
242///
243/// Paths that point to the current directory (`.` or `.\.`) are normalized to the empty path.
244///
245/// When the path is already normalized, returns it as-is without allocating.
246pub fn normalize_path<'path>(path: impl Into<Cow<'path, Path>>) -> Cow<'path, Path> {
247    let path = path.into();
248    // A path with leading `.` or `..` is not normalized.
249    if path
250        .components()
251        .any(|component| matches!(component, Component::ParentDir | Component::CurDir))
252    {
253        return Cow::Owned(normalized(&path));
254    }
255
256    // A path with non-leading `.`, repeated separators (`//`) or trailing slashes is not
257    // normalized.
258    if !path_equals_components(&path) {
259        return Cow::Owned(normalized(&path));
260    }
261
262    // Fast path: already normalized, return as-is.
263    path
264}
265
266/// Normalize `path`, returning it when it remains strictly under a non-empty `root`.
267///
268/// This comparison is lexical and does not resolve symlinks. The returned path is normalized, and
269/// equality with `root` is rejected.
270pub fn normalize_path_under(path: impl AsRef<Path>, root: impl AsRef<Path>) -> Option<PathBuf> {
271    let path = normalize_path(path.as_ref()).into_owned();
272    let root = normalize_path(root.as_ref());
273
274    if root.as_os_str().is_empty() || path.as_path() == root.as_ref() {
275        None
276    } else {
277        path.starts_with(root.as_ref()).then_some(path)
278    }
279}
280
281/// Normalize a [`Path`].
282///
283/// Unlike [`normalize_absolute_path`], this works with relative paths and does never error.
284///
285/// Note that we can theoretically go beyond the root dir here (e.g. `/usr/../../foo` becomes
286/// `/../foo`), but that's not a (correctness) problem, we will fail later with a file not found
287/// error with a path computed from the user's input.
288///
289/// # Examples
290///
291/// In: `../../workspace-git-path-dep-test/packages/c/../../packages/d`
292/// Out: `../../workspace-git-path-dep-test/packages/d`
293///
294/// In: `workspace-git-path-dep-test/packages/c/../../packages/d`
295/// Out: `workspace-git-path-dep-test/packages/d`
296///
297/// In: `./a/../../b`
298fn normalized(path: &Path) -> PathBuf {
299    let mut normalized = PathBuf::new();
300    for component in path.components() {
301        match component {
302            Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
303                // Preserve filesystem roots and regular path components.
304                normalized.push(component);
305            }
306            Component::ParentDir => {
307                match normalized.components().next_back() {
308                    None | Some(Component::ParentDir | Component::RootDir) => {
309                        // Preserve leading and above-root `..`
310                        normalized.push(component);
311                    }
312                    Some(Component::Normal(_) | Component::Prefix(_) | Component::CurDir) => {
313                        // Remove inner `..`
314                        normalized.pop();
315                    }
316                }
317            }
318            Component::CurDir => {
319                // Remove `.`
320            }
321        }
322    }
323    normalized
324}
325
326/// Compute a path describing `path` relative to `base`.
327///
328/// `lib/python/site-packages/foo/__init__.py` and `lib/python/site-packages` -> `foo/__init__.py`
329/// `lib/marker.txt` and `lib/python/site-packages` -> `../../marker.txt`
330/// `bin/foo_launcher` and `lib/python/site-packages` -> `../../../bin/foo_launcher`
331///
332/// Returns `Err` if there is no relative path between `path` and `base` (for example, if the paths
333/// are on different drives on Windows).
334pub fn relative_to(
335    path: impl AsRef<Path>,
336    base: impl AsRef<Path>,
337) -> Result<PathBuf, std::io::Error> {
338    // Normalize both paths, to avoid intermediate `..` components.
339    let path = normalize_path(path.as_ref());
340    let base = normalize_path(base.as_ref());
341
342    // Find the longest common prefix, and also return the path stripped from that prefix
343    let (stripped, common_prefix) = base
344        .ancestors()
345        .find_map(|ancestor| {
346            // Simplifying removes the UNC path prefix on windows.
347            dunce::simplified(&path)
348                .strip_prefix(dunce::simplified(ancestor))
349                .ok()
350                .map(|stripped| (stripped, ancestor))
351        })
352        .ok_or_else(|| {
353            std::io::Error::other(format!(
354                "Trivial strip failed: {} vs. {}",
355                path.simplified_display(),
356                base.simplified_display()
357            ))
358        })?;
359
360    // go as many levels up as required
361    let levels_up = base.components().count() - common_prefix.components().count();
362    let up = std::iter::repeat_n("..", levels_up).collect::<PathBuf>();
363
364    Ok(up.join(stripped))
365}
366
367/// Try to compute a path relative to `base` if `should_relativize` is true, otherwise return
368/// the absolute path. Falls back to absolute if relativization fails.
369pub fn try_relative_to_if(
370    path: impl AsRef<Path>,
371    base: impl AsRef<Path>,
372    should_relativize: bool,
373) -> Result<PathBuf, std::io::Error> {
374    if should_relativize {
375        relative_to(&path, &base).or_else(|_| std::path::absolute(path.as_ref()))
376    } else {
377        std::path::absolute(path.as_ref())
378    }
379}
380
381/// Convert a [`Path`] to a Windows `verbatim` path (prefixed with `\\?\`) when possible to bypass
382/// Win32 path normalization such as [`MAX_PATH`] and removed trailing characters (dot, space).
383/// Other characters as defined by [`Path.GetInvalidFileNameChars`] are still prohibited. This
384/// function will attempt to perform path normalization similar to Win32 default normalization
385/// without triggering the existing Win32 limitations.
386///
387/// Only [`Prefix::UNC`] and [`Prefix::Disk`] conversion compatible components are supported.
388///   * [`Prefix::UNC`] `\\server\share` becomes `\\?\UNC\server\share`
389///   * [`Prefix::Disk`] `DriveLetter:` becomes `\\?\DriveLetter:`
390///
391/// Other representations do not yield a `verbatim` path. The following cases are returned as-is:
392///   * Non-Windows systems.
393///   * Device paths such as those starting with `\\.\`.
394///   * Paths already prefixed with `\\?\` or `\\?\UNC\`.
395///
396/// WARNING: Adding the `\\?\` prefix effectively skips Win32 default path normalization. Even
397/// though it allows operations on paths that are normally unavailable, it can also be used to
398/// create entries that can potentially lead to further issues with operations that expect
399/// normalization such as symbolic links, junctions or reparse points.
400///
401/// [`MAX_PATH`]: https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation
402/// [`Path.GetInvalidFileNameChars`]: https://learn.microsoft.com/en-us/dotnet/api/system.io.path.getinvalidfilenamechars
403///
404/// See:
405///   * <https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file>
406///   * <https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats>
407pub fn verbatim_path(path: &Path) -> Cow<'_, Path> {
408    if !cfg!(windows) {
409        return Cow::Borrowed(path);
410    }
411
412    // Attempt to resolve a fully qualified path just like Win32 path normalization would.
413    // std::path::absolute calls GetFullPathNameW which defeats the purpose of this function
414    // as it results in Win32 default path normalization.
415    let resolved_path = if path.is_relative() {
416        Cow::Owned(CWD.join(path))
417    } else {
418        Cow::Borrowed(path)
419    };
420
421    // Fast Path: we only support verbatim conversion for Prefix::UNC and Prefix::Disk
422    if let Some(Component::Prefix(prefix)) = resolved_path.components().next() {
423        match prefix.kind() {
424            Prefix::UNC(..) | Prefix::Disk(_) => {},
425            // return as-is as there's no verbatim equivalent for `\\.\device`
426            Prefix::DeviceNS(_)
427            // return as-is as its already verbatim
428            | Prefix::Verbatim(_)
429            | Prefix::VerbatimDisk(_)
430            | Prefix::VerbatimUNC(..) => return Cow::Borrowed(path)
431        }
432    }
433
434    // Resolve relative directory components while avoiding default Win32 path normalization
435    let normalized_path = normalized(&resolved_path);
436
437    let mut components = normalized_path.components();
438    let Some(Component::Prefix(prefix)) = components.next() else {
439        return Cow::Borrowed(path);
440    };
441
442    match prefix.kind() {
443        // `DriveLetter:` -> `\\?\DriveLetter:`
444        Prefix::Disk(_) => {
445            let mut result = OsString::from(r"\\?\");
446            result.push(normalized_path.as_os_str()); // e.g. "C:"
447            Cow::Owned(PathBuf::from(result))
448        }
449        // `\\server\share` -> `\\?\UNC\server\share`
450        Prefix::UNC(server, share) => {
451            let mut result = OsString::from(r"\\?\UNC\");
452            result.push(server);
453            result.push(r"\");
454            result.push(share);
455            for component in components {
456                match component {
457                    Component::RootDir => {} // being cautious
458                    Component::Prefix(_) => {
459                        debug_assert!(false, "prefix already consumed");
460                    }
461                    Component::CurDir | Component::ParentDir => {
462                        debug_assert!(false, "path already normalized");
463                    }
464                    Component::Normal(_) => {
465                        result.push(r"\");
466                        result.push(component.as_os_str());
467                    }
468                }
469            }
470            Cow::Owned(PathBuf::from(result))
471        }
472        Prefix::DeviceNS(_)
473        | Prefix::Verbatim(_)
474        | Prefix::VerbatimDisk(_)
475        | Prefix::VerbatimUNC(..) => {
476            debug_assert!(false, "skipped via fast path");
477            Cow::Borrowed(path)
478        }
479    }
480}
481
482/// A path that can be serialized and deserialized in a portable way by converting Windows-style
483/// backslashes to forward slashes, and using a `.` for an empty path.
484///
485/// This implementation assumes that the path is valid UTF-8; otherwise, it won't roundtrip.
486#[derive(Debug, Clone, PartialEq, Eq)]
487pub struct PortablePath<'a>(&'a Path);
488
489#[derive(Debug, Clone, PartialEq, Eq)]
490pub struct PortablePathBuf(Box<Path>);
491
492#[cfg(feature = "schemars")]
493impl schemars::JsonSchema for PortablePathBuf {
494    fn schema_name() -> Cow<'static, str> {
495        Cow::Borrowed("PortablePathBuf")
496    }
497
498    fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
499        PathBuf::json_schema(_gen)
500    }
501}
502
503impl AsRef<Path> for PortablePath<'_> {
504    fn as_ref(&self) -> &Path {
505        self.0
506    }
507}
508
509impl<'a, T> From<&'a T> for PortablePath<'a>
510where
511    T: AsRef<Path> + ?Sized,
512{
513    fn from(path: &'a T) -> Self {
514        PortablePath(path.as_ref())
515    }
516}
517
518impl std::fmt::Display for PortablePath<'_> {
519    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
520        let path = self.0.to_slash_lossy();
521        if path.is_empty() {
522            write!(f, ".")
523        } else {
524            write!(f, "{path}")
525        }
526    }
527}
528
529impl std::fmt::Display for PortablePathBuf {
530    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
531        let path = self.0.to_slash_lossy();
532        if path.is_empty() {
533            write!(f, ".")
534        } else {
535            write!(f, "{path}")
536        }
537    }
538}
539
540impl From<&str> for PortablePathBuf {
541    fn from(path: &str) -> Self {
542        if path == "." {
543            Self(PathBuf::new().into_boxed_path())
544        } else {
545            Self(PathBuf::from(path).into_boxed_path())
546        }
547    }
548}
549
550impl From<PortablePathBuf> for Box<Path> {
551    fn from(portable: PortablePathBuf) -> Self {
552        portable.0
553    }
554}
555
556impl From<Box<Path>> for PortablePathBuf {
557    fn from(path: Box<Path>) -> Self {
558        Self(path)
559    }
560}
561
562impl<'a> From<&'a Path> for PortablePathBuf {
563    fn from(path: &'a Path) -> Self {
564        Box::<Path>::from(path).into()
565    }
566}
567
568#[cfg(feature = "serde")]
569impl serde::Serialize for PortablePathBuf {
570    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
571    where
572        S: serde::ser::Serializer,
573    {
574        self.to_string().serialize(serializer)
575    }
576}
577
578#[cfg(feature = "serde")]
579impl serde::Serialize for PortablePath<'_> {
580    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
581    where
582        S: serde::ser::Serializer,
583    {
584        self.to_string().serialize(serializer)
585    }
586}
587
588#[cfg(feature = "serde")]
589impl<'de> serde::de::Deserialize<'de> for PortablePathBuf {
590    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
591    where
592        D: serde::de::Deserializer<'de>,
593    {
594        let s = <Cow<'_, str>>::deserialize(deserializer)?;
595        if s == "." {
596            Ok(Self(PathBuf::new().into_boxed_path()))
597        } else {
598            Ok(Self(PathBuf::from(s.as_ref()).into_boxed_path()))
599        }
600    }
601}
602
603impl AsRef<Path> for PortablePathBuf {
604    fn as_ref(&self) -> &Path {
605        &self.0
606    }
607}
608
609#[cfg(test)]
610mod tests {
611    use super::*;
612
613    #[test]
614    fn test_normalize_url() {
615        if cfg!(windows) {
616            assert_eq!(
617                normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
618                "C:\\Users\\ferris\\wheel-0.42.0.tar.gz"
619            );
620        } else {
621            assert_eq!(
622                normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
623                "/C:/Users/ferris/wheel-0.42.0.tar.gz"
624            );
625        }
626
627        if cfg!(windows) {
628            assert_eq!(
629                normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
630                ".\\ferris\\wheel-0.42.0.tar.gz"
631            );
632        } else {
633            assert_eq!(
634                normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
635                "./ferris/wheel-0.42.0.tar.gz"
636            );
637        }
638
639        if cfg!(windows) {
640            assert_eq!(
641                normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
642                ".\\wheel cache\\wheel-0.42.0.tar.gz"
643            );
644        } else {
645            assert_eq!(
646                normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
647                "./wheel cache/wheel-0.42.0.tar.gz"
648            );
649        }
650    }
651
652    #[test]
653    fn test_normalize_path() {
654        let path = Path::new("/a/b/../c/./d");
655        let normalized = normalize_absolute_path(path).unwrap();
656        assert_eq!(normalized, Path::new("/a/c/d"));
657
658        let path = Path::new("/a/../c/./d");
659        let normalized = normalize_absolute_path(path).unwrap();
660        assert_eq!(normalized, Path::new("/c/d"));
661
662        // This should be an error.
663        let path = Path::new("/a/../../c/./d");
664        let err = normalize_absolute_path(path).unwrap_err();
665        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
666    }
667
668    #[test]
669    fn test_relative_to() {
670        assert_eq!(
671            relative_to(
672                Path::new("/home/ferris/carcinization/lib/python/site-packages/foo/__init__.py"),
673                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
674            )
675            .unwrap(),
676            Path::new("foo/__init__.py")
677        );
678        assert_eq!(
679            relative_to(
680                Path::new("/home/ferris/carcinization/lib/marker.txt"),
681                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
682            )
683            .unwrap(),
684            Path::new("../../marker.txt")
685        );
686        assert_eq!(
687            relative_to(
688                Path::new("/home/ferris/carcinization/bin/foo_launcher"),
689                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
690            )
691            .unwrap(),
692            Path::new("../../../bin/foo_launcher")
693        );
694    }
695
696    #[test]
697    fn test_normalize_relative() {
698        let cases = [
699            (
700                "../../workspace-git-path-dep-test/packages/c/../../packages/d",
701                "../../workspace-git-path-dep-test/packages/d",
702            ),
703            (
704                "workspace-git-path-dep-test/packages/c/../../packages/d",
705                "workspace-git-path-dep-test/packages/d",
706            ),
707            ("./a/../../b", "../b"),
708            ("/usr/../../foo", "/../foo"),
709            // Interior `.` segments (stripped by `Path::components`).
710            ("foo/./bar", "foo/bar"),
711            ("/a/./b/./c", "/a/b/c"),
712            ("./foo/bar", "foo/bar"),
713            (".", ""),
714            ("./.", ""),
715            ("foo/.", "foo"),
716            // Repeated separators (also stripped by `Path::components`).
717            ("foo//bar", "foo/bar"),
718            ("/a///b//c", "/a/b/c"),
719            // Mixed `.` and `..`.
720            ("foo/./../bar", "bar"),
721            ("foo/bar/./../baz", "foo/baz"),
722            // Already-normalized paths.
723            ("foo/bar", "foo/bar"),
724            ("/a/b/c", "/a/b/c"),
725            ("", ""),
726        ];
727        for (input, expected) in cases {
728            assert_eq!(
729                normalize_path(Path::new(input)),
730                Path::new(expected),
731                "input: {input:?}"
732            );
733        }
734
735        // Verify the fast path: already-normalized inputs are returned borrowed.
736        for already_normalized in ["foo/bar", "/a/b/c", "foo", "/", ""] {
737            let path = Path::new(already_normalized);
738            assert!(
739                matches!(normalize_path(path), Cow::Borrowed(_)),
740                "expected borrowed for {already_normalized:?}"
741            );
742        }
743    }
744
745    #[test]
746    fn test_normalize_path_under() {
747        assert_eq!(
748            normalize_path_under("scripts/script", "scripts"),
749            Some(PathBuf::from("scripts/script"))
750        );
751        assert_eq!(
752            normalize_path_under("/scripts/script", "/scripts"),
753            Some(PathBuf::from("/scripts/script"))
754        );
755        assert_eq!(
756            normalize_path_under("scripts/nested/../script", "scripts"),
757            Some(PathBuf::from("scripts/script"))
758        );
759        assert_eq!(
760            normalize_path_under("/scripts/nested/../script", "/scripts"),
761            Some(PathBuf::from("/scripts/script"))
762        );
763        assert_eq!(normalize_path_under("scripts/.", "scripts"), None);
764        assert_eq!(normalize_path_under("/scripts/.", "/scripts"), None);
765        assert_eq!(normalize_path_under("scripts/../script", "scripts"), None);
766        assert_eq!(normalize_path_under("/scripts/../script", "/scripts"), None);
767        assert_eq!(normalize_path_under("scripts/script", "."), None);
768        assert_eq!(normalize_path_under("scripts/script", ""), None);
769    }
770
771    #[test]
772    fn test_normalize_trailing_path_separator() {
773        let cases = [
774            (
775                "/home/ferris/projects/python/",
776                "/home/ferris/projects/python",
777            ),
778            ("python/", "python"),
779            ("/", "/"),
780            ("foo/bar/", "foo/bar"),
781            ("foo//", "foo"),
782        ];
783        for (input, expected) in cases {
784            assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
785        }
786    }
787
788    #[test]
789    #[cfg(windows)]
790    fn test_normalize_windows() {
791        let cases = [
792            (
793                r"C:\Users\Ferris\projects\python\",
794                r"C:\Users\Ferris\projects\python",
795            ),
796            (r"C:\foo\.\bar", r"C:\foo\bar"),
797            (r"C:\foo\\bar", r"C:\foo\bar"),
798            (r"C:\foo\bar\..\baz", r"C:\foo\baz"),
799            (r"foo\.\bar", r"foo\bar"),
800            (r"C:foo", r"C:foo"),
801            (r"C:\foo", r"C:\foo"),
802            (r"C:\\foo", r"C:\foo"),
803            (r"\\?\C:foo", r"\\?\C:foo"),
804            (r"\\?\C:\foo", r"\\?\C:\foo"),
805            (r"\\?\C:\\foo", r"\\?\C:\foo"),
806            (r"\\server\share\foo", r"\\server\share\foo"),
807        ];
808        for (input, expected) in cases {
809            assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
810        }
811    }
812
813    #[cfg(windows)]
814    #[test]
815    fn test_verbatim_path() {
816        let relative_path = format!(r"\\?\{}\path\to\logging.", CWD.simplified_display());
817        let relative_root = format!(
818            r"\\?\{}\path\to\logging.",
819            CWD.components()
820                .next()
821                .expect("expected a drive letter prefix")
822                .simplified_display()
823        );
824        let cases = [
825            // Non-Verbatim disk
826            (r"C:\path\to\logging.", r"\\?\C:\path\to\logging."),
827            (r"C:\path\to\.\logging.", r"\\?\C:\path\to\logging."),
828            (r"C:\path\to\..\to\logging.", r"\\?\C:\path\to\logging."),
829            (r"C:/path/to/../to/./logging.", r"\\?\C:\path\to\logging."),
830            (r"C:path\to\..\to\logging.", r"\\?\C:path\to\logging."), // @TODO(samypr100) we do not support expanding drive-relative paths
831            (r".\path\to\.\logging.", relative_path.as_str()),
832            (r"path\to\..\to\logging.", relative_path.as_str()),
833            (r"./path/to/logging.", relative_path.as_str()),
834            (r"\path\to\logging.", relative_root.as_str()),
835            // Non-Verbatim UNC
836            (
837                r"\\127.0.0.1\c$\path\to\logging.",
838                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
839            ),
840            (
841                r"\\127.0.0.1\c$\path\to\.\logging.",
842                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
843            ),
844            (
845                r"\\127.0.0.1\c$\path\to\..\to\logging.",
846                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
847            ),
848            (
849                r"//127.0.0.1/c$/path/to/../to/./logging.",
850                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
851            ),
852            // Verbatim Disk
853            (r"\\?\C:\path\to\logging.", r"\\?\C:\path\to\logging."),
854            // Verbatim UNC
855            (
856                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
857                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
858            ),
859            // Device Namespace
860            (r"\\.\PhysicalDrive0", r"\\.\PhysicalDrive0"),
861            (r"\\.\NUL", r"\\.\NUL"),
862        ];
863
864        for (input, expected) in cases {
865            assert_eq!(verbatim_path(Path::new(input)), Path::new(expected));
866        }
867    }
868}