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#[allow(clippy::exit, 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/// A path that can be serialized and deserialized in a portable way by converting Windows-style
322/// backslashes to forward slashes, and using a `.` for an empty path.
323///
324/// This implementation assumes that the path is valid UTF-8; otherwise, it won't roundtrip.
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub struct PortablePath<'a>(&'a Path);
327
328#[derive(Debug, Clone, PartialEq, Eq)]
329pub struct PortablePathBuf(Box<Path>);
330
331#[cfg(feature = "schemars")]
332impl schemars::JsonSchema for PortablePathBuf {
333    fn schema_name() -> Cow<'static, str> {
334        Cow::Borrowed("PortablePathBuf")
335    }
336
337    fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
338        PathBuf::json_schema(_gen)
339    }
340}
341
342impl AsRef<Path> for PortablePath<'_> {
343    fn as_ref(&self) -> &Path {
344        self.0
345    }
346}
347
348impl<'a, T> From<&'a T> for PortablePath<'a>
349where
350    T: AsRef<Path> + ?Sized,
351{
352    fn from(path: &'a T) -> Self {
353        PortablePath(path.as_ref())
354    }
355}
356
357impl std::fmt::Display for PortablePath<'_> {
358    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359        let path = self.0.to_slash_lossy();
360        if path.is_empty() {
361            write!(f, ".")
362        } else {
363            write!(f, "{path}")
364        }
365    }
366}
367
368impl std::fmt::Display for PortablePathBuf {
369    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370        let path = self.0.to_slash_lossy();
371        if path.is_empty() {
372            write!(f, ".")
373        } else {
374            write!(f, "{path}")
375        }
376    }
377}
378
379impl From<&str> for PortablePathBuf {
380    fn from(path: &str) -> Self {
381        if path == "." {
382            Self(PathBuf::new().into_boxed_path())
383        } else {
384            Self(PathBuf::from(path).into_boxed_path())
385        }
386    }
387}
388
389impl From<PortablePathBuf> for Box<Path> {
390    fn from(portable: PortablePathBuf) -> Self {
391        portable.0
392    }
393}
394
395impl From<Box<Path>> for PortablePathBuf {
396    fn from(path: Box<Path>) -> Self {
397        Self(path)
398    }
399}
400
401impl<'a> From<&'a Path> for PortablePathBuf {
402    fn from(path: &'a Path) -> Self {
403        Box::<Path>::from(path).into()
404    }
405}
406
407#[cfg(feature = "serde")]
408impl serde::Serialize for PortablePathBuf {
409    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
410    where
411        S: serde::ser::Serializer,
412    {
413        self.to_string().serialize(serializer)
414    }
415}
416
417#[cfg(feature = "serde")]
418impl serde::Serialize for PortablePath<'_> {
419    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
420    where
421        S: serde::ser::Serializer,
422    {
423        self.to_string().serialize(serializer)
424    }
425}
426
427#[cfg(feature = "serde")]
428impl<'de> serde::de::Deserialize<'de> for PortablePathBuf {
429    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
430    where
431        D: serde::de::Deserializer<'de>,
432    {
433        let s = String::deserialize(deserializer)?;
434        if s == "." {
435            Ok(Self(PathBuf::new().into_boxed_path()))
436        } else {
437            Ok(Self(PathBuf::from(s).into_boxed_path()))
438        }
439    }
440}
441
442impl AsRef<Path> for PortablePathBuf {
443    fn as_ref(&self) -> &Path {
444        &self.0
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn test_normalize_url() {
454        if cfg!(windows) {
455            assert_eq!(
456                normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
457                "C:\\Users\\ferris\\wheel-0.42.0.tar.gz"
458            );
459        } else {
460            assert_eq!(
461                normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
462                "/C:/Users/ferris/wheel-0.42.0.tar.gz"
463            );
464        }
465
466        if cfg!(windows) {
467            assert_eq!(
468                normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
469                ".\\ferris\\wheel-0.42.0.tar.gz"
470            );
471        } else {
472            assert_eq!(
473                normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
474                "./ferris/wheel-0.42.0.tar.gz"
475            );
476        }
477
478        if cfg!(windows) {
479            assert_eq!(
480                normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
481                ".\\wheel cache\\wheel-0.42.0.tar.gz"
482            );
483        } else {
484            assert_eq!(
485                normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
486                "./wheel cache/wheel-0.42.0.tar.gz"
487            );
488        }
489    }
490
491    #[test]
492    fn test_normalize_path() {
493        let path = Path::new("/a/b/../c/./d");
494        let normalized = normalize_absolute_path(path).unwrap();
495        assert_eq!(normalized, Path::new("/a/c/d"));
496
497        let path = Path::new("/a/../c/./d");
498        let normalized = normalize_absolute_path(path).unwrap();
499        assert_eq!(normalized, Path::new("/c/d"));
500
501        // This should be an error.
502        let path = Path::new("/a/../../c/./d");
503        let err = normalize_absolute_path(path).unwrap_err();
504        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
505    }
506
507    #[test]
508    fn test_relative_to() {
509        assert_eq!(
510            relative_to(
511                Path::new("/home/ferris/carcinization/lib/python/site-packages/foo/__init__.py"),
512                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
513            )
514            .unwrap(),
515            Path::new("foo/__init__.py")
516        );
517        assert_eq!(
518            relative_to(
519                Path::new("/home/ferris/carcinization/lib/marker.txt"),
520                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
521            )
522            .unwrap(),
523            Path::new("../../marker.txt")
524        );
525        assert_eq!(
526            relative_to(
527                Path::new("/home/ferris/carcinization/bin/foo_launcher"),
528                Path::new("/home/ferris/carcinization/lib/python/site-packages"),
529            )
530            .unwrap(),
531            Path::new("../../../bin/foo_launcher")
532        );
533    }
534
535    #[test]
536    fn test_normalize_relative() {
537        let cases = [
538            (
539                "../../workspace-git-path-dep-test/packages/c/../../packages/d",
540                "../../workspace-git-path-dep-test/packages/d",
541            ),
542            (
543                "workspace-git-path-dep-test/packages/c/../../packages/d",
544                "workspace-git-path-dep-test/packages/d",
545            ),
546            ("./a/../../b", "../b"),
547            ("/usr/../../foo", "/../foo"),
548        ];
549        for (input, expected) in cases {
550            assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
551        }
552    }
553}