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#[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 fn simplified(&self) -> &Path;
23
24 fn simplified_display(&self) -> impl std::fmt::Display;
29
30 fn simple_canonicalize(&self) -> std::io::Result<PathBuf>;
34
35 fn user_display(&self) -> impl std::fmt::Display;
39
40 fn user_display_from(&self, base: impl AsRef<Path>) -> impl std::fmt::Display;
45
46 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 CWD.ancestors().nth(1).is_none() {
70 return path.display();
71 }
72
73 let path = path.strip_prefix(CWD.simplified()).unwrap_or(path);
76
77 if path.as_os_str() == "" {
78 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 CWD.ancestors().nth(1).is_none() {
90 return path.display();
91 }
92
93 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 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 let path = path.strip_prefix(CWD.simplified()).unwrap_or(path);
113
114 path.to_slash()
116 .map(Either::Left)
117 .unwrap_or_else(|| Either::Right(path.display()))
118 }
119}
120
121pub trait PythonExt {
122 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
135pub fn normalize_url_path(path: &str) -> Cow<'_, str> {
142 let path = percent_encoding::percent_decode_str(path)
144 .decode_utf8()
145 .unwrap_or(Cow::Borrowed(path));
146
147 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
159pub fn normalize_absolute_path(path: &Path) -> Result<PathBuf, std::io::Error> {
176 let mut components = path.components().peekable();
177 let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().copied() {
178 components.next();
179 PathBuf::from(c.as_os_str())
180 } else {
181 PathBuf::new()
182 };
183
184 for component in components {
185 match component {
186 Component::Prefix(..) => unreachable!(),
187 Component::RootDir => {
188 ret.push(component.as_os_str());
189 }
190 Component::CurDir => {}
191 Component::ParentDir => {
192 if !ret.pop() {
193 return Err(std::io::Error::new(
194 std::io::ErrorKind::InvalidInput,
195 format!(
196 "cannot normalize a relative path beyond the base directory: {}",
197 path.display()
198 ),
199 ));
200 }
201 }
202 Component::Normal(c) => {
203 ret.push(c);
204 }
205 }
206 }
207 Ok(ret)
208}
209
210fn path_equals_components(path: &Path) -> bool {
217 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 if next_needs_separator && !matches!(component, Component::RootDir) {
226 expected_len += Path::new("/").as_os_str().as_encoded_bytes().len();
228 }
229 expected_len += bytes.len();
230 next_needs_separator = match component {
231 Component::RootDir => false,
233 Component::Prefix(_) => false,
235 _ => true,
236 };
237 }
238 expected_len == path.as_os_str().as_encoded_bytes().len()
239}
240
241pub fn normalize_path<'path>(path: impl Into<Cow<'path, Path>>) -> Cow<'path, Path> {
247 let path = path.into();
248 if path
250 .components()
251 .any(|component| matches!(component, Component::ParentDir | Component::CurDir))
252 {
253 return Cow::Owned(normalized(&path));
254 }
255
256 if !path_equals_components(&path) {
259 return Cow::Owned(normalized(&path));
260 }
261
262 path
264}
265
266fn normalized(path: &Path) -> PathBuf {
284 let mut normalized = PathBuf::new();
285 for component in path.components() {
286 match component {
287 Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
288 normalized.push(component);
290 }
291 Component::ParentDir => {
292 match normalized.components().next_back() {
293 None | Some(Component::ParentDir | Component::RootDir) => {
294 normalized.push(component);
296 }
297 Some(Component::Normal(_) | Component::Prefix(_) | Component::CurDir) => {
298 normalized.pop();
300 }
301 }
302 }
303 Component::CurDir => {
304 }
306 }
307 }
308 normalized
309}
310
311pub fn relative_to(
320 path: impl AsRef<Path>,
321 base: impl AsRef<Path>,
322) -> Result<PathBuf, std::io::Error> {
323 let path = normalize_path(path.as_ref());
325 let base = normalize_path(base.as_ref());
326
327 let (stripped, common_prefix) = base
329 .ancestors()
330 .find_map(|ancestor| {
331 dunce::simplified(&path)
333 .strip_prefix(dunce::simplified(ancestor))
334 .ok()
335 .map(|stripped| (stripped, ancestor))
336 })
337 .ok_or_else(|| {
338 std::io::Error::other(format!(
339 "Trivial strip failed: {} vs. {}",
340 path.simplified_display(),
341 base.simplified_display()
342 ))
343 })?;
344
345 let levels_up = base.components().count() - common_prefix.components().count();
347 let up = std::iter::repeat_n("..", levels_up).collect::<PathBuf>();
348
349 Ok(up.join(stripped))
350}
351
352pub fn try_relative_to_if(
355 path: impl AsRef<Path>,
356 base: impl AsRef<Path>,
357 should_relativize: bool,
358) -> Result<PathBuf, std::io::Error> {
359 if should_relativize {
360 relative_to(&path, &base).or_else(|_| std::path::absolute(path.as_ref()))
361 } else {
362 std::path::absolute(path.as_ref())
363 }
364}
365
366pub fn verbatim_path(path: &Path) -> Cow<'_, Path> {
393 if !cfg!(windows) {
394 return Cow::Borrowed(path);
395 }
396
397 let resolved_path = if path.is_relative() {
401 Cow::Owned(CWD.join(path))
402 } else {
403 Cow::Borrowed(path)
404 };
405
406 if let Some(Component::Prefix(prefix)) = resolved_path.components().next() {
408 match prefix.kind() {
409 Prefix::UNC(..) | Prefix::Disk(_) => {},
410 Prefix::DeviceNS(_)
412 | Prefix::Verbatim(_)
414 | Prefix::VerbatimDisk(_)
415 | Prefix::VerbatimUNC(..) => return Cow::Borrowed(path)
416 }
417 }
418
419 let normalized_path = normalized(&resolved_path);
421
422 let mut components = normalized_path.components();
423 let Some(Component::Prefix(prefix)) = components.next() else {
424 return Cow::Borrowed(path);
425 };
426
427 match prefix.kind() {
428 Prefix::Disk(_) => {
430 let mut result = OsString::from(r"\\?\");
431 result.push(normalized_path.as_os_str()); Cow::Owned(PathBuf::from(result))
433 }
434 Prefix::UNC(server, share) => {
436 let mut result = OsString::from(r"\\?\UNC\");
437 result.push(server);
438 result.push(r"\");
439 result.push(share);
440 for component in components {
441 match component {
442 Component::RootDir => {} Component::Prefix(_) => {
444 debug_assert!(false, "prefix already consumed");
445 }
446 Component::CurDir | Component::ParentDir => {
447 debug_assert!(false, "path already normalized");
448 }
449 Component::Normal(_) => {
450 result.push(r"\");
451 result.push(component.as_os_str());
452 }
453 }
454 }
455 Cow::Owned(PathBuf::from(result))
456 }
457 Prefix::DeviceNS(_)
458 | Prefix::Verbatim(_)
459 | Prefix::VerbatimDisk(_)
460 | Prefix::VerbatimUNC(..) => {
461 debug_assert!(false, "skipped via fast path");
462 Cow::Borrowed(path)
463 }
464 }
465}
466
467#[derive(Debug, Clone, PartialEq, Eq)]
472pub struct PortablePath<'a>(&'a Path);
473
474#[derive(Debug, Clone, PartialEq, Eq)]
475pub struct PortablePathBuf(Box<Path>);
476
477#[cfg(feature = "schemars")]
478impl schemars::JsonSchema for PortablePathBuf {
479 fn schema_name() -> Cow<'static, str> {
480 Cow::Borrowed("PortablePathBuf")
481 }
482
483 fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
484 PathBuf::json_schema(_gen)
485 }
486}
487
488impl AsRef<Path> for PortablePath<'_> {
489 fn as_ref(&self) -> &Path {
490 self.0
491 }
492}
493
494impl<'a, T> From<&'a T> for PortablePath<'a>
495where
496 T: AsRef<Path> + ?Sized,
497{
498 fn from(path: &'a T) -> Self {
499 PortablePath(path.as_ref())
500 }
501}
502
503impl std::fmt::Display for PortablePath<'_> {
504 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
505 let path = self.0.to_slash_lossy();
506 if path.is_empty() {
507 write!(f, ".")
508 } else {
509 write!(f, "{path}")
510 }
511 }
512}
513
514impl std::fmt::Display for PortablePathBuf {
515 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
516 let path = self.0.to_slash_lossy();
517 if path.is_empty() {
518 write!(f, ".")
519 } else {
520 write!(f, "{path}")
521 }
522 }
523}
524
525impl From<&str> for PortablePathBuf {
526 fn from(path: &str) -> Self {
527 if path == "." {
528 Self(PathBuf::new().into_boxed_path())
529 } else {
530 Self(PathBuf::from(path).into_boxed_path())
531 }
532 }
533}
534
535impl From<PortablePathBuf> for Box<Path> {
536 fn from(portable: PortablePathBuf) -> Self {
537 portable.0
538 }
539}
540
541impl From<Box<Path>> for PortablePathBuf {
542 fn from(path: Box<Path>) -> Self {
543 Self(path)
544 }
545}
546
547impl<'a> From<&'a Path> for PortablePathBuf {
548 fn from(path: &'a Path) -> Self {
549 Box::<Path>::from(path).into()
550 }
551}
552
553#[cfg(feature = "serde")]
554impl serde::Serialize for PortablePathBuf {
555 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
556 where
557 S: serde::ser::Serializer,
558 {
559 self.to_string().serialize(serializer)
560 }
561}
562
563#[cfg(feature = "serde")]
564impl serde::Serialize for PortablePath<'_> {
565 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
566 where
567 S: serde::ser::Serializer,
568 {
569 self.to_string().serialize(serializer)
570 }
571}
572
573#[cfg(feature = "serde")]
574impl<'de> serde::de::Deserialize<'de> for PortablePathBuf {
575 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
576 where
577 D: serde::de::Deserializer<'de>,
578 {
579 let s = <Cow<'_, str>>::deserialize(deserializer)?;
580 if s == "." {
581 Ok(Self(PathBuf::new().into_boxed_path()))
582 } else {
583 Ok(Self(PathBuf::from(s.as_ref()).into_boxed_path()))
584 }
585 }
586}
587
588impl AsRef<Path> for PortablePathBuf {
589 fn as_ref(&self) -> &Path {
590 &self.0
591 }
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597
598 #[test]
599 fn test_normalize_url() {
600 if cfg!(windows) {
601 assert_eq!(
602 normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
603 "C:\\Users\\ferris\\wheel-0.42.0.tar.gz"
604 );
605 } else {
606 assert_eq!(
607 normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
608 "/C:/Users/ferris/wheel-0.42.0.tar.gz"
609 );
610 }
611
612 if cfg!(windows) {
613 assert_eq!(
614 normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
615 ".\\ferris\\wheel-0.42.0.tar.gz"
616 );
617 } else {
618 assert_eq!(
619 normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
620 "./ferris/wheel-0.42.0.tar.gz"
621 );
622 }
623
624 if cfg!(windows) {
625 assert_eq!(
626 normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
627 ".\\wheel cache\\wheel-0.42.0.tar.gz"
628 );
629 } else {
630 assert_eq!(
631 normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
632 "./wheel cache/wheel-0.42.0.tar.gz"
633 );
634 }
635 }
636
637 #[test]
638 fn test_normalize_path() {
639 let path = Path::new("/a/b/../c/./d");
640 let normalized = normalize_absolute_path(path).unwrap();
641 assert_eq!(normalized, Path::new("/a/c/d"));
642
643 let path = Path::new("/a/../c/./d");
644 let normalized = normalize_absolute_path(path).unwrap();
645 assert_eq!(normalized, Path::new("/c/d"));
646
647 let path = Path::new("/a/../../c/./d");
649 let err = normalize_absolute_path(path).unwrap_err();
650 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
651 }
652
653 #[test]
654 fn test_relative_to() {
655 assert_eq!(
656 relative_to(
657 Path::new("/home/ferris/carcinization/lib/python/site-packages/foo/__init__.py"),
658 Path::new("/home/ferris/carcinization/lib/python/site-packages"),
659 )
660 .unwrap(),
661 Path::new("foo/__init__.py")
662 );
663 assert_eq!(
664 relative_to(
665 Path::new("/home/ferris/carcinization/lib/marker.txt"),
666 Path::new("/home/ferris/carcinization/lib/python/site-packages"),
667 )
668 .unwrap(),
669 Path::new("../../marker.txt")
670 );
671 assert_eq!(
672 relative_to(
673 Path::new("/home/ferris/carcinization/bin/foo_launcher"),
674 Path::new("/home/ferris/carcinization/lib/python/site-packages"),
675 )
676 .unwrap(),
677 Path::new("../../../bin/foo_launcher")
678 );
679 }
680
681 #[test]
682 fn test_normalize_relative() {
683 let cases = [
684 (
685 "../../workspace-git-path-dep-test/packages/c/../../packages/d",
686 "../../workspace-git-path-dep-test/packages/d",
687 ),
688 (
689 "workspace-git-path-dep-test/packages/c/../../packages/d",
690 "workspace-git-path-dep-test/packages/d",
691 ),
692 ("./a/../../b", "../b"),
693 ("/usr/../../foo", "/../foo"),
694 ("foo/./bar", "foo/bar"),
696 ("/a/./b/./c", "/a/b/c"),
697 ("./foo/bar", "foo/bar"),
698 (".", ""),
699 ("./.", ""),
700 ("foo/.", "foo"),
701 ("foo//bar", "foo/bar"),
703 ("/a///b//c", "/a/b/c"),
704 ("foo/./../bar", "bar"),
706 ("foo/bar/./../baz", "foo/baz"),
707 ("foo/bar", "foo/bar"),
709 ("/a/b/c", "/a/b/c"),
710 ("", ""),
711 ];
712 for (input, expected) in cases {
713 assert_eq!(
714 normalize_path(Path::new(input)),
715 Path::new(expected),
716 "input: {input:?}"
717 );
718 }
719
720 for already_normalized in ["foo/bar", "/a/b/c", "foo", "/", ""] {
722 let path = Path::new(already_normalized);
723 assert!(
724 matches!(normalize_path(path), Cow::Borrowed(_)),
725 "expected borrowed for {already_normalized:?}"
726 );
727 }
728 }
729
730 #[test]
731 fn test_normalize_trailing_path_separator() {
732 let cases = [
733 (
734 "/home/ferris/projects/python/",
735 "/home/ferris/projects/python",
736 ),
737 ("python/", "python"),
738 ("/", "/"),
739 ("foo/bar/", "foo/bar"),
740 ("foo//", "foo"),
741 ];
742 for (input, expected) in cases {
743 assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
744 }
745 }
746
747 #[test]
748 #[cfg(windows)]
749 fn test_normalize_windows() {
750 let cases = [
751 (
752 r"C:\Users\Ferris\projects\python\",
753 r"C:\Users\Ferris\projects\python",
754 ),
755 (r"C:\foo\.\bar", r"C:\foo\bar"),
756 (r"C:\foo\\bar", r"C:\foo\bar"),
757 (r"C:\foo\bar\..\baz", r"C:\foo\baz"),
758 (r"foo\.\bar", r"foo\bar"),
759 (r"C:foo", r"C:foo"),
760 (r"C:\foo", r"C:\foo"),
761 (r"C:\\foo", r"C:\foo"),
762 (r"\\?\C:foo", r"\\?\C:foo"),
763 (r"\\?\C:\foo", r"\\?\C:\foo"),
764 (r"\\?\C:\\foo", r"\\?\C:\foo"),
765 (r"\\server\share\foo", r"\\server\share\foo"),
766 ];
767 for (input, expected) in cases {
768 assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
769 }
770 }
771
772 #[cfg(windows)]
773 #[test]
774 fn test_verbatim_path() {
775 let relative_path = format!(r"\\?\{}\path\to\logging.", CWD.simplified_display());
776 let relative_root = format!(
777 r"\\?\{}\path\to\logging.",
778 CWD.components()
779 .next()
780 .expect("expected a drive letter prefix")
781 .simplified_display()
782 );
783 let cases = [
784 (r"C:\path\to\logging.", r"\\?\C:\path\to\logging."),
786 (r"C:\path\to\.\logging.", r"\\?\C:\path\to\logging."),
787 (r"C:\path\to\..\to\logging.", r"\\?\C:\path\to\logging."),
788 (r"C:/path/to/../to/./logging.", r"\\?\C:\path\to\logging."),
789 (r"C:path\to\..\to\logging.", r"\\?\C:path\to\logging."), (r".\path\to\.\logging.", relative_path.as_str()),
791 (r"path\to\..\to\logging.", relative_path.as_str()),
792 (r"./path/to/logging.", relative_path.as_str()),
793 (r"\path\to\logging.", relative_root.as_str()),
794 (
796 r"\\127.0.0.1\c$\path\to\logging.",
797 r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
798 ),
799 (
800 r"\\127.0.0.1\c$\path\to\.\logging.",
801 r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
802 ),
803 (
804 r"\\127.0.0.1\c$\path\to\..\to\logging.",
805 r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
806 ),
807 (
808 r"//127.0.0.1/c$/path/to/../to/./logging.",
809 r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
810 ),
811 (r"\\?\C:\path\to\logging.", r"\\?\C:\path\to\logging."),
813 (
815 r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
816 r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
817 ),
818 (r"\\.\PhysicalDrive0", r"\\.\PhysicalDrive0"),
820 (r"\\.\NUL", r"\\.\NUL"),
821 ];
822
823 for (input, expected) in cases {
824 assert_eq!(verbatim_path(Path::new(input)), Path::new(expected));
825 }
826 }
827}