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 a [`Path`].
267///
268/// Unlike [`normalize_absolute_path`], this works with relative paths and does never error.
269///
270/// Note that we can theoretically go beyond the root dir here (e.g. `/usr/../../foo` becomes
271/// `/../foo`), but that's not a (correctness) problem, we will fail later with a file not found
272/// error with a path computed from the user's input.
273///
274/// # Examples
275///
276/// In: `../../workspace-git-path-dep-test/packages/c/../../packages/d`
277/// Out: `../../workspace-git-path-dep-test/packages/d`
278///
279/// In: `workspace-git-path-dep-test/packages/c/../../packages/d`
280/// Out: `workspace-git-path-dep-test/packages/d`
281///
282/// In: `./a/../../b`
283fn normalized(path: &Path) -> PathBuf {
284    let mut normalized = PathBuf::new();
285    for component in path.components() {
286        match component {
287            Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
288                // Preserve filesystem roots and regular path components.
289                normalized.push(component);
290            }
291            Component::ParentDir => {
292                match normalized.components().next_back() {
293                    None | Some(Component::ParentDir | Component::RootDir) => {
294                        // Preserve leading and above-root `..`
295                        normalized.push(component);
296                    }
297                    Some(Component::Normal(_) | Component::Prefix(_) | Component::CurDir) => {
298                        // Remove inner `..`
299                        normalized.pop();
300                    }
301                }
302            }
303            Component::CurDir => {
304                // Remove `.`
305            }
306        }
307    }
308    normalized
309}
310
311/// Compute a path describing `path` relative to `base`.
312///
313/// `lib/python/site-packages/foo/__init__.py` and `lib/python/site-packages` -> `foo/__init__.py`
314/// `lib/marker.txt` and `lib/python/site-packages` -> `../../marker.txt`
315/// `bin/foo_launcher` and `lib/python/site-packages` -> `../../../bin/foo_launcher`
316///
317/// Returns `Err` if there is no relative path between `path` and `base` (for example, if the paths
318/// are on different drives on Windows).
319pub fn relative_to(
320    path: impl AsRef<Path>,
321    base: impl AsRef<Path>,
322) -> Result<PathBuf, std::io::Error> {
323    // Normalize both paths, to avoid intermediate `..` components.
324    let path = normalize_path(path.as_ref());
325    let base = normalize_path(base.as_ref());
326
327    // Find the longest common prefix, and also return the path stripped from that prefix
328    let (stripped, common_prefix) = base
329        .ancestors()
330        .find_map(|ancestor| {
331            // Simplifying removes the UNC path prefix on windows.
332            dunce::simplified(&path)
333                .strip_prefix(dunce::simplified(ancestor))
334                .ok()
335                .map(|stripped| (stripped, ancestor))
336        })
337        .ok_or_else(|| {
338            std::io::Error::other(format!(
339                "Trivial strip failed: {} vs. {}",
340                path.simplified_display(),
341                base.simplified_display()
342            ))
343        })?;
344
345    // go as many levels up as required
346    let levels_up = base.components().count() - common_prefix.components().count();
347    let up = std::iter::repeat_n("..", levels_up).collect::<PathBuf>();
348
349    Ok(up.join(stripped))
350}
351
352/// Try to compute a path relative to `base` if `should_relativize` is true, otherwise return
353/// the absolute path. Falls back to absolute if relativization fails.
354pub fn try_relative_to_if(
355    path: impl AsRef<Path>,
356    base: impl AsRef<Path>,
357    should_relativize: bool,
358) -> Result<PathBuf, std::io::Error> {
359    if should_relativize {
360        relative_to(&path, &base).or_else(|_| std::path::absolute(path.as_ref()))
361    } else {
362        std::path::absolute(path.as_ref())
363    }
364}
365
366/// Convert a [`Path`] to a Windows `verbatim` path (prefixed with `\\?\`) when possible to bypass
367/// Win32 path normalization such as [`MAX_PATH`] and removed trailing characters (dot, space).
368/// Other characters as defined by [`Path.GetInvalidFileNameChars`] are still prohibited. This
369/// function will attempt to perform path normalization similar to Win32 default normalization
370/// without triggering the existing Win32 limitations.
371///
372/// Only [`Prefix::UNC`] and [`Prefix::Disk`] conversion compatible components are supported.
373///   * [`Prefix::UNC`] `\\server\share` becomes `\\?\UNC\server\share`
374///   * [`Prefix::Disk`] `DriveLetter:` becomes `\\?\DriveLetter:`
375///
376/// Other representations do not yield a `verbatim` path. The following cases are returned as-is:
377///   * Non-Windows systems.
378///   * Device paths such as those starting with `\\.\`.
379///   * Paths already prefixed with `\\?\` or `\\?\UNC\`.
380///
381/// WARNING: Adding the `\\?\` prefix effectively skips Win32 default path normalization. Even
382/// though it allows operations on paths that are normally unavailable, it can also be used to
383/// create entries that can potentially lead to further issues with operations that expect
384/// normalization such as symbolic links, junctions or reparse points.
385///
386/// [`MAX_PATH`]: https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation
387/// [`Path.GetInvalidFileNameChars`]: https://learn.microsoft.com/en-us/dotnet/api/system.io.path.getinvalidfilenamechars
388///
389/// See:
390///   * <https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file>
391///   * <https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats>
392pub fn verbatim_path(path: &Path) -> Cow<'_, Path> {
393    if !cfg!(windows) {
394        return Cow::Borrowed(path);
395    }
396
397    // Attempt to resolve a fully qualified path just like Win32 path normalization would.
398    // std::path::absolute calls GetFullPathNameW which defeats the purpose of this function
399    // as it results in Win32 default path normalization.
400    let resolved_path = if path.is_relative() {
401        Cow::Owned(CWD.join(path))
402    } else {
403        Cow::Borrowed(path)
404    };
405
406    // Fast Path: we only support verbatim conversion for Prefix::UNC and Prefix::Disk
407    if let Some(Component::Prefix(prefix)) = resolved_path.components().next() {
408        match prefix.kind() {
409            Prefix::UNC(..) | Prefix::Disk(_) => {},
410            // return as-is as there's no verbatim equivalent for `\\.\device`
411            Prefix::DeviceNS(_)
412            // return as-is as its already verbatim
413            | Prefix::Verbatim(_)
414            | Prefix::VerbatimDisk(_)
415            | Prefix::VerbatimUNC(..) => return Cow::Borrowed(path)
416        }
417    }
418
419    // Resolve relative directory components while avoiding default Win32 path normalization
420    let normalized_path = normalized(&resolved_path);
421
422    let mut components = normalized_path.components();
423    let Some(Component::Prefix(prefix)) = components.next() else {
424        return Cow::Borrowed(path);
425    };
426
427    match prefix.kind() {
428        // `DriveLetter:` -> `\\?\DriveLetter:`
429        Prefix::Disk(_) => {
430            let mut result = OsString::from(r"\\?\");
431            result.push(normalized_path.as_os_str()); // e.g. "C:"
432            Cow::Owned(PathBuf::from(result))
433        }
434        // `\\server\share` -> `\\?\UNC\server\share`
435        Prefix::UNC(server, share) => {
436            let mut result = OsString::from(r"\\?\UNC\");
437            result.push(server);
438            result.push(r"\");
439            result.push(share);
440            for component in components {
441                match component {
442                    Component::RootDir => {} // being cautious
443                    Component::Prefix(_) => {
444                        debug_assert!(false, "prefix already consumed");
445                    }
446                    Component::CurDir | Component::ParentDir => {
447                        debug_assert!(false, "path already normalized");
448                    }
449                    Component::Normal(_) => {
450                        result.push(r"\");
451                        result.push(component.as_os_str());
452                    }
453                }
454            }
455            Cow::Owned(PathBuf::from(result))
456        }
457        Prefix::DeviceNS(_)
458        | Prefix::Verbatim(_)
459        | Prefix::VerbatimDisk(_)
460        | Prefix::VerbatimUNC(..) => {
461            debug_assert!(false, "skipped via fast path");
462            Cow::Borrowed(path)
463        }
464    }
465}
466
467/// A path that can be serialized and deserialized in a portable way by converting Windows-style
468/// backslashes to forward slashes, and using a `.` for an empty path.
469///
470/// This implementation assumes that the path is valid UTF-8; otherwise, it won't roundtrip.
471#[derive(Debug, Clone, PartialEq, Eq)]
472pub struct PortablePath<'a>(&'a Path);
473
474#[derive(Debug, Clone, PartialEq, Eq)]
475pub struct PortablePathBuf(Box<Path>);
476
477#[cfg(feature = "schemars")]
478impl schemars::JsonSchema for PortablePathBuf {
479    fn schema_name() -> Cow<'static, str> {
480        Cow::Borrowed("PortablePathBuf")
481    }
482
483    fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
484        PathBuf::json_schema(_gen)
485    }
486}
487
488impl AsRef<Path> for PortablePath<'_> {
489    fn as_ref(&self) -> &Path {
490        self.0
491    }
492}
493
494impl<'a, T> From<&'a T> for PortablePath<'a>
495where
496    T: AsRef<Path> + ?Sized,
497{
498    fn from(path: &'a T) -> Self {
499        PortablePath(path.as_ref())
500    }
501}
502
503impl std::fmt::Display for PortablePath<'_> {
504    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
505        let path = self.0.to_slash_lossy();
506        if path.is_empty() {
507            write!(f, ".")
508        } else {
509            write!(f, "{path}")
510        }
511    }
512}
513
514impl std::fmt::Display for PortablePathBuf {
515    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
516        let path = self.0.to_slash_lossy();
517        if path.is_empty() {
518            write!(f, ".")
519        } else {
520            write!(f, "{path}")
521        }
522    }
523}
524
525impl From<&str> for PortablePathBuf {
526    fn from(path: &str) -> Self {
527        if path == "." {
528            Self(PathBuf::new().into_boxed_path())
529        } else {
530            Self(PathBuf::from(path).into_boxed_path())
531        }
532    }
533}
534
535impl From<PortablePathBuf> for Box<Path> {
536    fn from(portable: PortablePathBuf) -> Self {
537        portable.0
538    }
539}
540
541impl From<Box<Path>> for PortablePathBuf {
542    fn from(path: Box<Path>) -> Self {
543        Self(path)
544    }
545}
546
547impl<'a> From<&'a Path> for PortablePathBuf {
548    fn from(path: &'a Path) -> Self {
549        Box::<Path>::from(path).into()
550    }
551}
552
553#[cfg(feature = "serde")]
554impl serde::Serialize for PortablePathBuf {
555    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
556    where
557        S: serde::ser::Serializer,
558    {
559        self.to_string().serialize(serializer)
560    }
561}
562
563#[cfg(feature = "serde")]
564impl serde::Serialize for PortablePath<'_> {
565    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
566    where
567        S: serde::ser::Serializer,
568    {
569        self.to_string().serialize(serializer)
570    }
571}
572
573#[cfg(feature = "serde")]
574impl<'de> serde::de::Deserialize<'de> for PortablePathBuf {
575    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
576    where
577        D: serde::de::Deserializer<'de>,
578    {
579        let s = <Cow<'_, str>>::deserialize(deserializer)?;
580        if s == "." {
581            Ok(Self(PathBuf::new().into_boxed_path()))
582        } else {
583            Ok(Self(PathBuf::from(s.as_ref()).into_boxed_path()))
584        }
585    }
586}
587
588impl AsRef<Path> for PortablePathBuf {
589    fn as_ref(&self) -> &Path {
590        &self.0
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    #[test]
599    fn test_normalize_url() {
600        if cfg!(windows) {
601            assert_eq!(
602                normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
603                "C:\\Users\\ferris\\wheel-0.42.0.tar.gz"
604            );
605        } else {
606            assert_eq!(
607                normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
608                "/C:/Users/ferris/wheel-0.42.0.tar.gz"
609            );
610        }
611
612        if cfg!(windows) {
613            assert_eq!(
614                normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
615                ".\\ferris\\wheel-0.42.0.tar.gz"
616            );
617        } else {
618            assert_eq!(
619                normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
620                "./ferris/wheel-0.42.0.tar.gz"
621            );
622        }
623
624        if cfg!(windows) {
625            assert_eq!(
626                normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
627                ".\\wheel cache\\wheel-0.42.0.tar.gz"
628            );
629        } else {
630            assert_eq!(
631                normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
632                "./wheel cache/wheel-0.42.0.tar.gz"
633            );
634        }
635    }
636
637    #[test]
638    fn test_normalize_path() {
639        let path = Path::new("/a/b/../c/./d");
640        let normalized = normalize_absolute_path(path).unwrap();
641        assert_eq!(normalized, Path::new("/a/c/d"));
642
643        let path = Path::new("/a/../c/./d");
644        let normalized = normalize_absolute_path(path).unwrap();
645        assert_eq!(normalized, Path::new("/c/d"));
646
647        // This should be an error.
648        let path = Path::new("/a/../../c/./d");
649        let err = normalize_absolute_path(path).unwrap_err();
650        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
651    }
652
653    #[test]
654    fn test_relative_to() {
655        assert_eq!(
656            relative_to(
657                Path::new("/home/ferris/carcinization/lib/python/site-packages/foo/__init__.py"),
658                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
659            )
660            .unwrap(),
661            Path::new("foo/__init__.py")
662        );
663        assert_eq!(
664            relative_to(
665                Path::new("/home/ferris/carcinization/lib/marker.txt"),
666                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
667            )
668            .unwrap(),
669            Path::new("../../marker.txt")
670        );
671        assert_eq!(
672            relative_to(
673                Path::new("/home/ferris/carcinization/bin/foo_launcher"),
674                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
675            )
676            .unwrap(),
677            Path::new("../../../bin/foo_launcher")
678        );
679    }
680
681    #[test]
682    fn test_normalize_relative() {
683        let cases = [
684            (
685                "../../workspace-git-path-dep-test/packages/c/../../packages/d",
686                "../../workspace-git-path-dep-test/packages/d",
687            ),
688            (
689                "workspace-git-path-dep-test/packages/c/../../packages/d",
690                "workspace-git-path-dep-test/packages/d",
691            ),
692            ("./a/../../b", "../b"),
693            ("/usr/../../foo", "/../foo"),
694            // Interior `.` segments (stripped by `Path::components`).
695            ("foo/./bar", "foo/bar"),
696            ("/a/./b/./c", "/a/b/c"),
697            ("./foo/bar", "foo/bar"),
698            (".", ""),
699            ("./.", ""),
700            ("foo/.", "foo"),
701            // Repeated separators (also stripped by `Path::components`).
702            ("foo//bar", "foo/bar"),
703            ("/a///b//c", "/a/b/c"),
704            // Mixed `.` and `..`.
705            ("foo/./../bar", "bar"),
706            ("foo/bar/./../baz", "foo/baz"),
707            // Already-normalized paths.
708            ("foo/bar", "foo/bar"),
709            ("/a/b/c", "/a/b/c"),
710            ("", ""),
711        ];
712        for (input, expected) in cases {
713            assert_eq!(
714                normalize_path(Path::new(input)),
715                Path::new(expected),
716                "input: {input:?}"
717            );
718        }
719
720        // Verify the fast path: already-normalized inputs are returned borrowed.
721        for already_normalized in ["foo/bar", "/a/b/c", "foo", "/", ""] {
722            let path = Path::new(already_normalized);
723            assert!(
724                matches!(normalize_path(path), Cow::Borrowed(_)),
725                "expected borrowed for {already_normalized:?}"
726            );
727        }
728    }
729
730    #[test]
731    fn test_normalize_trailing_path_separator() {
732        let cases = [
733            (
734                "/home/ferris/projects/python/",
735                "/home/ferris/projects/python",
736            ),
737            ("python/", "python"),
738            ("/", "/"),
739            ("foo/bar/", "foo/bar"),
740            ("foo//", "foo"),
741        ];
742        for (input, expected) in cases {
743            assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
744        }
745    }
746
747    #[test]
748    #[cfg(windows)]
749    fn test_normalize_windows() {
750        let cases = [
751            (
752                r"C:\Users\Ferris\projects\python\",
753                r"C:\Users\Ferris\projects\python",
754            ),
755            (r"C:\foo\.\bar", r"C:\foo\bar"),
756            (r"C:\foo\\bar", r"C:\foo\bar"),
757            (r"C:\foo\bar\..\baz", r"C:\foo\baz"),
758            (r"foo\.\bar", r"foo\bar"),
759            (r"C:foo", r"C:foo"),
760            (r"C:\foo", r"C:\foo"),
761            (r"C:\\foo", r"C:\foo"),
762            (r"\\?\C:foo", r"\\?\C:foo"),
763            (r"\\?\C:\foo", r"\\?\C:\foo"),
764            (r"\\?\C:\\foo", r"\\?\C:\foo"),
765            (r"\\server\share\foo", r"\\server\share\foo"),
766        ];
767        for (input, expected) in cases {
768            assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
769        }
770    }
771
772    #[cfg(windows)]
773    #[test]
774    fn test_verbatim_path() {
775        let relative_path = format!(r"\\?\{}\path\to\logging.", CWD.simplified_display());
776        let relative_root = format!(
777            r"\\?\{}\path\to\logging.",
778            CWD.components()
779                .next()
780                .expect("expected a drive letter prefix")
781                .simplified_display()
782        );
783        let cases = [
784            // Non-Verbatim disk
785            (r"C:\path\to\logging.", r"\\?\C:\path\to\logging."),
786            (r"C:\path\to\.\logging.", r"\\?\C:\path\to\logging."),
787            (r"C:\path\to\..\to\logging.", r"\\?\C:\path\to\logging."),
788            (r"C:/path/to/../to/./logging.", r"\\?\C:\path\to\logging."),
789            (r"C:path\to\..\to\logging.", r"\\?\C:path\to\logging."), // @TODO(samypr100) we do not support expanding drive-relative paths
790            (r".\path\to\.\logging.", relative_path.as_str()),
791            (r"path\to\..\to\logging.", relative_path.as_str()),
792            (r"./path/to/logging.", relative_path.as_str()),
793            (r"\path\to\logging.", relative_root.as_str()),
794            // Non-Verbatim UNC
795            (
796                r"\\127.0.0.1\c$\path\to\logging.",
797                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
798            ),
799            (
800                r"\\127.0.0.1\c$\path\to\.\logging.",
801                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
802            ),
803            (
804                r"\\127.0.0.1\c$\path\to\..\to\logging.",
805                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
806            ),
807            (
808                r"//127.0.0.1/c$/path/to/../to/./logging.",
809                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
810            ),
811            // Verbatim Disk
812            (r"\\?\C:\path\to\logging.", r"\\?\C:\path\to\logging."),
813            // Verbatim UNC
814            (
815                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
816                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
817            ),
818            // Device Namespace
819            (r"\\.\PhysicalDrive0", r"\\.\PhysicalDrive0"),
820            (r"\\.\NUL", r"\\.\NUL"),
821        ];
822
823        for (input, expected) in cases {
824            assert_eq!(verbatim_path(Path::new(input)), Path::new(expected));
825        }
826    }
827}