Skip to main content

uv_fs/
path.rs

1use std::borrow::Cow;
2use std::path::{Component, Path, PathBuf};
3use std::sync::LazyLock;
4
5use either::Either;
6use path_slash::PathExt;
7
8/// The current working directory.
9#[expect(clippy::print_stderr)]
10pub static CWD: LazyLock<PathBuf> = LazyLock::new(|| {
11    std::env::current_dir().unwrap_or_else(|_e| {
12        eprintln!("Current directory does not exist");
13        std::process::exit(1);
14    })
15});
16
17pub trait Simplified {
18    /// Simplify a [`Path`].
19    ///
20    /// On Windows, this will strip the `\\?\` prefix from paths. On other platforms, it's a no-op.
21    fn simplified(&self) -> &Path;
22
23    /// Render a [`Path`] for display.
24    ///
25    /// On Windows, this will strip the `\\?\` prefix from paths. On other platforms, it's
26    /// equivalent to [`std::path::Display`].
27    fn simplified_display(&self) -> impl std::fmt::Display;
28
29    /// Canonicalize a path without a `\\?\` prefix on Windows.
30    /// For a path that can't be canonicalized (e.g. on network drive or RAM drive on Windows),
31    /// this will return the absolute path if it exists.
32    fn simple_canonicalize(&self) -> std::io::Result<PathBuf>;
33
34    /// Render a [`Path`] for user-facing display.
35    ///
36    /// Like [`simplified_display`], but relativizes the path against the current working directory.
37    fn user_display(&self) -> impl std::fmt::Display;
38
39    /// Render a [`Path`] for user-facing display, where the [`Path`] is relative to a base path.
40    ///
41    /// If the [`Path`] is not relative to the base path, will attempt to relativize the path
42    /// against the current working directory.
43    fn user_display_from(&self, base: impl AsRef<Path>) -> impl std::fmt::Display;
44
45    /// Render a [`Path`] for user-facing display using a portable representation.
46    ///
47    /// Like [`user_display`], but uses a portable representation for relative paths.
48    fn portable_display(&self) -> impl std::fmt::Display;
49}
50
51impl<T: AsRef<Path>> Simplified for T {
52    fn simplified(&self) -> &Path {
53        dunce::simplified(self.as_ref())
54    }
55
56    fn simplified_display(&self) -> impl std::fmt::Display {
57        dunce::simplified(self.as_ref()).display()
58    }
59
60    fn simple_canonicalize(&self) -> std::io::Result<PathBuf> {
61        dunce::canonicalize(self.as_ref())
62    }
63
64    fn user_display(&self) -> impl std::fmt::Display {
65        let path = dunce::simplified(self.as_ref());
66
67        // If current working directory is root, display the path as-is.
68        if CWD.ancestors().nth(1).is_none() {
69            return path.display();
70        }
71
72        // Attempt to strip the current working directory, then the canonicalized current working
73        // directory, in case they differ.
74        let path = path.strip_prefix(CWD.simplified()).unwrap_or(path);
75
76        if path.as_os_str() == "" {
77            // Avoid printing an empty string for the current directory
78            return Path::new(".").display();
79        }
80
81        path.display()
82    }
83
84    fn user_display_from(&self, base: impl AsRef<Path>) -> impl std::fmt::Display {
85        let path = dunce::simplified(self.as_ref());
86
87        // If current working directory is root, display the path as-is.
88        if CWD.ancestors().nth(1).is_none() {
89            return path.display();
90        }
91
92        // Attempt to strip the base, then the current working directory, then the canonicalized
93        // current working directory, in case they differ.
94        let path = path
95            .strip_prefix(base.as_ref())
96            .unwrap_or_else(|_| path.strip_prefix(CWD.simplified()).unwrap_or(path));
97
98        if path.as_os_str() == "" {
99            // Avoid printing an empty string for the current directory
100            return Path::new(".").display();
101        }
102
103        path.display()
104    }
105
106    fn portable_display(&self) -> impl std::fmt::Display {
107        let path = dunce::simplified(self.as_ref());
108
109        // Attempt to strip the current working directory, then the canonicalized current working
110        // directory, in case they differ.
111        let path = path.strip_prefix(CWD.simplified()).unwrap_or(path);
112
113        // Use a portable representation for relative paths.
114        path.to_slash()
115            .map(Either::Left)
116            .unwrap_or_else(|| Either::Right(path.display()))
117    }
118}
119
120pub trait PythonExt {
121    /// Escape a [`Path`] for use in Python code.
122    fn escape_for_python(&self) -> String;
123}
124
125impl<T: AsRef<Path>> PythonExt for T {
126    fn escape_for_python(&self) -> String {
127        self.as_ref()
128            .to_string_lossy()
129            .replace('\\', "\\\\")
130            .replace('"', "\\\"")
131    }
132}
133
134/// Normalize the `path` component of a URL for use as a file path.
135///
136/// For example, on Windows, transforms `C:\Users\ferris\wheel-0.42.0.tar.gz` to
137/// `/C:/Users/ferris/wheel-0.42.0.tar.gz`.
138///
139/// On other platforms, this is a no-op.
140pub fn normalize_url_path(path: &str) -> Cow<'_, str> {
141    // Apply percent-decoding to the URL.
142    let path = percent_encoding::percent_decode_str(path)
143        .decode_utf8()
144        .unwrap_or(Cow::Borrowed(path));
145
146    // Return the path.
147    if cfg!(windows) {
148        Cow::Owned(
149            path.strip_prefix('/')
150                .unwrap_or(&path)
151                .replace('/', std::path::MAIN_SEPARATOR_STR),
152        )
153    } else {
154        path
155    }
156}
157
158/// Normalize a path, removing things like `.` and `..`.
159///
160/// Source: <https://github.com/rust-lang/cargo/blob/b48c41aedbd69ee3990d62a0e2006edbb506a480/crates/cargo-util/src/paths.rs#L76C1-L109C2>
161///
162/// CAUTION: Assumes that the path is already absolute.
163///
164/// CAUTION: This does not resolve symlinks (unlike
165/// [`std::fs::canonicalize`]). This may cause incorrect or surprising
166/// behavior at times. This should be used carefully. Unfortunately,
167/// [`std::fs::canonicalize`] can be hard to use correctly, since it can often
168/// fail, or on Windows returns annoying device paths.
169///
170/// # Errors
171///
172/// When a relative path is provided with `..` components that extend beyond the base directory.
173/// For example, `./a/../../b` cannot be normalized because it escapes the base directory.
174pub fn normalize_absolute_path(path: &Path) -> Result<PathBuf, std::io::Error> {
175    let mut components = path.components().peekable();
176    let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().copied() {
177        components.next();
178        PathBuf::from(c.as_os_str())
179    } else {
180        PathBuf::new()
181    };
182
183    for component in components {
184        match component {
185            Component::Prefix(..) => unreachable!(),
186            Component::RootDir => {
187                ret.push(component.as_os_str());
188            }
189            Component::CurDir => {}
190            Component::ParentDir => {
191                if !ret.pop() {
192                    return Err(std::io::Error::new(
193                        std::io::ErrorKind::InvalidInput,
194                        format!(
195                            "cannot normalize a relative path beyond the base directory: {}",
196                            path.display()
197                        ),
198                    ));
199                }
200            }
201            Component::Normal(c) => {
202                ret.push(c);
203            }
204        }
205    }
206    Ok(ret)
207}
208
209/// Normalize a [`Path`], removing `.`, `..`, and trailing slashes.
210pub fn normalize_path(path: &Path) -> Cow<'_, Path> {
211    // A path with `.` or `..` is not normalized.
212    if !path.components().all(|component| match component {
213        Component::Prefix(_) | Component::RootDir | Component::Normal(_) => true,
214        Component::ParentDir | Component::CurDir => false,
215    }) {
216        return Cow::Owned(normalized(path));
217    }
218
219    // A path with a trailing path separator is not normalized.
220    if path
221        .as_os_str()
222        .as_encoded_bytes()
223        .last()
224        .is_some_and(|trailing| {
225            if cfg!(windows) {
226                *trailing == b'\\' || *trailing == b'/'
227            } else if cfg!(unix) {
228                *trailing == b'/'
229            } else {
230                unimplemented!("Only Windows and Unix are supported")
231            }
232        })
233    {
234        return Cow::Owned(normalized(path));
235    }
236
237    // Fast path: if the path is already normalized, return it as-is.
238    Cow::Borrowed(path)
239}
240
241/// Normalize a [`PathBuf`], removing things like `.` and `..`.
242pub fn normalize_path_buf(path: PathBuf) -> PathBuf {
243    // Fast path: if the path is already normalized, return it as-is.
244    if path.components().all(|component| match component {
245        Component::Prefix(_) | Component::RootDir | Component::Normal(_) => true,
246        Component::ParentDir | Component::CurDir => false,
247    }) {
248        path
249    } else {
250        normalized(&path)
251    }
252}
253
254/// Normalize a [`Path`].
255///
256/// Unlike [`normalize_absolute_path`], this works with relative paths and does never error.
257///
258/// Note that we can theoretically go beyond the root dir here (e.g. `/usr/../../foo` becomes
259/// `/../foo`), but that's not a (correctness) problem, we will fail later with a file not found
260/// error with a path computed from the user's input.
261///
262/// # Examples
263///
264/// In: `../../workspace-git-path-dep-test/packages/c/../../packages/d`
265/// Out: `../../workspace-git-path-dep-test/packages/d`
266///
267/// In: `workspace-git-path-dep-test/packages/c/../../packages/d`
268/// Out: `workspace-git-path-dep-test/packages/d`
269///
270/// In: `./a/../../b`
271fn normalized(path: &Path) -> PathBuf {
272    let mut normalized = PathBuf::new();
273    for component in path.components() {
274        match component {
275            Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
276                // Preserve filesystem roots and regular path components.
277                normalized.push(component);
278            }
279            Component::ParentDir => {
280                match normalized.components().next_back() {
281                    None | Some(Component::ParentDir | Component::RootDir) => {
282                        // Preserve leading and above-root `..`
283                        normalized.push(component);
284                    }
285                    Some(Component::Normal(_) | Component::Prefix(_) | Component::CurDir) => {
286                        // Remove inner `..`
287                        normalized.pop();
288                    }
289                }
290            }
291            Component::CurDir => {
292                // Remove `.`
293            }
294        }
295    }
296    normalized
297}
298
299/// Compute a path describing `path` relative to `base`.
300///
301/// `lib/python/site-packages/foo/__init__.py` and `lib/python/site-packages` -> `foo/__init__.py`
302/// `lib/marker.txt` and `lib/python/site-packages` -> `../../marker.txt`
303/// `bin/foo_launcher` and `lib/python/site-packages` -> `../../../bin/foo_launcher`
304///
305/// Returns `Err` if there is no relative path between `path` and `base` (for example, if the paths
306/// are on different drives on Windows).
307pub fn relative_to(
308    path: impl AsRef<Path>,
309    base: impl AsRef<Path>,
310) -> Result<PathBuf, std::io::Error> {
311    // Normalize both paths, to avoid intermediate `..` components.
312    let path = normalize_path(path.as_ref());
313    let base = normalize_path(base.as_ref());
314
315    // Find the longest common prefix, and also return the path stripped from that prefix
316    let (stripped, common_prefix) = base
317        .ancestors()
318        .find_map(|ancestor| {
319            // Simplifying removes the UNC path prefix on windows.
320            dunce::simplified(&path)
321                .strip_prefix(dunce::simplified(ancestor))
322                .ok()
323                .map(|stripped| (stripped, ancestor))
324        })
325        .ok_or_else(|| {
326            std::io::Error::other(format!(
327                "Trivial strip failed: {} vs. {}",
328                path.simplified_display(),
329                base.simplified_display()
330            ))
331        })?;
332
333    // go as many levels up as required
334    let levels_up = base.components().count() - common_prefix.components().count();
335    let up = std::iter::repeat_n("..", levels_up).collect::<PathBuf>();
336
337    Ok(up.join(stripped))
338}
339
340/// Try to compute a path relative to `base` if `should_relativize` is true, otherwise return
341/// the absolute path. Falls back to absolute if relativization fails.
342pub fn try_relative_to_if(
343    path: impl AsRef<Path>,
344    base: impl AsRef<Path>,
345    should_relativize: bool,
346) -> Result<PathBuf, std::io::Error> {
347    if should_relativize {
348        relative_to(&path, &base).or_else(|_| std::path::absolute(path.as_ref()))
349    } else {
350        std::path::absolute(path.as_ref())
351    }
352}
353
354/// A path that can be serialized and deserialized in a portable way by converting Windows-style
355/// backslashes to forward slashes, and using a `.` for an empty path.
356///
357/// This implementation assumes that the path is valid UTF-8; otherwise, it won't roundtrip.
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub struct PortablePath<'a>(&'a Path);
360
361#[derive(Debug, Clone, PartialEq, Eq)]
362pub struct PortablePathBuf(Box<Path>);
363
364#[cfg(feature = "schemars")]
365impl schemars::JsonSchema for PortablePathBuf {
366    fn schema_name() -> Cow<'static, str> {
367        Cow::Borrowed("PortablePathBuf")
368    }
369
370    fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
371        PathBuf::json_schema(_gen)
372    }
373}
374
375impl AsRef<Path> for PortablePath<'_> {
376    fn as_ref(&self) -> &Path {
377        self.0
378    }
379}
380
381impl<'a, T> From<&'a T> for PortablePath<'a>
382where
383    T: AsRef<Path> + ?Sized,
384{
385    fn from(path: &'a T) -> Self {
386        PortablePath(path.as_ref())
387    }
388}
389
390impl std::fmt::Display for PortablePath<'_> {
391    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
392        let path = self.0.to_slash_lossy();
393        if path.is_empty() {
394            write!(f, ".")
395        } else {
396            write!(f, "{path}")
397        }
398    }
399}
400
401impl std::fmt::Display for PortablePathBuf {
402    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
403        let path = self.0.to_slash_lossy();
404        if path.is_empty() {
405            write!(f, ".")
406        } else {
407            write!(f, "{path}")
408        }
409    }
410}
411
412impl From<&str> for PortablePathBuf {
413    fn from(path: &str) -> Self {
414        if path == "." {
415            Self(PathBuf::new().into_boxed_path())
416        } else {
417            Self(PathBuf::from(path).into_boxed_path())
418        }
419    }
420}
421
422impl From<PortablePathBuf> for Box<Path> {
423    fn from(portable: PortablePathBuf) -> Self {
424        portable.0
425    }
426}
427
428impl From<Box<Path>> for PortablePathBuf {
429    fn from(path: Box<Path>) -> Self {
430        Self(path)
431    }
432}
433
434impl<'a> From<&'a Path> for PortablePathBuf {
435    fn from(path: &'a Path) -> Self {
436        Box::<Path>::from(path).into()
437    }
438}
439
440#[cfg(feature = "serde")]
441impl serde::Serialize for PortablePathBuf {
442    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
443    where
444        S: serde::ser::Serializer,
445    {
446        self.to_string().serialize(serializer)
447    }
448}
449
450#[cfg(feature = "serde")]
451impl serde::Serialize for PortablePath<'_> {
452    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
453    where
454        S: serde::ser::Serializer,
455    {
456        self.to_string().serialize(serializer)
457    }
458}
459
460#[cfg(feature = "serde")]
461impl<'de> serde::de::Deserialize<'de> for PortablePathBuf {
462    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
463    where
464        D: serde::de::Deserializer<'de>,
465    {
466        let s = <Cow<'_, str>>::deserialize(deserializer)?;
467        if s == "." {
468            Ok(Self(PathBuf::new().into_boxed_path()))
469        } else {
470            Ok(Self(PathBuf::from(s.as_ref()).into_boxed_path()))
471        }
472    }
473}
474
475impl AsRef<Path> for PortablePathBuf {
476    fn as_ref(&self) -> &Path {
477        &self.0
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    #[test]
486    fn test_normalize_url() {
487        if cfg!(windows) {
488            assert_eq!(
489                normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
490                "C:\\Users\\ferris\\wheel-0.42.0.tar.gz"
491            );
492        } else {
493            assert_eq!(
494                normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
495                "/C:/Users/ferris/wheel-0.42.0.tar.gz"
496            );
497        }
498
499        if cfg!(windows) {
500            assert_eq!(
501                normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
502                ".\\ferris\\wheel-0.42.0.tar.gz"
503            );
504        } else {
505            assert_eq!(
506                normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
507                "./ferris/wheel-0.42.0.tar.gz"
508            );
509        }
510
511        if cfg!(windows) {
512            assert_eq!(
513                normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
514                ".\\wheel cache\\wheel-0.42.0.tar.gz"
515            );
516        } else {
517            assert_eq!(
518                normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
519                "./wheel cache/wheel-0.42.0.tar.gz"
520            );
521        }
522    }
523
524    #[test]
525    fn test_normalize_path() {
526        let path = Path::new("/a/b/../c/./d");
527        let normalized = normalize_absolute_path(path).unwrap();
528        assert_eq!(normalized, Path::new("/a/c/d"));
529
530        let path = Path::new("/a/../c/./d");
531        let normalized = normalize_absolute_path(path).unwrap();
532        assert_eq!(normalized, Path::new("/c/d"));
533
534        // This should be an error.
535        let path = Path::new("/a/../../c/./d");
536        let err = normalize_absolute_path(path).unwrap_err();
537        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
538    }
539
540    #[test]
541    fn test_relative_to() {
542        assert_eq!(
543            relative_to(
544                Path::new("/home/ferris/carcinization/lib/python/site-packages/foo/__init__.py"),
545                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
546            )
547            .unwrap(),
548            Path::new("foo/__init__.py")
549        );
550        assert_eq!(
551            relative_to(
552                Path::new("/home/ferris/carcinization/lib/marker.txt"),
553                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
554            )
555            .unwrap(),
556            Path::new("../../marker.txt")
557        );
558        assert_eq!(
559            relative_to(
560                Path::new("/home/ferris/carcinization/bin/foo_launcher"),
561                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
562            )
563            .unwrap(),
564            Path::new("../../../bin/foo_launcher")
565        );
566    }
567
568    #[test]
569    fn test_normalize_relative() {
570        let cases = [
571            (
572                "../../workspace-git-path-dep-test/packages/c/../../packages/d",
573                "../../workspace-git-path-dep-test/packages/d",
574            ),
575            (
576                "workspace-git-path-dep-test/packages/c/../../packages/d",
577                "workspace-git-path-dep-test/packages/d",
578            ),
579            ("./a/../../b", "../b"),
580            ("/usr/../../foo", "/../foo"),
581        ];
582        for (input, expected) in cases {
583            assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
584        }
585    }
586
587    #[test]
588    fn test_normalize_trailing_path_separator() {
589        let cases = [
590            (
591                "/home/ferris/projects/python/",
592                "/home/ferris/projects/python",
593            ),
594            ("python/", "python"),
595            ("/", "/"),
596        ];
597        for (input, expected) in cases {
598            assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
599        }
600    }
601
602    #[test]
603    #[cfg(windows)]
604    fn test_normalize_trailing_path_separator_windows() {
605        let cases = [(
606            r"C:\Users\Ferris\projects\python\",
607            r"C:\Users\Ferris\projects\python",
608        )];
609        for (input, expected) in cases {
610            assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
611        }
612    }
613}