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 = components
178        .next_if_map_mut(|component| match component {
179            Component::Prefix(..) => Some(PathBuf::from(component.as_os_str())),
180            _ => None,
181        })
182        .unwrap_or_default();
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/// Find the root of the nearest Git repository containing `path`.
368///
369/// A `.git` directory or file is treated as a repository marker to support both regular
370/// repositories and linked worktrees.
371pub fn find_git_repository_root(path: &Path) -> Option<&Path> {
372    // TODO: Consider supporting GIT_CEILING_DIRECTORIES here.
373    path.ancestors()
374        .find(|ancestor| ancestor.join(".git").exists())
375}
376
377/// Try to compute a path relative to `base` if `should_relativize` is true, otherwise return
378/// the absolute path. Falls back to absolute if relativization fails.
379pub fn try_relative_to_if(
380    path: impl AsRef<Path>,
381    base: impl AsRef<Path>,
382    should_relativize: bool,
383) -> Result<PathBuf, std::io::Error> {
384    if should_relativize {
385        relative_to(&path, &base).or_else(|_| std::path::absolute(path.as_ref()))
386    } else {
387        std::path::absolute(path.as_ref())
388    }
389}
390
391/// Convert a [`Path`] to a Windows `verbatim` path (prefixed with `\\?\`) when possible to bypass
392/// Win32 path normalization such as [`MAX_PATH`] and removed trailing characters (dot, space).
393/// Other characters as defined by [`Path.GetInvalidFileNameChars`] are still prohibited. This
394/// function will attempt to perform path normalization similar to Win32 default normalization
395/// without triggering the existing Win32 limitations.
396///
397/// Only [`Prefix::UNC`] and [`Prefix::Disk`] conversion compatible components are supported.
398///   * [`Prefix::UNC`] `\\server\share` becomes `\\?\UNC\server\share`
399///   * [`Prefix::Disk`] `DriveLetter:` becomes `\\?\DriveLetter:`
400///
401/// Other representations do not yield a `verbatim` path. The following cases are returned as-is:
402///   * Non-Windows systems.
403///   * Device paths such as those starting with `\\.\`.
404///   * Paths already prefixed with `\\?\` or `\\?\UNC\`.
405///
406/// WARNING: Adding the `\\?\` prefix effectively skips Win32 default path normalization. Even
407/// though it allows operations on paths that are normally unavailable, it can also be used to
408/// create entries that can potentially lead to further issues with operations that expect
409/// normalization such as symbolic links, junctions or reparse points.
410///
411/// [`MAX_PATH`]: https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation
412/// [`Path.GetInvalidFileNameChars`]: https://learn.microsoft.com/en-us/dotnet/api/system.io.path.getinvalidfilenamechars
413///
414/// See:
415///   * <https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file>
416///   * <https://learn.microsoft.com/en-us/dotnet/standard/io/file-path-formats>
417pub fn verbatim_path(path: &Path) -> Cow<'_, Path> {
418    if !cfg!(windows) {
419        return Cow::Borrowed(path);
420    }
421
422    // Attempt to resolve a fully qualified path just like Win32 path normalization would.
423    // std::path::absolute calls GetFullPathNameW which defeats the purpose of this function
424    // as it results in Win32 default path normalization.
425    let resolved_path = if path.is_relative() {
426        Cow::Owned(CWD.join(path))
427    } else {
428        Cow::Borrowed(path)
429    };
430
431    // Fast Path: we only support verbatim conversion for Prefix::UNC and Prefix::Disk
432    if let Some(Component::Prefix(prefix)) = resolved_path.components().next() {
433        match prefix.kind() {
434            Prefix::UNC(..) | Prefix::Disk(_) => {},
435            // return as-is as there's no verbatim equivalent for `\\.\device`
436            Prefix::DeviceNS(_)
437            // return as-is as its already verbatim
438            | Prefix::Verbatim(_)
439            | Prefix::VerbatimDisk(_)
440            | Prefix::VerbatimUNC(..) => return Cow::Borrowed(path)
441        }
442    }
443
444    // Resolve relative directory components while avoiding default Win32 path normalization
445    let normalized_path = normalized(&resolved_path);
446
447    let mut components = normalized_path.components();
448    let Some(Component::Prefix(prefix)) = components.next() else {
449        return Cow::Borrowed(path);
450    };
451
452    match prefix.kind() {
453        // `DriveLetter:` -> `\\?\DriveLetter:`
454        Prefix::Disk(_) => {
455            let mut result = OsString::from(r"\\?\");
456            result.push(normalized_path.as_os_str()); // e.g. "C:"
457            Cow::Owned(PathBuf::from(result))
458        }
459        // `\\server\share` -> `\\?\UNC\server\share`
460        Prefix::UNC(server, share) => {
461            let mut result = OsString::from(r"\\?\UNC\");
462            result.push(server);
463            result.push(r"\");
464            result.push(share);
465            for component in components {
466                match component {
467                    Component::RootDir => {} // being cautious
468                    Component::Prefix(_) => {
469                        debug_assert!(false, "prefix already consumed");
470                    }
471                    Component::CurDir | Component::ParentDir => {
472                        debug_assert!(false, "path already normalized");
473                    }
474                    Component::Normal(_) => {
475                        result.push(r"\");
476                        result.push(component.as_os_str());
477                    }
478                }
479            }
480            Cow::Owned(PathBuf::from(result))
481        }
482        Prefix::DeviceNS(_)
483        | Prefix::Verbatim(_)
484        | Prefix::VerbatimDisk(_)
485        | Prefix::VerbatimUNC(..) => {
486            debug_assert!(false, "skipped via fast path");
487            Cow::Borrowed(path)
488        }
489    }
490}
491
492/// A path that can be serialized and deserialized in a portable way by converting Windows-style
493/// backslashes to forward slashes, and using a `.` for an empty path.
494///
495/// This implementation assumes that the path is valid UTF-8; otherwise, it won't roundtrip.
496#[derive(Debug, Clone, PartialEq, Eq)]
497pub struct PortablePath<'a>(&'a Path);
498
499#[derive(Debug, Clone, PartialEq, Eq)]
500pub struct PortablePathBuf(Box<Path>);
501
502#[cfg(feature = "schemars")]
503impl schemars::JsonSchema for PortablePathBuf {
504    fn schema_name() -> Cow<'static, str> {
505        Cow::Borrowed("PortablePathBuf")
506    }
507
508    fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
509        PathBuf::json_schema(_gen)
510    }
511}
512
513impl AsRef<Path> for PortablePath<'_> {
514    fn as_ref(&self) -> &Path {
515        self.0
516    }
517}
518
519impl<'a, T> From<&'a T> for PortablePath<'a>
520where
521    T: AsRef<Path> + ?Sized,
522{
523    fn from(path: &'a T) -> Self {
524        PortablePath(path.as_ref())
525    }
526}
527
528impl std::fmt::Display for PortablePath<'_> {
529    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
530        let path = self.0.to_slash_lossy();
531        if path.is_empty() {
532            write!(f, ".")
533        } else {
534            write!(f, "{path}")
535        }
536    }
537}
538
539impl std::fmt::Display for PortablePathBuf {
540    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
541        let path = self.0.to_slash_lossy();
542        if path.is_empty() {
543            write!(f, ".")
544        } else {
545            write!(f, "{path}")
546        }
547    }
548}
549
550impl From<&str> for PortablePathBuf {
551    fn from(path: &str) -> Self {
552        if path == "." {
553            Self(PathBuf::new().into_boxed_path())
554        } else {
555            Self(PathBuf::from(path).into_boxed_path())
556        }
557    }
558}
559
560impl From<PortablePathBuf> for Box<Path> {
561    fn from(portable: PortablePathBuf) -> Self {
562        portable.0
563    }
564}
565
566impl From<Box<Path>> for PortablePathBuf {
567    fn from(path: Box<Path>) -> Self {
568        Self(path)
569    }
570}
571
572impl<'a> From<&'a Path> for PortablePathBuf {
573    fn from(path: &'a Path) -> Self {
574        Box::<Path>::from(path).into()
575    }
576}
577
578#[cfg(feature = "serde")]
579impl serde::Serialize for PortablePathBuf {
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 serde::Serialize for PortablePath<'_> {
590    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
591    where
592        S: serde::ser::Serializer,
593    {
594        self.to_string().serialize(serializer)
595    }
596}
597
598#[cfg(feature = "serde")]
599impl<'de> serde::de::Deserialize<'de> for PortablePathBuf {
600    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
601    where
602        D: serde::de::Deserializer<'de>,
603    {
604        let s = <Cow<'_, str>>::deserialize(deserializer)?;
605        if s == "." {
606            Ok(Self(PathBuf::new().into_boxed_path()))
607        } else {
608            Ok(Self(PathBuf::from(s.as_ref()).into_boxed_path()))
609        }
610    }
611}
612
613impl AsRef<Path> for PortablePathBuf {
614    fn as_ref(&self) -> &Path {
615        &self.0
616    }
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622
623    #[test]
624    fn test_find_git_repository_root() -> std::io::Result<()> {
625        let temp_dir = tempfile::tempdir()?;
626
627        let repository = temp_dir.path().join("repository");
628        let nested = repository.join("packages/project");
629        fs_err::create_dir_all(repository.join(".git"))?;
630        fs_err::create_dir_all(&nested)?;
631        assert_eq!(
632            find_git_repository_root(&nested),
633            Some(repository.as_path())
634        );
635
636        let worktree = temp_dir.path().join("worktree");
637        let nested = worktree.join("packages/project");
638        fs_err::create_dir_all(&nested)?;
639        fs_err::write(worktree.join(".git"), "gitdir: ../repository/.git")?;
640        assert_eq!(find_git_repository_root(&nested), Some(worktree.as_path()));
641
642        Ok(())
643    }
644
645    #[test]
646    fn test_normalize_url() {
647        if cfg!(windows) {
648            assert_eq!(
649                normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
650                "C:\\Users\\ferris\\wheel-0.42.0.tar.gz"
651            );
652        } else {
653            assert_eq!(
654                normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
655                "/C:/Users/ferris/wheel-0.42.0.tar.gz"
656            );
657        }
658
659        if cfg!(windows) {
660            assert_eq!(
661                normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
662                ".\\ferris\\wheel-0.42.0.tar.gz"
663            );
664        } else {
665            assert_eq!(
666                normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
667                "./ferris/wheel-0.42.0.tar.gz"
668            );
669        }
670
671        if cfg!(windows) {
672            assert_eq!(
673                normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
674                ".\\wheel cache\\wheel-0.42.0.tar.gz"
675            );
676        } else {
677            assert_eq!(
678                normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
679                "./wheel cache/wheel-0.42.0.tar.gz"
680            );
681        }
682    }
683
684    #[test]
685    fn test_normalize_path() {
686        let path = Path::new("/a/b/../c/./d");
687        let normalized = normalize_absolute_path(path).unwrap();
688        assert_eq!(normalized, Path::new("/a/c/d"));
689
690        let path = Path::new("/a/../c/./d");
691        let normalized = normalize_absolute_path(path).unwrap();
692        assert_eq!(normalized, Path::new("/c/d"));
693
694        // This should be an error.
695        let path = Path::new("/a/../../c/./d");
696        let err = normalize_absolute_path(path).unwrap_err();
697        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
698    }
699
700    #[test]
701    fn test_relative_to() {
702        assert_eq!(
703            relative_to(
704                Path::new("/home/ferris/carcinization/lib/python/site-packages/foo/__init__.py"),
705                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
706            )
707            .unwrap(),
708            Path::new("foo/__init__.py")
709        );
710        assert_eq!(
711            relative_to(
712                Path::new("/home/ferris/carcinization/lib/marker.txt"),
713                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
714            )
715            .unwrap(),
716            Path::new("../../marker.txt")
717        );
718        assert_eq!(
719            relative_to(
720                Path::new("/home/ferris/carcinization/bin/foo_launcher"),
721                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
722            )
723            .unwrap(),
724            Path::new("../../../bin/foo_launcher")
725        );
726    }
727
728    #[test]
729    fn test_normalize_relative() {
730        let cases = [
731            (
732                "../../workspace-git-path-dep-test/packages/c/../../packages/d",
733                "../../workspace-git-path-dep-test/packages/d",
734            ),
735            (
736                "workspace-git-path-dep-test/packages/c/../../packages/d",
737                "workspace-git-path-dep-test/packages/d",
738            ),
739            ("./a/../../b", "../b"),
740            ("/usr/../../foo", "/../foo"),
741            // Interior `.` segments (stripped by `Path::components`).
742            ("foo/./bar", "foo/bar"),
743            ("/a/./b/./c", "/a/b/c"),
744            ("./foo/bar", "foo/bar"),
745            (".", ""),
746            ("./.", ""),
747            ("foo/.", "foo"),
748            // Repeated separators (also stripped by `Path::components`).
749            ("foo//bar", "foo/bar"),
750            ("/a///b//c", "/a/b/c"),
751            // Mixed `.` and `..`.
752            ("foo/./../bar", "bar"),
753            ("foo/bar/./../baz", "foo/baz"),
754            // Already-normalized paths.
755            ("foo/bar", "foo/bar"),
756            ("/a/b/c", "/a/b/c"),
757            ("", ""),
758        ];
759        for (input, expected) in cases {
760            assert_eq!(
761                normalize_path(Path::new(input)),
762                Path::new(expected),
763                "input: {input:?}"
764            );
765        }
766
767        // Verify the fast path: already-normalized inputs are returned borrowed.
768        for already_normalized in ["foo/bar", "/a/b/c", "foo", "/", ""] {
769            let path = Path::new(already_normalized);
770            assert!(
771                matches!(normalize_path(path), Cow::Borrowed(_)),
772                "expected borrowed for {already_normalized:?}"
773            );
774        }
775    }
776
777    #[test]
778    fn test_normalize_path_under() {
779        assert_eq!(
780            normalize_path_under("scripts/script", "scripts"),
781            Some(PathBuf::from("scripts/script"))
782        );
783        assert_eq!(
784            normalize_path_under("/scripts/script", "/scripts"),
785            Some(PathBuf::from("/scripts/script"))
786        );
787        assert_eq!(
788            normalize_path_under("scripts/nested/../script", "scripts"),
789            Some(PathBuf::from("scripts/script"))
790        );
791        assert_eq!(
792            normalize_path_under("/scripts/nested/../script", "/scripts"),
793            Some(PathBuf::from("/scripts/script"))
794        );
795        assert_eq!(normalize_path_under("scripts/.", "scripts"), None);
796        assert_eq!(normalize_path_under("/scripts/.", "/scripts"), None);
797        assert_eq!(normalize_path_under("scripts/../script", "scripts"), None);
798        assert_eq!(normalize_path_under("/scripts/../script", "/scripts"), None);
799        assert_eq!(normalize_path_under("scripts/script", "."), None);
800        assert_eq!(normalize_path_under("scripts/script", ""), None);
801    }
802
803    #[test]
804    fn test_normalize_trailing_path_separator() {
805        let cases = [
806            (
807                "/home/ferris/projects/python/",
808                "/home/ferris/projects/python",
809            ),
810            ("python/", "python"),
811            ("/", "/"),
812            ("foo/bar/", "foo/bar"),
813            ("foo//", "foo"),
814        ];
815        for (input, expected) in cases {
816            assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
817        }
818    }
819
820    #[test]
821    #[cfg(windows)]
822    fn test_normalize_windows() {
823        let cases = [
824            (
825                r"C:\Users\Ferris\projects\python\",
826                r"C:\Users\Ferris\projects\python",
827            ),
828            (r"C:\foo\.\bar", r"C:\foo\bar"),
829            (r"C:\foo\\bar", r"C:\foo\bar"),
830            (r"C:\foo\bar\..\baz", r"C:\foo\baz"),
831            (r"foo\.\bar", r"foo\bar"),
832            (r"C:foo", r"C:foo"),
833            (r"C:\foo", r"C:\foo"),
834            (r"C:\\foo", r"C:\foo"),
835            (r"\\?\C:foo", r"\\?\C:foo"),
836            (r"\\?\C:\foo", r"\\?\C:\foo"),
837            (r"\\?\C:\\foo", r"\\?\C:\foo"),
838            (r"\\server\share\foo", r"\\server\share\foo"),
839        ];
840        for (input, expected) in cases {
841            assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
842        }
843    }
844
845    #[cfg(windows)]
846    #[test]
847    fn test_verbatim_path() {
848        let relative_path = format!(r"\\?\{}\path\to\logging.", CWD.simplified_display());
849        let relative_root = format!(
850            r"\\?\{}\path\to\logging.",
851            CWD.components()
852                .next()
853                .expect("expected a drive letter prefix")
854                .simplified_display()
855        );
856        let cases = [
857            // Non-Verbatim disk
858            (r"C:\path\to\logging.", r"\\?\C:\path\to\logging."),
859            (r"C:\path\to\.\logging.", r"\\?\C:\path\to\logging."),
860            (r"C:\path\to\..\to\logging.", r"\\?\C:\path\to\logging."),
861            (r"C:/path/to/../to/./logging.", r"\\?\C:\path\to\logging."),
862            (r"C:path\to\..\to\logging.", r"\\?\C:path\to\logging."), // @TODO(samypr100) we do not support expanding drive-relative paths
863            (r".\path\to\.\logging.", relative_path.as_str()),
864            (r"path\to\..\to\logging.", relative_path.as_str()),
865            (r"./path/to/logging.", relative_path.as_str()),
866            (r"\path\to\logging.", relative_root.as_str()),
867            // Non-Verbatim UNC
868            (
869                r"\\127.0.0.1\c$\path\to\logging.",
870                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
871            ),
872            (
873                r"\\127.0.0.1\c$\path\to\.\logging.",
874                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
875            ),
876            (
877                r"\\127.0.0.1\c$\path\to\..\to\logging.",
878                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
879            ),
880            (
881                r"//127.0.0.1/c$/path/to/../to/./logging.",
882                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
883            ),
884            // Verbatim Disk
885            (r"\\?\C:\path\to\logging.", r"\\?\C:\path\to\logging."),
886            // Verbatim UNC
887            (
888                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
889                r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
890            ),
891            // Device Namespace
892            (r"\\.\PhysicalDrive0", r"\\.\PhysicalDrive0"),
893            (r"\\.\NUL", r"\\.\NUL"),
894        ];
895
896        for (input, expected) in cases {
897            assert_eq!(verbatim_path(Path::new(input)), Path::new(expected));
898        }
899    }
900}