1use std::borrow::Cow;
2use std::path::{Component, Path, PathBuf};
3use std::sync::LazyLock;
4
5use either::Either;
6use path_slash::PathExt;
7
8#[expect(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 fn simplified(&self) -> &Path;
22
23 fn simplified_display(&self) -> impl std::fmt::Display;
28
29 fn simple_canonicalize(&self) -> std::io::Result<PathBuf>;
33
34 fn user_display(&self) -> impl std::fmt::Display;
38
39 fn user_display_from(&self, base: impl AsRef<Path>) -> impl std::fmt::Display;
44
45 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 CWD.ancestors().nth(1).is_none() {
69 return path.display();
70 }
71
72 let path = path.strip_prefix(CWD.simplified()).unwrap_or(path);
75
76 if path.as_os_str() == "" {
77 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 CWD.ancestors().nth(1).is_none() {
89 return path.display();
90 }
91
92 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 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 let path = path.strip_prefix(CWD.simplified()).unwrap_or(path);
112
113 path.to_slash()
115 .map(Either::Left)
116 .unwrap_or_else(|| Either::Right(path.display()))
117 }
118}
119
120pub trait PythonExt {
121 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
134pub fn normalize_url_path(path: &str) -> Cow<'_, str> {
141 let path = percent_encoding::percent_decode_str(path)
143 .decode_utf8()
144 .unwrap_or(Cow::Borrowed(path));
145
146 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
158pub 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
209pub fn normalize_path(path: &Path) -> Cow<'_, Path> {
211 if !path.components().all(|component| match component {
213 Component::Prefix(_) | Component::RootDir | Component::Normal(_) => true,
214 Component::ParentDir | Component::CurDir => false,
215 }) {
216 return Cow::Owned(normalized(path));
217 }
218
219 if path
221 .as_os_str()
222 .as_encoded_bytes()
223 .last()
224 .is_some_and(|trailing| {
225 if cfg!(windows) {
226 *trailing == b'\\' || *trailing == b'/'
227 } else if cfg!(unix) {
228 *trailing == b'/'
229 } else {
230 unimplemented!("Only Windows and Unix are supported")
231 }
232 })
233 {
234 return Cow::Owned(normalized(path));
235 }
236
237 Cow::Borrowed(path)
239}
240
241pub fn normalize_path_buf(path: PathBuf) -> PathBuf {
243 if path.components().all(|component| match component {
245 Component::Prefix(_) | Component::RootDir | Component::Normal(_) => true,
246 Component::ParentDir | Component::CurDir => false,
247 }) {
248 path
249 } else {
250 normalized(&path)
251 }
252}
253
254fn normalized(path: &Path) -> PathBuf {
272 let mut normalized = PathBuf::new();
273 for component in path.components() {
274 match component {
275 Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
276 normalized.push(component);
278 }
279 Component::ParentDir => {
280 match normalized.components().next_back() {
281 None | Some(Component::ParentDir | Component::RootDir) => {
282 normalized.push(component);
284 }
285 Some(Component::Normal(_) | Component::Prefix(_) | Component::CurDir) => {
286 normalized.pop();
288 }
289 }
290 }
291 Component::CurDir => {
292 }
294 }
295 }
296 normalized
297}
298
299pub fn relative_to(
308 path: impl AsRef<Path>,
309 base: impl AsRef<Path>,
310) -> Result<PathBuf, std::io::Error> {
311 let path = normalize_path(path.as_ref());
313 let base = normalize_path(base.as_ref());
314
315 let (stripped, common_prefix) = base
317 .ancestors()
318 .find_map(|ancestor| {
319 dunce::simplified(&path)
321 .strip_prefix(dunce::simplified(ancestor))
322 .ok()
323 .map(|stripped| (stripped, ancestor))
324 })
325 .ok_or_else(|| {
326 std::io::Error::other(format!(
327 "Trivial strip failed: {} vs. {}",
328 path.simplified_display(),
329 base.simplified_display()
330 ))
331 })?;
332
333 let levels_up = base.components().count() - common_prefix.components().count();
335 let up = std::iter::repeat_n("..", levels_up).collect::<PathBuf>();
336
337 Ok(up.join(stripped))
338}
339
340pub fn try_relative_to_if(
343 path: impl AsRef<Path>,
344 base: impl AsRef<Path>,
345 should_relativize: bool,
346) -> Result<PathBuf, std::io::Error> {
347 if should_relativize {
348 relative_to(&path, &base).or_else(|_| std::path::absolute(path.as_ref()))
349 } else {
350 std::path::absolute(path.as_ref())
351 }
352}
353
354#[derive(Debug, Clone, PartialEq, Eq)]
359pub struct PortablePath<'a>(&'a Path);
360
361#[derive(Debug, Clone, PartialEq, Eq)]
362pub struct PortablePathBuf(Box<Path>);
363
364#[cfg(feature = "schemars")]
365impl schemars::JsonSchema for PortablePathBuf {
366 fn schema_name() -> Cow<'static, str> {
367 Cow::Borrowed("PortablePathBuf")
368 }
369
370 fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
371 PathBuf::json_schema(_gen)
372 }
373}
374
375impl AsRef<Path> for PortablePath<'_> {
376 fn as_ref(&self) -> &Path {
377 self.0
378 }
379}
380
381impl<'a, T> From<&'a T> for PortablePath<'a>
382where
383 T: AsRef<Path> + ?Sized,
384{
385 fn from(path: &'a T) -> Self {
386 PortablePath(path.as_ref())
387 }
388}
389
390impl std::fmt::Display for PortablePath<'_> {
391 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
392 let path = self.0.to_slash_lossy();
393 if path.is_empty() {
394 write!(f, ".")
395 } else {
396 write!(f, "{path}")
397 }
398 }
399}
400
401impl std::fmt::Display for PortablePathBuf {
402 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
403 let path = self.0.to_slash_lossy();
404 if path.is_empty() {
405 write!(f, ".")
406 } else {
407 write!(f, "{path}")
408 }
409 }
410}
411
412impl From<&str> for PortablePathBuf {
413 fn from(path: &str) -> Self {
414 if path == "." {
415 Self(PathBuf::new().into_boxed_path())
416 } else {
417 Self(PathBuf::from(path).into_boxed_path())
418 }
419 }
420}
421
422impl From<PortablePathBuf> for Box<Path> {
423 fn from(portable: PortablePathBuf) -> Self {
424 portable.0
425 }
426}
427
428impl From<Box<Path>> for PortablePathBuf {
429 fn from(path: Box<Path>) -> Self {
430 Self(path)
431 }
432}
433
434impl<'a> From<&'a Path> for PortablePathBuf {
435 fn from(path: &'a Path) -> Self {
436 Box::<Path>::from(path).into()
437 }
438}
439
440#[cfg(feature = "serde")]
441impl serde::Serialize for PortablePathBuf {
442 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
443 where
444 S: serde::ser::Serializer,
445 {
446 self.to_string().serialize(serializer)
447 }
448}
449
450#[cfg(feature = "serde")]
451impl serde::Serialize for PortablePath<'_> {
452 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
453 where
454 S: serde::ser::Serializer,
455 {
456 self.to_string().serialize(serializer)
457 }
458}
459
460#[cfg(feature = "serde")]
461impl<'de> serde::de::Deserialize<'de> for PortablePathBuf {
462 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
463 where
464 D: serde::de::Deserializer<'de>,
465 {
466 let s = <Cow<'_, str>>::deserialize(deserializer)?;
467 if s == "." {
468 Ok(Self(PathBuf::new().into_boxed_path()))
469 } else {
470 Ok(Self(PathBuf::from(s.as_ref()).into_boxed_path()))
471 }
472 }
473}
474
475impl AsRef<Path> for PortablePathBuf {
476 fn as_ref(&self) -> &Path {
477 &self.0
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 #[test]
486 fn test_normalize_url() {
487 if cfg!(windows) {
488 assert_eq!(
489 normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
490 "C:\\Users\\ferris\\wheel-0.42.0.tar.gz"
491 );
492 } else {
493 assert_eq!(
494 normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
495 "/C:/Users/ferris/wheel-0.42.0.tar.gz"
496 );
497 }
498
499 if cfg!(windows) {
500 assert_eq!(
501 normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
502 ".\\ferris\\wheel-0.42.0.tar.gz"
503 );
504 } else {
505 assert_eq!(
506 normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
507 "./ferris/wheel-0.42.0.tar.gz"
508 );
509 }
510
511 if cfg!(windows) {
512 assert_eq!(
513 normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
514 ".\\wheel cache\\wheel-0.42.0.tar.gz"
515 );
516 } else {
517 assert_eq!(
518 normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
519 "./wheel cache/wheel-0.42.0.tar.gz"
520 );
521 }
522 }
523
524 #[test]
525 fn test_normalize_path() {
526 let path = Path::new("/a/b/../c/./d");
527 let normalized = normalize_absolute_path(path).unwrap();
528 assert_eq!(normalized, Path::new("/a/c/d"));
529
530 let path = Path::new("/a/../c/./d");
531 let normalized = normalize_absolute_path(path).unwrap();
532 assert_eq!(normalized, Path::new("/c/d"));
533
534 let path = Path::new("/a/../../c/./d");
536 let err = normalize_absolute_path(path).unwrap_err();
537 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
538 }
539
540 #[test]
541 fn test_relative_to() {
542 assert_eq!(
543 relative_to(
544 Path::new("/home/ferris/carcinization/lib/python/site-packages/foo/__init__.py"),
545 Path::new("/home/ferris/carcinization/lib/python/site-packages"),
546 )
547 .unwrap(),
548 Path::new("foo/__init__.py")
549 );
550 assert_eq!(
551 relative_to(
552 Path::new("/home/ferris/carcinization/lib/marker.txt"),
553 Path::new("/home/ferris/carcinization/lib/python/site-packages"),
554 )
555 .unwrap(),
556 Path::new("../../marker.txt")
557 );
558 assert_eq!(
559 relative_to(
560 Path::new("/home/ferris/carcinization/bin/foo_launcher"),
561 Path::new("/home/ferris/carcinization/lib/python/site-packages"),
562 )
563 .unwrap(),
564 Path::new("../../../bin/foo_launcher")
565 );
566 }
567
568 #[test]
569 fn test_normalize_relative() {
570 let cases = [
571 (
572 "../../workspace-git-path-dep-test/packages/c/../../packages/d",
573 "../../workspace-git-path-dep-test/packages/d",
574 ),
575 (
576 "workspace-git-path-dep-test/packages/c/../../packages/d",
577 "workspace-git-path-dep-test/packages/d",
578 ),
579 ("./a/../../b", "../b"),
580 ("/usr/../../foo", "/../foo"),
581 ];
582 for (input, expected) in cases {
583 assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
584 }
585 }
586
587 #[test]
588 fn test_normalize_trailing_path_separator() {
589 let cases = [
590 (
591 "/home/ferris/projects/python/",
592 "/home/ferris/projects/python",
593 ),
594 ("python/", "python"),
595 ("/", "/"),
596 ];
597 for (input, expected) in cases {
598 assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
599 }
600 }
601
602 #[test]
603 #[cfg(windows)]
604 fn test_normalize_trailing_path_separator_windows() {
605 let cases = [(
606 r"C:\Users\Ferris\projects\python\",
607 r"C:\Users\Ferris\projects\python",
608 )];
609 for (input, expected) in cases {
610 assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
611 }
612 }
613}