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