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 things like `.` and `..`.
210pub fn normalize_path(path: &Path) -> Cow<'_, Path> {
211    // Fast path: if the path is already normalized, return it as-is.
212    if path.components().all(|component| match component {
213        Component::Prefix(_) | Component::RootDir | Component::Normal(_) => true,
214        Component::ParentDir | Component::CurDir => false,
215    }) {
216        Cow::Borrowed(path)
217    } else {
218        Cow::Owned(normalized(path))
219    }
220}
221
222/// Normalize a [`PathBuf`], removing things like `.` and `..`.
223pub fn normalize_path_buf(path: PathBuf) -> PathBuf {
224    // Fast path: if the path is already normalized, return it as-is.
225    if path.components().all(|component| match component {
226        Component::Prefix(_) | Component::RootDir | Component::Normal(_) => true,
227        Component::ParentDir | Component::CurDir => false,
228    }) {
229        path
230    } else {
231        normalized(&path)
232    }
233}
234
235/// Normalize a [`Path`].
236///
237/// Unlike [`normalize_absolute_path`], this works with relative paths and does never error.
238///
239/// Note that we can theoretically go beyond the root dir here (e.g. `/usr/../../foo` becomes
240/// `/../foo`), but that's not a (correctness) problem, we will fail later with a file not found
241/// error with a path computed from the user's input.
242///
243/// # Examples
244///
245/// In: `../../workspace-git-path-dep-test/packages/c/../../packages/d`
246/// Out: `../../workspace-git-path-dep-test/packages/d`
247///
248/// In: `workspace-git-path-dep-test/packages/c/../../packages/d`
249/// Out: `workspace-git-path-dep-test/packages/d`
250///
251/// In: `./a/../../b`
252fn normalized(path: &Path) -> PathBuf {
253    let mut normalized = PathBuf::new();
254    for component in path.components() {
255        match component {
256            Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
257                // Preserve filesystem roots and regular path components.
258                normalized.push(component);
259            }
260            Component::ParentDir => {
261                match normalized.components().next_back() {
262                    None | Some(Component::ParentDir | Component::RootDir) => {
263                        // Preserve leading and above-root `..`
264                        normalized.push(component);
265                    }
266                    Some(Component::Normal(_) | Component::Prefix(_) | Component::CurDir) => {
267                        // Remove inner `..`
268                        normalized.pop();
269                    }
270                }
271            }
272            Component::CurDir => {
273                // Remove `.`
274            }
275        }
276    }
277    normalized
278}
279
280/// Compute a path describing `path` relative to `base`.
281///
282/// `lib/python/site-packages/foo/__init__.py` and `lib/python/site-packages` -> `foo/__init__.py`
283/// `lib/marker.txt` and `lib/python/site-packages` -> `../../marker.txt`
284/// `bin/foo_launcher` and `lib/python/site-packages` -> `../../../bin/foo_launcher`
285///
286/// Returns `Err` if there is no relative path between `path` and `base` (for example, if the paths
287/// are on different drives on Windows).
288pub fn relative_to(
289    path: impl AsRef<Path>,
290    base: impl AsRef<Path>,
291) -> Result<PathBuf, std::io::Error> {
292    // Normalize both paths, to avoid intermediate `..` components.
293    let path = normalize_path(path.as_ref());
294    let base = normalize_path(base.as_ref());
295
296    // Find the longest common prefix, and also return the path stripped from that prefix
297    let (stripped, common_prefix) = base
298        .ancestors()
299        .find_map(|ancestor| {
300            // Simplifying removes the UNC path prefix on windows.
301            dunce::simplified(&path)
302                .strip_prefix(dunce::simplified(ancestor))
303                .ok()
304                .map(|stripped| (stripped, ancestor))
305        })
306        .ok_or_else(|| {
307            std::io::Error::other(format!(
308                "Trivial strip failed: {} vs. {}",
309                path.simplified_display(),
310                base.simplified_display()
311            ))
312        })?;
313
314    // go as many levels up as required
315    let levels_up = base.components().count() - common_prefix.components().count();
316    let up = std::iter::repeat_n("..", levels_up).collect::<PathBuf>();
317
318    Ok(up.join(stripped))
319}
320
321/// Try to compute a path relative to `base` if `should_relativize` is true, otherwise return
322/// the absolute path. Falls back to absolute if relativization fails.
323pub fn try_relative_to_if(
324    path: impl AsRef<Path>,
325    base: impl AsRef<Path>,
326    should_relativize: bool,
327) -> Result<PathBuf, std::io::Error> {
328    if should_relativize {
329        relative_to(&path, &base).or_else(|_| std::path::absolute(path.as_ref()))
330    } else {
331        std::path::absolute(path.as_ref())
332    }
333}
334
335/// A path that can be serialized and deserialized in a portable way by converting Windows-style
336/// backslashes to forward slashes, and using a `.` for an empty path.
337///
338/// This implementation assumes that the path is valid UTF-8; otherwise, it won't roundtrip.
339#[derive(Debug, Clone, PartialEq, Eq)]
340pub struct PortablePath<'a>(&'a Path);
341
342#[derive(Debug, Clone, PartialEq, Eq)]
343pub struct PortablePathBuf(Box<Path>);
344
345#[cfg(feature = "schemars")]
346impl schemars::JsonSchema for PortablePathBuf {
347    fn schema_name() -> Cow<'static, str> {
348        Cow::Borrowed("PortablePathBuf")
349    }
350
351    fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
352        PathBuf::json_schema(_gen)
353    }
354}
355
356impl AsRef<Path> for PortablePath<'_> {
357    fn as_ref(&self) -> &Path {
358        self.0
359    }
360}
361
362impl<'a, T> From<&'a T> for PortablePath<'a>
363where
364    T: AsRef<Path> + ?Sized,
365{
366    fn from(path: &'a T) -> Self {
367        PortablePath(path.as_ref())
368    }
369}
370
371impl std::fmt::Display for PortablePath<'_> {
372    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373        let path = self.0.to_slash_lossy();
374        if path.is_empty() {
375            write!(f, ".")
376        } else {
377            write!(f, "{path}")
378        }
379    }
380}
381
382impl std::fmt::Display for PortablePathBuf {
383    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
384        let path = self.0.to_slash_lossy();
385        if path.is_empty() {
386            write!(f, ".")
387        } else {
388            write!(f, "{path}")
389        }
390    }
391}
392
393impl From<&str> for PortablePathBuf {
394    fn from(path: &str) -> Self {
395        if path == "." {
396            Self(PathBuf::new().into_boxed_path())
397        } else {
398            Self(PathBuf::from(path).into_boxed_path())
399        }
400    }
401}
402
403impl From<PortablePathBuf> for Box<Path> {
404    fn from(portable: PortablePathBuf) -> Self {
405        portable.0
406    }
407}
408
409impl From<Box<Path>> for PortablePathBuf {
410    fn from(path: Box<Path>) -> Self {
411        Self(path)
412    }
413}
414
415impl<'a> From<&'a Path> for PortablePathBuf {
416    fn from(path: &'a Path) -> Self {
417        Box::<Path>::from(path).into()
418    }
419}
420
421#[cfg(feature = "serde")]
422impl serde::Serialize for PortablePathBuf {
423    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
424    where
425        S: serde::ser::Serializer,
426    {
427        self.to_string().serialize(serializer)
428    }
429}
430
431#[cfg(feature = "serde")]
432impl serde::Serialize for PortablePath<'_> {
433    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
434    where
435        S: serde::ser::Serializer,
436    {
437        self.to_string().serialize(serializer)
438    }
439}
440
441#[cfg(feature = "serde")]
442impl<'de> serde::de::Deserialize<'de> for PortablePathBuf {
443    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
444    where
445        D: serde::de::Deserializer<'de>,
446    {
447        let s = <Cow<'_, str>>::deserialize(deserializer)?;
448        if s == "." {
449            Ok(Self(PathBuf::new().into_boxed_path()))
450        } else {
451            Ok(Self(PathBuf::from(s.as_ref()).into_boxed_path()))
452        }
453    }
454}
455
456impl AsRef<Path> for PortablePathBuf {
457    fn as_ref(&self) -> &Path {
458        &self.0
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    #[test]
467    fn test_normalize_url() {
468        if cfg!(windows) {
469            assert_eq!(
470                normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
471                "C:\\Users\\ferris\\wheel-0.42.0.tar.gz"
472            );
473        } else {
474            assert_eq!(
475                normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
476                "/C:/Users/ferris/wheel-0.42.0.tar.gz"
477            );
478        }
479
480        if cfg!(windows) {
481            assert_eq!(
482                normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
483                ".\\ferris\\wheel-0.42.0.tar.gz"
484            );
485        } else {
486            assert_eq!(
487                normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
488                "./ferris/wheel-0.42.0.tar.gz"
489            );
490        }
491
492        if cfg!(windows) {
493            assert_eq!(
494                normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
495                ".\\wheel cache\\wheel-0.42.0.tar.gz"
496            );
497        } else {
498            assert_eq!(
499                normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
500                "./wheel cache/wheel-0.42.0.tar.gz"
501            );
502        }
503    }
504
505    #[test]
506    fn test_normalize_path() {
507        let path = Path::new("/a/b/../c/./d");
508        let normalized = normalize_absolute_path(path).unwrap();
509        assert_eq!(normalized, Path::new("/a/c/d"));
510
511        let path = Path::new("/a/../c/./d");
512        let normalized = normalize_absolute_path(path).unwrap();
513        assert_eq!(normalized, Path::new("/c/d"));
514
515        // This should be an error.
516        let path = Path::new("/a/../../c/./d");
517        let err = normalize_absolute_path(path).unwrap_err();
518        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
519    }
520
521    #[test]
522    fn test_relative_to() {
523        assert_eq!(
524            relative_to(
525                Path::new("/home/ferris/carcinization/lib/python/site-packages/foo/__init__.py"),
526                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
527            )
528            .unwrap(),
529            Path::new("foo/__init__.py")
530        );
531        assert_eq!(
532            relative_to(
533                Path::new("/home/ferris/carcinization/lib/marker.txt"),
534                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
535            )
536            .unwrap(),
537            Path::new("../../marker.txt")
538        );
539        assert_eq!(
540            relative_to(
541                Path::new("/home/ferris/carcinization/bin/foo_launcher"),
542                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
543            )
544            .unwrap(),
545            Path::new("../../../bin/foo_launcher")
546        );
547    }
548
549    #[test]
550    fn test_normalize_relative() {
551        let cases = [
552            (
553                "../../workspace-git-path-dep-test/packages/c/../../packages/d",
554                "../../workspace-git-path-dep-test/packages/d",
555            ),
556            (
557                "workspace-git-path-dep-test/packages/c/../../packages/d",
558                "workspace-git-path-dep-test/packages/d",
559            ),
560            ("./a/../../b", "../b"),
561            ("/usr/../../foo", "/../foo"),
562        ];
563        for (input, expected) in cases {
564            assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
565        }
566    }
567}