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