clitest_lib/
util.rs

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    /// Returns a string that can be used in the environment to refer to this
108    /// path.
109    ///
110    /// In the case where this path may be accessed via multiple routes, we will
111    /// choose the shortest (ie: /tmp on macOS rather than /private/tmp).
112    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
210/// Best effort to canonicalize a path.
211fn 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    // Walk up the path, canonicalizing each component and taking the first
224    // component that exists.
225    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    // On Apple, we can strip the /private prefix from the path for display purposes
268    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 the path is relative, we can try strip the cwd from its canonical
275    // version to eliminate any relative paths.
276    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    // Unlikely, but just print the path if we're not on unix or windows
288    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 the path is in tmp, try to prettify it
298    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    // Skip out here in debug mode
318    if debug {
319        // On Windows, we can strip the \\?\ prefix from the path for display purposes
320        if cfg!(windows) {
321            if let Some(Component::Prefix(prefix)) = canon_path.components().next() {
322                // This is a backslash explosion in debug mode...
323                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 the path is in home, try to prettify it
335    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    // On Windows, we can strip the \\?\ prefix from the path for display purposes
347    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/// A single bit of a shell-ish string.
376#[derive(derive_more::Debug, Clone, Hash, Eq, PartialEq, PartialOrd, Ord)]
377pub enum ShellBit {
378    /// A literal string that does not participate in expansion. Comes from
379    /// `'string'`.
380    #[debug("{_0:?}")]
381    Literal(String),
382    /// A string that is (possibly) quoted and participates in expansion. Comes
383    /// from `"string"` or `string`.
384    #[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        // TODO
421        match self {
422            ShellBit::Literal(s) => serializer.serialize_str(s),
423            ShellBit::Quoted(s) => serializer.serialize_str(s),
424        }
425    }
426}
427
428/// Split a shell-ish string into a vector of strings.
429pub 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        // Test partial canonicalization
519        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}