1use std::{
2 borrow::Cow,
3 ffi::OsStr,
4 path::{Component, Path, PathBuf, Prefix},
5};
6
7use keepcalm::SharedGlobalMut;
8use serde::Serialize;
9use tempfile::TempDir;
10
11static CANONICAL_TEMP_DIR: SharedGlobalMut<PathBuf> = SharedGlobalMut::new_lazy(|| {
12 let tmp = if cfg!(target_vendor = "apple") {
13 Path::new("/tmp").to_owned()
14 } else {
15 std::env::temp_dir()
16 };
17 match dunce::canonicalize(&tmp) {
18 Ok(canonical) => canonical,
19 Err(_) => tmp,
20 }
21});
22
23static CANONICAL_CWD: SharedGlobalMut<Option<PathBuf>> = SharedGlobalMut::new_lazy(|| {
24 let cwd = std::env::current_dir().ok()?;
25 match dunce::canonicalize(&cwd) {
26 Ok(canonical) => Some(canonical),
27 Err(_) => Some(cwd),
28 }
29});
30
31static CANONICAL_HOME_DIR: SharedGlobalMut<Option<PathBuf>> = SharedGlobalMut::new_lazy(|| {
32 dirs::home_dir().map(|home| dunce::canonicalize(&home).unwrap_or(home))
33});
34
35#[derive(Clone, Eq, PartialEq, Hash, PartialOrd, Ord)]
36pub struct NicePathBuf {
37 path: PathBuf,
38}
39
40impl serde::Serialize for NicePathBuf {
41 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
42 where
43 S: serde::Serializer,
44 {
45 serializer.serialize_str(&self.path.display().to_string())
46 }
47}
48
49impl<'de> serde::Deserialize<'de> for NicePathBuf {
50 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
51 where
52 D: serde::Deserializer<'de>,
53 {
54 let s = String::deserialize(deserializer)?;
55 Ok(Self::new(&s))
56 }
57}
58
59impl From<&'_ NicePathBuf> for NicePathBuf {
60 fn from(path: &NicePathBuf) -> Self {
61 path.clone()
62 }
63}
64
65impl From<&'_ Path> for NicePathBuf {
66 fn from(path: &Path) -> Self {
67 NicePathBuf::new(path)
68 }
69}
70
71impl AsRef<Path> for NicePathBuf {
72 fn as_ref(&self) -> &Path {
73 &self.path
74 }
75}
76
77impl NicePathBuf {
78 pub fn new(path: impl AsRef<Path>) -> Self {
79 Self {
80 path: path.as_ref().to_path_buf(),
81 }
82 }
83
84 pub fn exists(&self) -> std::io::Result<bool> {
85 std::fs::exists(&self.path)
86 }
87
88 pub fn join(&self, other: impl AsRef<Path>) -> Self {
89 Self {
90 path: self.path.join(other.as_ref()),
91 }
92 }
93
94 pub fn create_dir_all(&self) -> std::io::Result<()> {
95 std::fs::create_dir_all(&self.path)
96 }
97
98 pub fn remove_dir_all(&self) -> std::io::Result<()> {
99 std::fs::remove_dir_all(&self.path)
100 }
101
102 pub fn cwd() -> NicePathBuf {
103 let cwd = std::env::current_dir().expect("Couldn't get current directory");
104 cwd.into()
105 }
106
107 pub fn env_string(&self) -> String {
113 let path = &self.path;
114 let canonical = canonicalize_path(path);
115 if cfg!(target_vendor = "apple") {
116 if let Ok(tmp) = canonical.strip_prefix(CANONICAL_TEMP_DIR.read()) {
117 format!("/tmp/{}", tmp.display())
118 } else {
119 canonical.display().to_string()
120 }
121 } else {
122 canonical.display().to_string()
123 }
124 }
125}
126
127impl From<PathBuf> for NicePathBuf {
128 fn from(path: PathBuf) -> Self {
129 Self { path }
130 }
131}
132
133impl From<String> for NicePathBuf {
134 fn from(path: String) -> Self {
135 Self {
136 path: PathBuf::from(path),
137 }
138 }
139}
140
141impl std::fmt::Display for NicePathBuf {
142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 write_pretty_path(false, &self.path, f)
144 }
145}
146
147impl std::fmt::Debug for NicePathBuf {
148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149 write_pretty_path(true, &self.path, f)
150 }
151}
152
153pub struct NiceTempDir {
154 path: TempDir,
155}
156
157impl Default for NiceTempDir {
158 fn default() -> Self {
159 Self::new()
160 }
161}
162
163impl NiceTempDir {
164 pub fn new() -> Self {
165 let path = if cfg!(target_vendor = "apple") {
166 tempfile::Builder::new()
167 .tempdir_in("/tmp")
168 .expect("Couldn't create tempdir")
169 } else {
170 tempfile::tempdir().expect("Couldn't create tempdir")
171 };
172 debug_assert!(path.path().is_absolute());
173 debug_assert!(matches!(std::fs::exists(path.path()), Ok(true)));
174 Self { path }
175 }
176
177 pub fn exists(&self) -> Result<bool, std::io::Error> {
178 std::fs::exists(self.path.path())
179 }
180
181 pub fn remove_dir_all(self) -> std::io::Result<()> {
182 self.path.close()
183 }
184
185 pub fn join(&self, other: impl AsRef<Path>) -> NicePathBuf {
186 NicePathBuf::new(self.path.path().join(other.as_ref()))
187 }
188
189 pub fn file_name(&self) -> Option<&OsStr> {
190 self.path.path().file_name()
191 }
192
193 pub fn env_string(&self) -> String {
194 NicePathBuf::from(self).env_string()
195 }
196}
197
198impl std::fmt::Display for NiceTempDir {
199 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200 write!(f, "{}", NicePathBuf::new(self.path.path()))
201 }
202}
203
204impl From<&'_ NiceTempDir> for NicePathBuf {
205 fn from(tempdir: &NiceTempDir) -> Self {
206 NicePathBuf::new(tempdir.path.path())
207 }
208}
209
210fn canonicalize_path(path: &Path) -> Cow<Path> {
212 if let Ok(path) = dunce::canonicalize(path) {
213 return path.into();
214 }
215
216 let components = path.components();
217 let Some(last) = components.last() else {
218 return path.into();
219 };
220
221 let mut rest = PathBuf::from(last.as_os_str());
222
223 let mut path = path;
226 while let Some(parent) = path.parent() {
227 if let Ok(mut path) = dunce::canonicalize(parent) {
228 for component in rest.components() {
229 match component {
230 Component::ParentDir => {
231 if let Some(parent) = path.parent() {
232 path = parent.to_path_buf();
233 }
234 }
235 Component::CurDir => {}
236 _ => {
237 path = path.join(component.as_os_str());
238 }
239 }
240 }
241 return path.into();
242 }
243
244 path = parent;
245 let components = path.components();
246 let Some(last) = components.last() else {
247 return path.into();
248 };
249
250 rest = PathBuf::from(last.as_os_str()).join(rest);
251 }
252
253 path.into()
254}
255
256fn write_pretty_path(
257 debug: bool,
258 path: &Path,
259 f: &mut std::fmt::Formatter<'_>,
260) -> std::fmt::Result {
261 let tmp = &*CANONICAL_TEMP_DIR.read();
262 let home = &*CANONICAL_HOME_DIR.read();
263 let cwd = &*CANONICAL_CWD.read();
264
265 let mut canon_path = canonicalize_path(path);
266
267 if cfg!(target_vendor = "apple") && canon_path.is_absolute() {
269 if let Ok(without_private) = canon_path.strip_prefix("/private") {
270 canon_path = Path::new("/").join(without_private).into();
271 }
272 }
273
274 if let Some(cwd) = cwd {
277 if let Ok(path) = canon_path.strip_prefix(cwd) {
278 if debug {
279 write_debug_path(f, path)?;
280 } else {
281 write!(f, "{}", path.display())?;
282 }
283 return Ok(());
284 }
285 }
286
287 if !cfg!(unix) && !cfg!(windows) {
289 if debug {
290 write_debug_path(f, path)?;
291 } else {
292 write!(f, "{}", path.display())?;
293 }
294 return Ok(());
295 }
296
297 if let Ok(path) = canon_path.strip_prefix(tmp) {
299 if cfg!(unix) {
300 let path = Path::new("/tmp").join(path);
301 if debug {
302 write_debug_path(f, &path)?;
303 } else {
304 write!(f, "{}", path.display())?;
305 }
306 } else if cfg!(windows) {
307 let path = Path::new("%TEMP%").join(path);
308 if debug {
309 write_debug_path(f, &path)?;
310 } else {
311 write!(f, "{}", path.display())?;
312 }
313 }
314 return Ok(());
315 }
316
317 if debug {
319 if cfg!(windows) {
321 if let Some(Component::Prefix(prefix)) = canon_path.components().next() {
322 if let Prefix::VerbatimDisk(_) = prefix.kind() {
324 return f
325 .write_str(&format!("<{}>", canon_path.display()).replace(r"\\?\", ""));
326 }
327 }
328 }
329
330 write_debug_path(f, &canon_path)?;
331 return Ok(());
332 }
333
334 if let Some(home) = home {
336 if let Ok(path) = canon_path.strip_prefix(home) {
337 if cfg!(unix) {
338 write!(f, "~/{}", path.display())?;
339 } else if cfg!(windows) {
340 write!(f, "%USERPROFILE%\\{}", path.display())?;
341 }
342 return Ok(());
343 }
344 }
345
346 if cfg!(windows) {
348 if let Some(Component::Prefix(prefix)) = canon_path.components().next() {
349 if let Prefix::VerbatimDisk(_) = prefix.kind() {
350 return write!(
351 f,
352 "{}",
353 canon_path.display().to_string().replace(r"\\?\", "")
354 );
355 }
356 }
357 }
358
359 write!(f, "{}", canon_path.display())
360}
361
362fn write_debug_path(f: &mut std::fmt::Formatter<'_>, path: &Path) -> std::fmt::Result {
363 if cfg!(windows) {
364 write!(f, "<{}>", path.display())
365 } else {
366 write!(f, "{path:?}")
367 }
368}
369
370#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
371pub enum ShellParseError {
372 UnmatchedQuote(char),
373}
374
375#[derive(derive_more::Debug, Clone, Hash, Eq, PartialEq, PartialOrd, Ord)]
377pub enum ShellBit {
378 #[debug("{_0:?}")]
381 Literal(String),
382 #[debug("{_0:?}")]
385 Quoted(String),
386}
387
388impl PartialEq<str> for ShellBit {
389 fn eq(&self, other: &str) -> bool {
390 match self {
391 ShellBit::Literal(s) => s == other,
392 ShellBit::Quoted(s) => s == other,
393 }
394 }
395}
396
397impl PartialEq<&'_ str> for ShellBit {
398 fn eq(&self, other: &&str) -> bool {
399 match self {
400 ShellBit::Literal(s) => s == other,
401 ShellBit::Quoted(s) => s == other,
402 }
403 }
404}
405
406impl ShellBit {
407 pub fn to_string(&self) -> String {
408 match self {
409 ShellBit::Literal(s) => s.clone(),
410 ShellBit::Quoted(s) => s.clone(),
411 }
412 }
413}
414
415impl Serialize for ShellBit {
416 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
417 where
418 S: serde::Serializer,
419 {
420 match self {
422 ShellBit::Literal(s) => serializer.serialize_str(s),
423 ShellBit::Quoted(s) => serializer.serialize_str(s),
424 }
425 }
426}
427
428pub fn shell_split(input: &str) -> Result<Vec<ShellBit>, ShellParseError> {
430 let mut result = Vec::new();
431 let mut in_string = None;
432 let mut in_escape = false;
433 let mut accum = String::new();
434
435 for c in input.chars() {
436 if let Some(string_char) = in_string {
437 if string_char == '\'' {
438 if c == string_char {
439 in_string = None;
440 result.push(ShellBit::Literal(std::mem::take(&mut accum)));
441 } else {
442 accum.push(c);
443 }
444 } else if in_escape {
445 in_escape = false;
446 accum.push('\\');
447 accum.push(c);
448 } else if c == '\\' {
449 in_escape = true;
450 } else if c == string_char {
451 in_string = None;
452 if c == '"' {
453 result.push(ShellBit::Quoted(std::mem::take(&mut accum)));
454 }
455 } else {
456 accum.push(c);
457 }
458 } else if c == '\\' {
459 in_escape = true;
460 } else if in_escape {
461 in_escape = false;
462 accum.push('\\');
463 accum.push(c);
464 } else if c == '"' || c == '\'' {
465 in_string = Some(c);
466 } else if c == ' ' {
467 if accum.is_empty() {
468 continue;
469 }
470 result.push(ShellBit::Quoted(std::mem::take(&mut accum)));
471 } else {
472 accum.push(c);
473 }
474 }
475 if let Some(string_char) = in_string {
476 return Err(ShellParseError::UnmatchedQuote(string_char));
477 }
478
479 if !accum.is_empty() {
480 result.push(ShellBit::Quoted(std::mem::take(&mut accum)));
481 }
482
483 Ok(result)
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 #[cfg(unix)]
491 #[test]
492 fn test_nice_path_buf_tmp_unix() {
493 let path = NicePathBuf::new(Path::new("/tmp/hello.world"));
494
495 assert_eq!("/tmp/hello.world", format!("{}", path));
496 assert_eq!("\"/tmp/hello.world\"", format!("{:?}", path));
497
498 let path = NicePathBuf::new(Path::new("//tmp//hello.world"));
499
500 assert_eq!("/tmp/hello.world", format!("{}", path));
501 assert_eq!("\"/tmp/hello.world\"", format!("{:?}", path));
502
503 let path = NicePathBuf::new(Path::new("//does-not-exist-anywhere/..//tmp//hello.world"));
504
505 assert_eq!("/tmp/hello.world", format!("{}", path));
506 assert_eq!("\"/tmp/hello.world\"", format!("{:?}", path));
507
508 let path = NicePathBuf::new(
509 Path::new("/tmp")
510 .canonicalize()
511 .unwrap()
512 .join("hello.world"),
513 );
514
515 assert_eq!("/tmp/hello.world", format!("{}", path));
516 assert_eq!("\"/tmp/hello.world\"", format!("{:?}", path));
517
518 let temp_dir = NiceTempDir::new();
520 let path = temp_dir.join("a/b/c/d");
521
522 let name = temp_dir.file_name().unwrap().to_string_lossy();
523
524 assert_eq!(format!("/tmp/{name}/a/b/c/d"), format!("{}", path));
525 assert_eq!(format!("\"/tmp/{name}/a/b/c/d\""), format!("{:?}", path));
526 }
527
528 #[cfg(windows)]
529 #[test]
530 fn test_nice_path_buf_tmp_windows() {
531 let tmp = std::env::temp_dir();
532 let tmp = tmp.join("hello.world");
533
534 let path = NicePathBuf::new(&tmp);
535
536 assert_eq!(r"%TEMP%\hello.world", format!("{}", path));
537 assert_eq!(r"<%TEMP%\hello.world>", format!("{:?}", path));
538
539 let path = NicePathBuf::new(
540 &std::env::temp_dir()
541 .canonicalize()
542 .unwrap()
543 .join("hello.world"),
544 );
545
546 assert_eq!(r"%TEMP%\hello.world", format!("{}", path));
547 assert_eq!(r"<%TEMP%\hello.world>", format!("{:?}", path));
548
549 let path = NicePathBuf::new(r#"C:\directory"#);
550
551 assert_eq!(r"C:\directory", format!("{}", path));
552 assert_eq!(r"<C:\directory>", format!("{:?}", path));
553 }
554
555 #[test]
556 fn test_shell_split() {
557 assert_eq!(format!("{:?}", shell_split("").unwrap()), r#"[]"#);
558 assert_eq!(format!("{:?}", shell_split("a").unwrap()), r#"["a"]"#);
559 assert_eq!(
560 format!("{:?}", shell_split("a b").unwrap()),
561 r#"["a", "b"]"#
562 );
563 assert_eq!(
564 format!("{:?}", shell_split("a b c").unwrap()),
565 r#"["a", "b", "c"]"#
566 );
567 assert_eq!(
568 format!("{:?}", shell_split("a 'b' c").unwrap()),
569 r#"["a", "b", "c"]"#
570 );
571 assert_eq!(
572 format!("{:?}", shell_split("a 'b c' d").unwrap()),
573 r#"["a", "b c", "d"]"#
574 );
575 assert_eq!(
576 format!("{:?}", shell_split(r#"a "b" c"#).unwrap()),
577 r#"["a", "b", "c"]"#
578 );
579 assert_eq!(
580 format!("{:?}", shell_split(r#"a "b c" d"#).unwrap()),
581 r#"["a", "b c", "d"]"#
582 );
583 assert_eq!(
584 format!("{:?}", shell_split(r#"a "b\'c" d"#).unwrap()),
585 r#"["a", "b\\'c", "d"]"#
586 );
587 assert_eq!(
588 format!("{:?}", shell_split(r#"a "a\\b" d"#).unwrap()),
589 r#"["a", "a\\\\b", "d"]"#
590 );
591 assert_eq!(
592 format!("{:?}", shell_split(r#"a 'a\\b' d"#).unwrap()),
593 r#"["a", "a\\\\b", "d"]"#
594 );
595 }
596
597 #[test]
598 fn test_shell_split_errors() {
599 assert_eq!(
600 shell_split("a 'b").unwrap_err(),
601 ShellParseError::UnmatchedQuote('\'')
602 );
603 assert_eq!(
604 shell_split("a \"b c").unwrap_err(),
605 ShellParseError::UnmatchedQuote('"')
606 );
607 assert_eq!(
608 shell_split("a '").unwrap_err(),
609 ShellParseError::UnmatchedQuote('\'')
610 );
611 }
612}