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
210pub fn normalize_path(path: &Path) -> Cow<'_, Path> {
212 if !path.components().all(|component| match component {
214 Component::Prefix(_) | Component::RootDir | Component::Normal(_) => true,
215 Component::ParentDir | Component::CurDir => false,
216 }) {
217 return Cow::Owned(normalized(path));
218 }
219
220 if path
222 .as_os_str()
223 .as_encoded_bytes()
224 .last()
225 .is_some_and(|trailing| {
226 if cfg!(windows) {
227 *trailing == b'\\' || *trailing == b'/'
228 } else if cfg!(unix) {
229 *trailing == b'/'
230 } else {
231 unimplemented!("Only Windows and Unix are supported")
232 }
233 })
234 {
235 return Cow::Owned(normalized(path));
236 }
237
238 Cow::Borrowed(path)
240}
241
242pub fn normalize_path_buf(path: PathBuf) -> PathBuf {
244 if path.components().all(|component| match component {
246 Component::Prefix(_) | Component::RootDir | Component::Normal(_) => true,
247 Component::ParentDir | Component::CurDir => false,
248 }) {
249 path
250 } else {
251 normalized(&path)
252 }
253}
254
255fn normalized(path: &Path) -> PathBuf {
273 let mut normalized = PathBuf::new();
274 for component in path.components() {
275 match component {
276 Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
277 normalized.push(component);
279 }
280 Component::ParentDir => {
281 match normalized.components().next_back() {
282 None | Some(Component::ParentDir | Component::RootDir) => {
283 normalized.push(component);
285 }
286 Some(Component::Normal(_) | Component::Prefix(_) | Component::CurDir) => {
287 normalized.pop();
289 }
290 }
291 }
292 Component::CurDir => {
293 }
295 }
296 }
297 normalized
298}
299
300pub fn relative_to(
309 path: impl AsRef<Path>,
310 base: impl AsRef<Path>,
311) -> Result<PathBuf, std::io::Error> {
312 let path = normalize_path(path.as_ref());
314 let base = normalize_path(base.as_ref());
315
316 let (stripped, common_prefix) = base
318 .ancestors()
319 .find_map(|ancestor| {
320 dunce::simplified(&path)
322 .strip_prefix(dunce::simplified(ancestor))
323 .ok()
324 .map(|stripped| (stripped, ancestor))
325 })
326 .ok_or_else(|| {
327 std::io::Error::other(format!(
328 "Trivial strip failed: {} vs. {}",
329 path.simplified_display(),
330 base.simplified_display()
331 ))
332 })?;
333
334 let levels_up = base.components().count() - common_prefix.components().count();
336 let up = std::iter::repeat_n("..", levels_up).collect::<PathBuf>();
337
338 Ok(up.join(stripped))
339}
340
341pub fn try_relative_to_if(
344 path: impl AsRef<Path>,
345 base: impl AsRef<Path>,
346 should_relativize: bool,
347) -> Result<PathBuf, std::io::Error> {
348 if should_relativize {
349 relative_to(&path, &base).or_else(|_| std::path::absolute(path.as_ref()))
350 } else {
351 std::path::absolute(path.as_ref())
352 }
353}
354
355pub fn verbatim_path(path: &Path) -> Cow<'_, Path> {
382 if !cfg!(windows) {
383 return Cow::Borrowed(path);
384 }
385
386 let resolved_path = if path.is_relative() {
390 Cow::Owned(CWD.join(path))
391 } else {
392 Cow::Borrowed(path)
393 };
394
395 if let Some(Component::Prefix(prefix)) = resolved_path.components().next() {
397 match prefix.kind() {
398 Prefix::UNC(..) | Prefix::Disk(_) => {},
399 Prefix::DeviceNS(_)
401 | Prefix::Verbatim(_)
403 | Prefix::VerbatimDisk(_)
404 | Prefix::VerbatimUNC(..) => return Cow::Borrowed(path)
405 }
406 }
407
408 let normalized_path = normalized(&resolved_path);
410
411 let mut components = normalized_path.components();
412 let Some(Component::Prefix(prefix)) = components.next() else {
413 return Cow::Borrowed(path);
414 };
415
416 match prefix.kind() {
417 Prefix::Disk(_) => {
419 let mut result = OsString::from(r"\\?\");
420 result.push(normalized_path.as_os_str()); Cow::Owned(PathBuf::from(result))
422 }
423 Prefix::UNC(server, share) => {
425 let mut result = OsString::from(r"\\?\UNC\");
426 result.push(server);
427 result.push(r"\");
428 result.push(share);
429 for component in components {
430 match component {
431 Component::RootDir => {} Component::Prefix(_) => {
433 debug_assert!(false, "prefix already consumed");
434 }
435 Component::CurDir | Component::ParentDir => {
436 debug_assert!(false, "path already normalized");
437 }
438 Component::Normal(_) => {
439 result.push(r"\");
440 result.push(component.as_os_str());
441 }
442 }
443 }
444 Cow::Owned(PathBuf::from(result))
445 }
446 Prefix::DeviceNS(_)
447 | Prefix::Verbatim(_)
448 | Prefix::VerbatimDisk(_)
449 | Prefix::VerbatimUNC(..) => {
450 debug_assert!(false, "skipped via fast path");
451 Cow::Borrowed(path)
452 }
453 }
454}
455
456#[derive(Debug, Clone, PartialEq, Eq)]
461pub struct PortablePath<'a>(&'a Path);
462
463#[derive(Debug, Clone, PartialEq, Eq)]
464pub struct PortablePathBuf(Box<Path>);
465
466#[cfg(feature = "schemars")]
467impl schemars::JsonSchema for PortablePathBuf {
468 fn schema_name() -> Cow<'static, str> {
469 Cow::Borrowed("PortablePathBuf")
470 }
471
472 fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
473 PathBuf::json_schema(_gen)
474 }
475}
476
477impl AsRef<Path> for PortablePath<'_> {
478 fn as_ref(&self) -> &Path {
479 self.0
480 }
481}
482
483impl<'a, T> From<&'a T> for PortablePath<'a>
484where
485 T: AsRef<Path> + ?Sized,
486{
487 fn from(path: &'a T) -> Self {
488 PortablePath(path.as_ref())
489 }
490}
491
492impl std::fmt::Display for PortablePath<'_> {
493 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
494 let path = self.0.to_slash_lossy();
495 if path.is_empty() {
496 write!(f, ".")
497 } else {
498 write!(f, "{path}")
499 }
500 }
501}
502
503impl std::fmt::Display for PortablePathBuf {
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 From<&str> for PortablePathBuf {
515 fn from(path: &str) -> Self {
516 if path == "." {
517 Self(PathBuf::new().into_boxed_path())
518 } else {
519 Self(PathBuf::from(path).into_boxed_path())
520 }
521 }
522}
523
524impl From<PortablePathBuf> for Box<Path> {
525 fn from(portable: PortablePathBuf) -> Self {
526 portable.0
527 }
528}
529
530impl From<Box<Path>> for PortablePathBuf {
531 fn from(path: Box<Path>) -> Self {
532 Self(path)
533 }
534}
535
536impl<'a> From<&'a Path> for PortablePathBuf {
537 fn from(path: &'a Path) -> Self {
538 Box::<Path>::from(path).into()
539 }
540}
541
542#[cfg(feature = "serde")]
543impl serde::Serialize for PortablePathBuf {
544 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
545 where
546 S: serde::ser::Serializer,
547 {
548 self.to_string().serialize(serializer)
549 }
550}
551
552#[cfg(feature = "serde")]
553impl serde::Serialize for PortablePath<'_> {
554 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
555 where
556 S: serde::ser::Serializer,
557 {
558 self.to_string().serialize(serializer)
559 }
560}
561
562#[cfg(feature = "serde")]
563impl<'de> serde::de::Deserialize<'de> for PortablePathBuf {
564 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
565 where
566 D: serde::de::Deserializer<'de>,
567 {
568 let s = <Cow<'_, str>>::deserialize(deserializer)?;
569 if s == "." {
570 Ok(Self(PathBuf::new().into_boxed_path()))
571 } else {
572 Ok(Self(PathBuf::from(s.as_ref()).into_boxed_path()))
573 }
574 }
575}
576
577impl AsRef<Path> for PortablePathBuf {
578 fn as_ref(&self) -> &Path {
579 &self.0
580 }
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586
587 #[test]
588 fn test_normalize_url() {
589 if cfg!(windows) {
590 assert_eq!(
591 normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
592 "C:\\Users\\ferris\\wheel-0.42.0.tar.gz"
593 );
594 } else {
595 assert_eq!(
596 normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
597 "/C:/Users/ferris/wheel-0.42.0.tar.gz"
598 );
599 }
600
601 if cfg!(windows) {
602 assert_eq!(
603 normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
604 ".\\ferris\\wheel-0.42.0.tar.gz"
605 );
606 } else {
607 assert_eq!(
608 normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
609 "./ferris/wheel-0.42.0.tar.gz"
610 );
611 }
612
613 if cfg!(windows) {
614 assert_eq!(
615 normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
616 ".\\wheel cache\\wheel-0.42.0.tar.gz"
617 );
618 } else {
619 assert_eq!(
620 normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
621 "./wheel cache/wheel-0.42.0.tar.gz"
622 );
623 }
624 }
625
626 #[test]
627 fn test_normalize_path() {
628 let path = Path::new("/a/b/../c/./d");
629 let normalized = normalize_absolute_path(path).unwrap();
630 assert_eq!(normalized, Path::new("/a/c/d"));
631
632 let path = Path::new("/a/../c/./d");
633 let normalized = normalize_absolute_path(path).unwrap();
634 assert_eq!(normalized, Path::new("/c/d"));
635
636 let path = Path::new("/a/../../c/./d");
638 let err = normalize_absolute_path(path).unwrap_err();
639 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
640 }
641
642 #[test]
643 fn test_relative_to() {
644 assert_eq!(
645 relative_to(
646 Path::new("/home/ferris/carcinization/lib/python/site-packages/foo/__init__.py"),
647 Path::new("/home/ferris/carcinization/lib/python/site-packages"),
648 )
649 .unwrap(),
650 Path::new("foo/__init__.py")
651 );
652 assert_eq!(
653 relative_to(
654 Path::new("/home/ferris/carcinization/lib/marker.txt"),
655 Path::new("/home/ferris/carcinization/lib/python/site-packages"),
656 )
657 .unwrap(),
658 Path::new("../../marker.txt")
659 );
660 assert_eq!(
661 relative_to(
662 Path::new("/home/ferris/carcinization/bin/foo_launcher"),
663 Path::new("/home/ferris/carcinization/lib/python/site-packages"),
664 )
665 .unwrap(),
666 Path::new("../../../bin/foo_launcher")
667 );
668 }
669
670 #[test]
671 fn test_normalize_relative() {
672 let cases = [
673 (
674 "../../workspace-git-path-dep-test/packages/c/../../packages/d",
675 "../../workspace-git-path-dep-test/packages/d",
676 ),
677 (
678 "workspace-git-path-dep-test/packages/c/../../packages/d",
679 "workspace-git-path-dep-test/packages/d",
680 ),
681 ("./a/../../b", "../b"),
682 ("/usr/../../foo", "/../foo"),
683 ];
684 for (input, expected) in cases {
685 assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
686 }
687 }
688
689 #[test]
690 fn test_normalize_trailing_path_separator() {
691 let cases = [
692 (
693 "/home/ferris/projects/python/",
694 "/home/ferris/projects/python",
695 ),
696 ("python/", "python"),
697 ("/", "/"),
698 ];
699 for (input, expected) in cases {
700 assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
701 }
702 }
703
704 #[test]
705 #[cfg(windows)]
706 fn test_normalize_trailing_path_separator_windows() {
707 let cases = [(
708 r"C:\Users\Ferris\projects\python\",
709 r"C:\Users\Ferris\projects\python",
710 )];
711 for (input, expected) in cases {
712 assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
713 }
714 }
715
716 #[cfg(windows)]
717 #[test]
718 fn test_verbatim_path() {
719 let relative_path = format!(r"\\?\{}\path\to\logging.", CWD.simplified_display());
720 let relative_root = format!(
721 r"\\?\{}\path\to\logging.",
722 CWD.components()
723 .next()
724 .expect("expected a drive letter prefix")
725 .simplified_display()
726 );
727 let cases = [
728 (r"C:\path\to\logging.", r"\\?\C:\path\to\logging."),
730 (r"C:\path\to\.\logging.", r"\\?\C:\path\to\logging."),
731 (r"C:\path\to\..\to\logging.", r"\\?\C:\path\to\logging."),
732 (r"C:/path/to/../to/./logging.", r"\\?\C:\path\to\logging."),
733 (r"C:path\to\..\to\logging.", r"\\?\C:path\to\logging."), (r".\path\to\.\logging.", relative_path.as_str()),
735 (r"path\to\..\to\logging.", relative_path.as_str()),
736 (r"./path/to/logging.", relative_path.as_str()),
737 (r"\path\to\logging.", relative_root.as_str()),
738 (
740 r"\\127.0.0.1\c$\path\to\logging.",
741 r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
742 ),
743 (
744 r"\\127.0.0.1\c$\path\to\.\logging.",
745 r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
746 ),
747 (
748 r"\\127.0.0.1\c$\path\to\..\to\logging.",
749 r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
750 ),
751 (
752 r"//127.0.0.1/c$/path/to/../to/./logging.",
753 r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
754 ),
755 (r"\\?\C:\path\to\logging.", r"\\?\C:\path\to\logging."),
757 (
759 r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
760 r"\\?\UNC\127.0.0.1\c$\path\to\logging.",
761 ),
762 (r"\\.\PhysicalDrive0", r"\\.\PhysicalDrive0"),
764 (r"\\.\NUL", r"\\.\NUL"),
765 ];
766
767 for (input, expected) in cases {
768 assert_eq!(verbatim_path(Path::new(input)), Path::new(expected));
769 }
770 }
771}