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 Cow::Borrowed(path)
217 } else {
218 Cow::Owned(normalized(path))
219 }
220}
221
222pub fn normalize_path_buf(path: PathBuf) -> PathBuf {
224 if path.components().all(|component| match component {
226 Component::Prefix(_) | Component::RootDir | Component::Normal(_) => true,
227 Component::ParentDir | Component::CurDir => false,
228 }) {
229 path
230 } else {
231 normalized(&path)
232 }
233}
234
235fn normalized(path: &Path) -> PathBuf {
253 let mut normalized = PathBuf::new();
254 for component in path.components() {
255 match component {
256 Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
257 normalized.push(component);
259 }
260 Component::ParentDir => {
261 match normalized.components().next_back() {
262 None | Some(Component::ParentDir | Component::RootDir) => {
263 normalized.push(component);
265 }
266 Some(Component::Normal(_) | Component::Prefix(_) | Component::CurDir) => {
267 normalized.pop();
269 }
270 }
271 }
272 Component::CurDir => {
273 }
275 }
276 }
277 normalized
278}
279
280pub fn relative_to(
289 path: impl AsRef<Path>,
290 base: impl AsRef<Path>,
291) -> Result<PathBuf, std::io::Error> {
292 let path = normalize_path(path.as_ref());
294 let base = normalize_path(base.as_ref());
295
296 let (stripped, common_prefix) = base
298 .ancestors()
299 .find_map(|ancestor| {
300 dunce::simplified(&path)
302 .strip_prefix(dunce::simplified(ancestor))
303 .ok()
304 .map(|stripped| (stripped, ancestor))
305 })
306 .ok_or_else(|| {
307 std::io::Error::other(format!(
308 "Trivial strip failed: {} vs. {}",
309 path.simplified_display(),
310 base.simplified_display()
311 ))
312 })?;
313
314 let levels_up = base.components().count() - common_prefix.components().count();
316 let up = std::iter::repeat_n("..", levels_up).collect::<PathBuf>();
317
318 Ok(up.join(stripped))
319}
320
321pub fn try_relative_to_if(
324 path: impl AsRef<Path>,
325 base: impl AsRef<Path>,
326 should_relativize: bool,
327) -> Result<PathBuf, std::io::Error> {
328 if should_relativize {
329 relative_to(&path, &base).or_else(|_| std::path::absolute(path.as_ref()))
330 } else {
331 std::path::absolute(path.as_ref())
332 }
333}
334
335#[derive(Debug, Clone, PartialEq, Eq)]
340pub struct PortablePath<'a>(&'a Path);
341
342#[derive(Debug, Clone, PartialEq, Eq)]
343pub struct PortablePathBuf(Box<Path>);
344
345#[cfg(feature = "schemars")]
346impl schemars::JsonSchema for PortablePathBuf {
347 fn schema_name() -> Cow<'static, str> {
348 Cow::Borrowed("PortablePathBuf")
349 }
350
351 fn json_schema(_gen: &mut schemars::generate::SchemaGenerator) -> schemars::Schema {
352 PathBuf::json_schema(_gen)
353 }
354}
355
356impl AsRef<Path> for PortablePath<'_> {
357 fn as_ref(&self) -> &Path {
358 self.0
359 }
360}
361
362impl<'a, T> From<&'a T> for PortablePath<'a>
363where
364 T: AsRef<Path> + ?Sized,
365{
366 fn from(path: &'a T) -> Self {
367 PortablePath(path.as_ref())
368 }
369}
370
371impl std::fmt::Display for PortablePath<'_> {
372 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373 let path = self.0.to_slash_lossy();
374 if path.is_empty() {
375 write!(f, ".")
376 } else {
377 write!(f, "{path}")
378 }
379 }
380}
381
382impl std::fmt::Display for PortablePathBuf {
383 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
384 let path = self.0.to_slash_lossy();
385 if path.is_empty() {
386 write!(f, ".")
387 } else {
388 write!(f, "{path}")
389 }
390 }
391}
392
393impl From<&str> for PortablePathBuf {
394 fn from(path: &str) -> Self {
395 if path == "." {
396 Self(PathBuf::new().into_boxed_path())
397 } else {
398 Self(PathBuf::from(path).into_boxed_path())
399 }
400 }
401}
402
403impl From<PortablePathBuf> for Box<Path> {
404 fn from(portable: PortablePathBuf) -> Self {
405 portable.0
406 }
407}
408
409impl From<Box<Path>> for PortablePathBuf {
410 fn from(path: Box<Path>) -> Self {
411 Self(path)
412 }
413}
414
415impl<'a> From<&'a Path> for PortablePathBuf {
416 fn from(path: &'a Path) -> Self {
417 Box::<Path>::from(path).into()
418 }
419}
420
421#[cfg(feature = "serde")]
422impl serde::Serialize for PortablePathBuf {
423 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
424 where
425 S: serde::ser::Serializer,
426 {
427 self.to_string().serialize(serializer)
428 }
429}
430
431#[cfg(feature = "serde")]
432impl serde::Serialize for PortablePath<'_> {
433 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
434 where
435 S: serde::ser::Serializer,
436 {
437 self.to_string().serialize(serializer)
438 }
439}
440
441#[cfg(feature = "serde")]
442impl<'de> serde::de::Deserialize<'de> for PortablePathBuf {
443 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
444 where
445 D: serde::de::Deserializer<'de>,
446 {
447 let s = <Cow<'_, str>>::deserialize(deserializer)?;
448 if s == "." {
449 Ok(Self(PathBuf::new().into_boxed_path()))
450 } else {
451 Ok(Self(PathBuf::from(s.as_ref()).into_boxed_path()))
452 }
453 }
454}
455
456impl AsRef<Path> for PortablePathBuf {
457 fn as_ref(&self) -> &Path {
458 &self.0
459 }
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465
466 #[test]
467 fn test_normalize_url() {
468 if cfg!(windows) {
469 assert_eq!(
470 normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
471 "C:\\Users\\ferris\\wheel-0.42.0.tar.gz"
472 );
473 } else {
474 assert_eq!(
475 normalize_url_path("/C:/Users/ferris/wheel-0.42.0.tar.gz"),
476 "/C:/Users/ferris/wheel-0.42.0.tar.gz"
477 );
478 }
479
480 if cfg!(windows) {
481 assert_eq!(
482 normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
483 ".\\ferris\\wheel-0.42.0.tar.gz"
484 );
485 } else {
486 assert_eq!(
487 normalize_url_path("./ferris/wheel-0.42.0.tar.gz"),
488 "./ferris/wheel-0.42.0.tar.gz"
489 );
490 }
491
492 if cfg!(windows) {
493 assert_eq!(
494 normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
495 ".\\wheel cache\\wheel-0.42.0.tar.gz"
496 );
497 } else {
498 assert_eq!(
499 normalize_url_path("./wheel%20cache/wheel-0.42.0.tar.gz"),
500 "./wheel cache/wheel-0.42.0.tar.gz"
501 );
502 }
503 }
504
505 #[test]
506 fn test_normalize_path() {
507 let path = Path::new("/a/b/../c/./d");
508 let normalized = normalize_absolute_path(path).unwrap();
509 assert_eq!(normalized, Path::new("/a/c/d"));
510
511 let path = Path::new("/a/../c/./d");
512 let normalized = normalize_absolute_path(path).unwrap();
513 assert_eq!(normalized, Path::new("/c/d"));
514
515 let path = Path::new("/a/../../c/./d");
517 let err = normalize_absolute_path(path).unwrap_err();
518 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
519 }
520
521 #[test]
522 fn test_relative_to() {
523 assert_eq!(
524 relative_to(
525 Path::new("/home/ferris/carcinization/lib/python/site-packages/foo/__init__.py"),
526 Path::new("/home/ferris/carcinization/lib/python/site-packages"),
527 )
528 .unwrap(),
529 Path::new("foo/__init__.py")
530 );
531 assert_eq!(
532 relative_to(
533 Path::new("/home/ferris/carcinization/lib/marker.txt"),
534 Path::new("/home/ferris/carcinization/lib/python/site-packages"),
535 )
536 .unwrap(),
537 Path::new("../../marker.txt")
538 );
539 assert_eq!(
540 relative_to(
541 Path::new("/home/ferris/carcinization/bin/foo_launcher"),
542 Path::new("/home/ferris/carcinization/lib/python/site-packages"),
543 )
544 .unwrap(),
545 Path::new("../../../bin/foo_launcher")
546 );
547 }
548
549 #[test]
550 fn test_normalize_relative() {
551 let cases = [
552 (
553 "../../workspace-git-path-dep-test/packages/c/../../packages/d",
554 "../../workspace-git-path-dep-test/packages/d",
555 ),
556 (
557 "workspace-git-path-dep-test/packages/c/../../packages/d",
558 "workspace-git-path-dep-test/packages/d",
559 ),
560 ("./a/../../b", "../b"),
561 ("/usr/../../foo", "/../foo"),
562 ];
563 for (input, expected) in cases {
564 assert_eq!(normalize_path(Path::new(input)), Path::new(expected));
565 }
566 }
567}