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
266pub 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
281fn 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 normalized.push(component);
305 }
306 Component::ParentDir => {
307 match normalized.components().next_back() {
308 None | Some(Component::ParentDir | Component::RootDir) => {
309 normalized.push(component);
311 }
312 Some(Component::Normal(_) | Component::Prefix(_) | Component::CurDir) => {
313 normalized.pop();
315 }
316 }
317 }
318 Component::CurDir => {
319 }
321 }
322 }
323 normalized
324}
325
326pub fn relative_to(
335 path: impl AsRef<Path>,
336 base: impl AsRef<Path>,
337) -> Result<PathBuf, std::io::Error> {
338 let path = normalize_path(path.as_ref());
340 let base = normalize_path(base.as_ref());
341
342 let (stripped, common_prefix) = base
344 .ancestors()
345 .find_map(|ancestor| {
346 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 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
367pub fn try_relative_to_if(
370 path: impl AsRef<Path>,
371 base: impl AsRef<Path>,
372 should_relativize: bool,
373) -> Result<PathBuf, std::io::Error> {
374 if should_relativize {
375 relative_to(&path, &base).or_else(|_| std::path::absolute(path.as_ref()))
376 } else {
377 std::path::absolute(path.as_ref())
378 }
379}
380
381pub fn verbatim_path(path: &Path) -> Cow<'_, Path> {
408 if !cfg!(windows) {
409 return Cow::Borrowed(path);
410 }
411
412 let resolved_path = if path.is_relative() {
416 Cow::Owned(CWD.join(path))
417 } else {
418 Cow::Borrowed(path)
419 };
420
421 if let Some(Component::Prefix(prefix)) = resolved_path.components().next() {
423 match prefix.kind() {
424 Prefix::UNC(..) | Prefix::Disk(_) => {},
425 Prefix::DeviceNS(_)
427 | Prefix::Verbatim(_)
429 | Prefix::VerbatimDisk(_)
430 | Prefix::VerbatimUNC(..) => return Cow::Borrowed(path)
431 }
432 }
433
434 let normalized_path = normalized(&resolved_path);
436
437 let mut components = normalized_path.components();
438 let Some(Component::Prefix(prefix)) = components.next() else {
439 return Cow::Borrowed(path);
440 };
441
442 match prefix.kind() {
443 Prefix::Disk(_) => {
445 let mut result = OsString::from(r"\\?\");
446 result.push(normalized_path.as_os_str()); Cow::Owned(PathBuf::from(result))
448 }
449 Prefix::UNC(server, share) => {
451 let mut result = OsString::from(r"\\?\UNC\");
452 result.push(server);
453 result.push(r"\");
454 result.push(share);
455 for component in components {
456 match component {
457 Component::RootDir => {} Component::Prefix(_) => {
459 debug_assert!(false, "prefix already consumed");
460 }
461 Component::CurDir | Component::ParentDir => {
462 debug_assert!(false, "path already normalized");
463 }
464 Component::Normal(_) => {
465 result.push(r"\");
466 result.push(component.as_os_str());
467 }
468 }
469 }
470 Cow::Owned(PathBuf::from(result))
471 }
472 Prefix::DeviceNS(_)
473 | Prefix::Verbatim(_)
474 | Prefix::VerbatimDisk(_)
475 | Prefix::VerbatimUNC(..) => {
476 debug_assert!(false, "skipped via fast path");
477 Cow::Borrowed(path)
478 }
479 }
480}
481
482#[derive(Debug, Clone, PartialEq, Eq)]
487pub struct PortablePath<'a>(&'a Path);
488
489#[derive(Debug, Clone, PartialEq, Eq)]
490pub struct PortablePathBuf(Box<Path>);
491
492#[cfg(feature = "schemars")]
493impl schemars::JsonSchema for PortablePathBuf {
494 fn schema_name() -> Cow<'static, str> {
495 Cow::Borrowed("PortablePathBuf")
496 }
497
498 fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
499 PathBuf::json_schema(_gen)
500 }
501}
502
503impl AsRef<Path> for PortablePath<'_> {
504 fn as_ref(&self) -> &Path {
505 self.0
506 }
507}
508
509impl<'a, T> From<&'a T> for PortablePath<'a>
510where
511 T: AsRef<Path> + ?Sized,
512{
513 fn from(path: &'a T) -> Self {
514 PortablePath(path.as_ref())
515 }
516}
517
518impl std::fmt::Display for PortablePath<'_> {
519 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
520 let path = self.0.to_slash_lossy();
521 if path.is_empty() {
522 write!(f, ".")
523 } else {
524 write!(f, "{path}")
525 }
526 }
527}
528
529impl std::fmt::Display for PortablePathBuf {
530 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
531 let path = self.0.to_slash_lossy();
532 if path.is_empty() {
533 write!(f, ".")
534 } else {
535 write!(f, "{path}")
536 }
537 }
538}
539
540impl From<&str> for PortablePathBuf {
541 fn from(path: &str) -> Self {
542 if path == "." {
543 Self(PathBuf::new().into_boxed_path())
544 } else {
545 Self(PathBuf::from(path).into_boxed_path())
546 }
547 }
548}
549
550impl From<PortablePathBuf> for Box<Path> {
551 fn from(portable: PortablePathBuf) -> Self {
552 portable.0
553 }
554}
555
556impl From<Box<Path>> for PortablePathBuf {
557 fn from(path: Box<Path>) -> Self {
558 Self(path)
559 }
560}
561
562impl<'a> From<&'a Path> for PortablePathBuf {
563 fn from(path: &'a Path) -> Self {
564 Box::<Path>::from(path).into()
565 }
566}
567
568#[cfg(feature = "serde")]
569impl serde::Serialize for PortablePathBuf {
570 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
571 where
572 S: serde::ser::Serializer,
573 {
574 self.to_string().serialize(serializer)
575 }
576}
577
578#[cfg(feature = "serde")]
579impl serde::Serialize for PortablePath<'_> {
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<'de> serde::de::Deserialize<'de> for PortablePathBuf {
590 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
591 where
592 D: serde::de::Deserializer<'de>,
593 {
594 let s = <Cow<'_, str>>::deserialize(deserializer)?;
595 if s == "." {
596 Ok(Self(PathBuf::new().into_boxed_path()))
597 } else {
598 Ok(Self(PathBuf::from(s.as_ref()).into_boxed_path()))
599 }
600 }
601}
602
603impl AsRef<Path> for PortablePathBuf {
604 fn as_ref(&self) -> &Path {
605 &self.0
606 }
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612
613 #[test]
614 fn test_normalize_url() {
615 if cfg!(windows) {
616 assert_eq!(
617 normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
618 "C:\\Users\\ferris\\wheel-0.42.0.tar.gz"
619 );
620 } else {
621 assert_eq!(
622 normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
623 "/C:/Users/ferris/wheel-0.42.0.tar.gz"
624 );
625 }
626
627 if cfg!(windows) {
628 assert_eq!(
629 normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
630 ".\\ferris\\wheel-0.42.0.tar.gz"
631 );
632 } else {
633 assert_eq!(
634 normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
635 "./ferris/wheel-0.42.0.tar.gz"
636 );
637 }
638
639 if cfg!(windows) {
640 assert_eq!(
641 normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
642 ".\\wheel cache\\wheel-0.42.0.tar.gz"
643 );
644 } else {
645 assert_eq!(
646 normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
647 "./wheel cache/wheel-0.42.0.tar.gz"
648 );
649 }
650 }
651
652 #[test]
653 fn test_normalize_path() {
654 let path = Path::new("/a/b/../c/./d");
655 let normalized = normalize_absolute_path(path).unwrap();
656 assert_eq!(normalized, Path::new("/a/c/d"));
657
658 let path = Path::new("/a/../c/./d");
659 let normalized = normalize_absolute_path(path).unwrap();
660 assert_eq!(normalized, Path::new("/c/d"));
661
662 let path = Path::new("/a/../../c/./d");
664 let err = normalize_absolute_path(path).unwrap_err();
665 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
666 }
667
668 #[test]
669 fn test_relative_to() {
670 assert_eq!(
671 relative_to(
672 Path::new("/home/ferris/carcinization/lib/python/site-packages/foo/__init__.py"),
673 Path::new("/home/ferris/carcinization/lib/python/site-packages"),
674 )
675 .unwrap(),
676 Path::new("foo/__init__.py")
677 );
678 assert_eq!(
679 relative_to(
680 Path::new("/home/ferris/carcinization/lib/marker.txt"),
681 Path::new("/home/ferris/carcinization/lib/python/site-packages"),
682 )
683 .unwrap(),
684 Path::new("../../marker.txt")
685 );
686 assert_eq!(
687 relative_to(
688 Path::new("/home/ferris/carcinization/bin/foo_launcher"),
689 Path::new("/home/ferris/carcinization/lib/python/site-packages"),
690 )
691 .unwrap(),
692 Path::new("../../../bin/foo_launcher")
693 );
694 }
695
696 #[test]
697 fn test_normalize_relative() {
698 let cases = [
699 (
700 "../../workspace-git-path-dep-test/packages/c/../../packages/d",
701 "../../workspace-git-path-dep-test/packages/d",
702 ),
703 (
704 "workspace-git-path-dep-test/packages/c/../../packages/d",
705 "workspace-git-path-dep-test/packages/d",
706 ),
707 ("./a/../../b", "../b"),
708 ("/usr/../../foo", "/../foo"),
709 ("foo/./bar", "foo/bar"),
711 ("/a/./b/./c", "/a/b/c"),
712 ("./foo/bar", "foo/bar"),
713 (".", ""),
714 ("./.", ""),
715 ("foo/.", "foo"),
716 ("foo//bar", "foo/bar"),
718 ("/a///b//c", "/a/b/c"),
719 ("foo/./../bar", "bar"),
721 ("foo/bar/./../baz", "foo/baz"),
722 ("foo/bar", "foo/bar"),
724 ("/a/b/c", "/a/b/c"),
725 ("", ""),
726 ];
727 for (input, expected) in cases {
728 assert_eq!(
729 normalize_path(Path::new(input)),
730 Path::new(expected),
731 "input: {input:?}"
732 );
733 }
734
735 for already_normalized in ["foo/bar", "/a/b/c", "foo", "/", ""] {
737 let path = Path::new(already_normalized);
738 assert!(
739 matches!(normalize_path(path), Cow::Borrowed(_)),
740 "expected borrowed for {already_normalized:?}"
741 );
742 }
743 }
744
745 #[test]
746 fn test_normalize_path_under() {
747 assert_eq!(
748 normalize_path_under("scripts/script", "scripts"),
749 Some(PathBuf::from("scripts/script"))
750 );
751 assert_eq!(
752 normalize_path_under("/scripts/script", "/scripts"),
753 Some(PathBuf::from("/scripts/script"))
754 );
755 assert_eq!(
756 normalize_path_under("scripts/nested/../script", "scripts"),
757 Some(PathBuf::from("scripts/script"))
758 );
759 assert_eq!(
760 normalize_path_under("/scripts/nested/../script", "/scripts"),
761 Some(PathBuf::from("/scripts/script"))
762 );
763 assert_eq!(normalize_path_under("scripts/.", "scripts"), None);
764 assert_eq!(normalize_path_under("/scripts/.", "/scripts"), None);
765 assert_eq!(normalize_path_under("scripts/../script", "scripts"), None);
766 assert_eq!(normalize_path_under("/scripts/../script", "/scripts"), None);
767 assert_eq!(normalize_path_under("scripts/script", "."), None);
768 assert_eq!(normalize_path_under("scripts/script", ""), None);
769 }
770
771 #[test]
772 fn test_normalize_trailing_path_separator() {
773 let cases = [
774 (
775 "/home/ferris/projects/python/",
776 "/home/ferris/projects/python",
777 ),
778 ("python/", "python"),
779 ("/", "/"),
780 ("foo/bar/", "foo/bar"),
781 ("foo//", "foo"),
782 ];
783 for (input, expected) in cases {
784 assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
785 }
786 }
787
788 #[test]
789 #[cfg(windows)]
790 fn test_normalize_windows() {
791 let cases = [
792 (
793 r"C:\Users\Ferris\projects\python\",
794 r"C:\Users\Ferris\projects\python",
795 ),
796 (r"C:\foo\.\bar", r"C:\foo\bar"),
797 (r"C:\foo\\bar", r"C:\foo\bar"),
798 (r"C:\foo\bar\..\baz", r"C:\foo\baz"),
799 (r"foo\.\bar", r"foo\bar"),
800 (r"C:foo", r"C:foo"),
801 (r"C:\foo", r"C:\foo"),
802 (r"C:\\foo", r"C:\foo"),
803 (r"\\?\C:foo", r"\\?\C:foo"),
804 (r"\\?\C:\foo", r"\\?\C:\foo"),
805 (r"\\?\C:\\foo", r"\\?\C:\foo"),
806 (r"\\server\share\foo", r"\\server\share\foo"),
807 ];
808 for (input, expected) in cases {
809 assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
810 }
811 }
812
813 #[cfg(windows)]
814 #[test]
815 fn test_verbatim_path() {
816 let relative_path = format!(r"\\?\{}\path\to\logging.", CWD.simplified_display());
817 let relative_root = format!(
818 r"\\?\{}\path\to\logging.",
819 CWD.components()
820 .next()
821 .expect("expected a drive letter prefix")
822 .simplified_display()
823 );
824 let cases = [
825 (r"C:\path\to\logging.", r"\\?\C:\path\to\logging."),
827 (r"C:\path\to\.\logging.", r"\\?\C:\path\to\logging."),
828 (r"C:\path\to\..\to\logging.", r"\\?\C:\path\to\logging."),
829 (r"C:/path/to/../to/./logging.", r"\\?\C:\path\to\logging."),
830 (r"C:path\to\..\to\logging.", r"\\?\C:path\to\logging."), (r".\path\to\.\logging.", relative_path.as_str()),
832 (r"path\to\..\to\logging.", relative_path.as_str()),
833 (r"./path/to/logging.", relative_path.as_str()),
834 (r"\path\to\logging.", relative_root.as_str()),
835 (
837 r"\\127.0.0.1\c$\path\to\logging.",
838 r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
839 ),
840 (
841 r"\\127.0.0.1\c$\path\to\.\logging.",
842 r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
843 ),
844 (
845 r"\\127.0.0.1\c$\path\to\..\to\logging.",
846 r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
847 ),
848 (
849 r"//127.0.0.1/c$/path/to/../to/./logging.",
850 r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
851 ),
852 (r"\\?\C:\path\to\logging.", r"\\?\C:\path\to\logging."),
854 (
856 r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
857 r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
858 ),
859 (r"\\.\PhysicalDrive0", r"\\.\PhysicalDrive0"),
861 (r"\\.\NUL", r"\\.\NUL"),
862 ];
863
864 for (input, expected) in cases {
865 assert_eq!(verbatim_path(Path::new(input)), Path::new(expected));
866 }
867 }
868}