Skip to main content

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 parent(&self) -> Option<NicePathBuf> {
103        self.path.parent().map(NicePathBuf::new)
104    }
105
106    pub fn cwd() -> NicePathBuf {
107        let cwd = std::env::current_dir().expect("Couldn't get current directory");
108        cwd.into()
109    }
110
111    /// Returns a string that can be used in the environment to refer to this
112    /// path.
113    ///
114    /// In the case where this path may be accessed via multiple routes, we will
115    /// choose the shortest (ie: /tmp on macOS rather than /private/tmp).
116    pub fn env_string(&self) -> String {
117        let path = &self.path;
118        let canonical = canonicalize_path(path);
119        if cfg!(target_vendor = "apple") {
120            if let Ok(tmp) = canonical.strip_prefix(CANONICAL_TEMP_DIR.read()) {
121                format!("/tmp/{}", tmp.display())
122            } else {
123                canonical.display().to_string()
124            }
125        } else {
126            canonical.display().to_string()
127        }
128    }
129}
130
131impl From<PathBuf> for NicePathBuf {
132    fn from(path: PathBuf) -> Self {
133        Self { path }
134    }
135}
136
137impl From<String> for NicePathBuf {
138    fn from(path: String) -> Self {
139        Self {
140            path: PathBuf::from(path),
141        }
142    }
143}
144
145impl std::fmt::Display for NicePathBuf {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        write_pretty_path(false, &self.path, f)
148    }
149}
150
151impl std::fmt::Debug for NicePathBuf {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        write_pretty_path(true, &self.path, f)
154    }
155}
156
157pub struct NiceTempDir {
158    path: TempDir,
159}
160
161impl Default for NiceTempDir {
162    fn default() -> Self {
163        Self::new()
164    }
165}
166
167impl NiceTempDir {
168    pub fn new() -> Self {
169        let path = if cfg!(target_vendor = "apple") {
170            tempfile::Builder::new()
171                .tempdir_in("/tmp")
172                .expect("Couldn't create tempdir")
173        } else {
174            tempfile::tempdir().expect("Couldn't create tempdir")
175        };
176        debug_assert!(path.path().is_absolute());
177        debug_assert!(matches!(std::fs::exists(path.path()), Ok(true)));
178        Self { path }
179    }
180
181    pub fn exists(&self) -> Result<bool, std::io::Error> {
182        std::fs::exists(self.path.path())
183    }
184
185    pub fn remove_dir_all(self) -> std::io::Result<()> {
186        self.path.close()
187    }
188
189    pub fn join(&self, other: impl AsRef<Path>) -> NicePathBuf {
190        NicePathBuf::new(self.path.path().join(other.as_ref()))
191    }
192
193    pub fn file_name(&self) -> Option<&OsStr> {
194        self.path.path().file_name()
195    }
196
197    pub fn env_string(&self) -> String {
198        NicePathBuf::from(self).env_string()
199    }
200}
201
202impl std::fmt::Display for NiceTempDir {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        write!(f, "{}", NicePathBuf::new(self.path.path()))
205    }
206}
207
208impl From<&'_ NiceTempDir> for NicePathBuf {
209    fn from(tempdir: &NiceTempDir) -> Self {
210        NicePathBuf::new(tempdir.path.path())
211    }
212}
213
214/// Best effort to canonicalize a path.
215fn canonicalize_path(path: &Path) -> Cow<'_, Path> {
216    if let Ok(path) = dunce::canonicalize(path) {
217        return path.into();
218    }
219
220    let mut components = path.components();
221    let Some(last) = components.next_back() else {
222        return path.into();
223    };
224
225    let mut rest = PathBuf::from(last.as_os_str());
226
227    // Walk up the path, canonicalizing each component and taking the first
228    // component that exists.
229    let mut path = path;
230    while let Some(parent) = path.parent() {
231        if let Ok(mut path) = dunce::canonicalize(parent) {
232            for component in rest.components() {
233                match component {
234                    Component::ParentDir => {
235                        if let Some(parent) = path.parent() {
236                            path = parent.to_path_buf();
237                        }
238                    }
239                    Component::CurDir => {}
240                    _ => {
241                        path = path.join(component.as_os_str());
242                    }
243                }
244            }
245            return path.into();
246        }
247
248        path = parent;
249        let mut components = path.components();
250        let Some(last) = components.next_back() else {
251            return path.into();
252        };
253
254        rest = PathBuf::from(last.as_os_str()).join(rest);
255    }
256
257    path.into()
258}
259
260fn write_pretty_path(
261    debug: bool,
262    path: &Path,
263    f: &mut std::fmt::Formatter<'_>,
264) -> std::fmt::Result {
265    let tmp = &*CANONICAL_TEMP_DIR.read();
266    let home = &*CANONICAL_HOME_DIR.read();
267    let cwd = &*CANONICAL_CWD.read();
268
269    let mut canon_path = canonicalize_path(path);
270
271    // On Apple, we can strip the /private prefix from the path for display purposes
272    if cfg!(target_vendor = "apple")
273        && canon_path.is_absolute()
274        && let Ok(without_private) = canon_path.strip_prefix("/private")
275    {
276        canon_path = Path::new("/").join(without_private).into();
277    }
278
279    // If the path is relative, we can try strip the cwd from its canonical
280    // version to eliminate any relative paths.
281    if let Some(cwd) = cwd
282        && let Ok(path) = canon_path.strip_prefix(cwd)
283    {
284        if debug {
285            write_debug_path(f, path)?;
286        } else {
287            #[cfg(windows)]
288            write!(f, ".\\{}", path.display())?;
289            #[cfg(not(windows))]
290            write!(f, "./{}", path.display())?;
291        }
292        return Ok(());
293    }
294
295    // Unlikely, but just print the path if we're not on unix or windows
296    if !cfg!(unix) && !cfg!(windows) {
297        if debug {
298            write_debug_path(f, path)?;
299        } else {
300            write!(f, "{}", path.display())?;
301        }
302        return Ok(());
303    }
304
305    // If the path is in tmp, try to prettify it
306    if let Ok(path) = canon_path.strip_prefix(tmp) {
307        if cfg!(unix) {
308            let path = Path::new("/tmp").join(path);
309            if debug {
310                write_debug_path(f, &path)?;
311            } else {
312                write!(f, "{}", path.display())?;
313            }
314        } else if cfg!(windows) {
315            let path = Path::new("%TEMP%").join(path);
316            if debug {
317                write_debug_path(f, &path)?;
318            } else {
319                write!(f, "{}", path.display())?;
320            }
321        }
322        return Ok(());
323    }
324
325    // Skip out here in debug mode
326    if debug {
327        // On Windows, we can strip the \\?\ prefix from the path for display purposes
328        if cfg!(windows)
329            && let Some(Component::Prefix(prefix)) = canon_path.components().next()
330        {
331            // This is a backslash explosion in debug mode...
332            if let Prefix::VerbatimDisk(_) = prefix.kind() {
333                return f.write_str(&format!("<{}>", canon_path.display()).replace(r"\\?\", ""));
334            }
335        }
336
337        write_debug_path(f, &canon_path)?;
338        return Ok(());
339    }
340
341    // If the path is in home, try to prettify it
342    if let Some(home) = home
343        && let Ok(path) = canon_path.strip_prefix(home)
344    {
345        if cfg!(unix) {
346            write!(f, "~/{}", path.display())?;
347        } else if cfg!(windows) {
348            write!(f, "%USERPROFILE%\\{}", path.display())?;
349        }
350        return Ok(());
351    }
352
353    // On Windows, we can strip the \\?\ prefix from the path for display purposes
354    if cfg!(windows)
355        && let Some(Component::Prefix(prefix)) = canon_path.components().next()
356        && let Prefix::VerbatimDisk(_) = prefix.kind()
357    {
358        return write!(
359            f,
360            "{}",
361            canon_path.display().to_string().replace(r"\\?\", "")
362        );
363    }
364
365    write!(f, "{}", canon_path.display())
366}
367
368fn write_debug_path(f: &mut std::fmt::Formatter<'_>, path: &Path) -> std::fmt::Result {
369    if cfg!(windows) {
370        write!(f, "<{}>", path.display())
371    } else {
372        write!(f, "{path:?}")
373    }
374}
375
376#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, derive_more::Error, derive_more::Display)]
377pub enum ShellParseError {
378    #[display("unmatched quote ({_0})")]
379    UnmatchedQuote(#[error(not(source))] char),
380    #[display("invalid hex escape ({_0})")]
381    InvalidHexEscape(#[error(not(source))] char),
382}
383
384/// A single bit of a shell-ish string.
385#[derive(derive_more::Debug, Clone, Hash, Eq, PartialEq, PartialOrd, Ord)]
386pub enum ShellBit {
387    /// A literal string that does not participate in expansion. Comes from
388    /// `'string'`.
389    #[debug("{_0:?}")]
390    Literal(String),
391    /// A string that is (possibly) quoted and participates in expansion. Comes
392    /// from `"string"` or `string`.
393    #[debug("{_0:?}")]
394    Quoted(String),
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 PartialEq<&'_ str> for ShellBit {
407    fn eq(&self, other: &&str) -> bool {
408        match self {
409            ShellBit::Literal(s) => s == other,
410            ShellBit::Quoted(s) => s == other,
411        }
412    }
413}
414
415impl std::fmt::Display for ShellBit {
416    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
417        match self {
418            ShellBit::Literal(s) => f.write_str(s),
419            ShellBit::Quoted(s) => f.write_str(s),
420        }
421    }
422}
423
424impl Serialize for ShellBit {
425    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
426    where
427        S: serde::Serializer,
428    {
429        match self {
430            ShellBit::Literal(s) => serializer.serialize_str(s),
431            ShellBit::Quoted(s) => serializer.serialize_str(s),
432        }
433    }
434}
435
436/// Split a shell-ish string into a vector of strings.
437pub fn shell_split(input: &str) -> Result<Vec<ShellBit>, ShellParseError> {
438    let mut result = Vec::new();
439    let mut in_string = None;
440    let mut in_escape = false;
441    let mut in_hex_escape = 0;
442    let mut hex_accum = 0;
443    let mut accum = String::new();
444
445    for c in input.chars() {
446        match in_hex_escape {
447            2 => {
448                in_hex_escape = 1;
449                if c.is_ascii_hexdigit() {
450                    hex_accum = c.to_digit(16).unwrap();
451                    continue;
452                } else {
453                    return Err(ShellParseError::InvalidHexEscape(c));
454                }
455            }
456            1 => {
457                in_hex_escape = 0;
458                if c.is_ascii_hexdigit() {
459                    hex_accum = hex_accum * 16 + c.to_digit(16).unwrap();
460                    accum.push(char::from_u32(hex_accum).unwrap());
461                    continue;
462                } else {
463                    return Err(ShellParseError::InvalidHexEscape(c));
464                }
465            }
466            _ => {}
467        }
468
469        if in_escape {
470            in_escape = false;
471            match c {
472                // alert (BEL)
473                'a' => accum.push('\x07'),
474                // backspace
475                'b' => accum.push('\x08'),
476                // form feed
477                'f' => accum.push('\x0c'),
478                // new line
479                'n' => accum.push('\n'),
480                // carriage return
481                'r' => accum.push('\r'),
482                // horizontal tab
483                't' => accum.push('\t'),
484                // vertical tab
485                'v' => accum.push('\x0b'),
486                // escape
487                'e' => accum.push('\x1b'),
488                // null
489                '0' => accum.push('\0'),
490
491                '"' => accum.push('"'),
492                'x' => in_hex_escape = 2,
493                _ => {
494                    accum.push('\\');
495                    accum.push(c);
496                }
497            }
498            continue;
499        }
500
501        if let Some(string_char) = in_string {
502            if string_char == '\'' {
503                if c == string_char {
504                    in_string = None;
505                    result.push(ShellBit::Literal(std::mem::take(&mut accum)));
506                } else {
507                    accum.push(c);
508                }
509            } else if c == '\\' {
510                in_escape = true;
511            } else if c == string_char {
512                in_string = None;
513                if c == '"' {
514                    result.push(ShellBit::Quoted(std::mem::take(&mut accum)));
515                }
516            } else {
517                accum.push(c);
518            }
519        } else if c == '\\' {
520            in_escape = true;
521        } else if c == '"' || c == '\'' {
522            in_string = Some(c);
523        } else if c == ' ' {
524            if accum.is_empty() {
525                continue;
526            }
527            result.push(ShellBit::Quoted(std::mem::take(&mut accum)));
528        } else {
529            accum.push(c);
530        }
531    }
532    if let Some(string_char) = in_string {
533        return Err(ShellParseError::UnmatchedQuote(string_char));
534    }
535
536    if !accum.is_empty() {
537        result.push(ShellBit::Quoted(std::mem::take(&mut accum)));
538    }
539
540    Ok(result)
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    #[cfg(unix)]
548    #[test]
549    fn test_nice_path_buf_tmp_unix() {
550        let path = NicePathBuf::new(Path::new("/tmp/hello.world"));
551
552        assert_eq!("/tmp/hello.world", format!("{path}"));
553        assert_eq!("\"/tmp/hello.world\"", format!("{path:?}"));
554
555        let path = NicePathBuf::new(Path::new("//tmp//hello.world"));
556
557        assert_eq!("/tmp/hello.world", format!("{path}"));
558        assert_eq!("\"/tmp/hello.world\"", format!("{path:?}"));
559
560        let path = NicePathBuf::new(Path::new("//does-not-exist-anywhere/..//tmp//hello.world"));
561
562        assert_eq!("/tmp/hello.world", format!("{path}"));
563        assert_eq!("\"/tmp/hello.world\"", format!("{path:?}"));
564
565        let path = NicePathBuf::new(
566            Path::new("/tmp")
567                .canonicalize()
568                .unwrap()
569                .join("hello.world"),
570        );
571
572        assert_eq!("/tmp/hello.world", format!("{path}"));
573        assert_eq!("\"/tmp/hello.world\"", format!("{path:?}"));
574
575        // Test partial canonicalization
576        let temp_dir = NiceTempDir::new();
577        let path = temp_dir.join("a/b/c/d");
578
579        let name = temp_dir.file_name().unwrap().to_string_lossy();
580
581        assert_eq!(format!("/tmp/{name}/a/b/c/d"), format!("{}", path));
582        assert_eq!(format!("\"/tmp/{name}/a/b/c/d\""), format!("{:?}", path));
583    }
584
585    #[cfg(windows)]
586    #[test]
587    fn test_nice_path_buf_tmp_windows() {
588        let tmp = std::env::temp_dir();
589        let tmp = tmp.join("hello.world");
590
591        let path = NicePathBuf::new(&tmp);
592
593        assert_eq!(r"%TEMP%\hello.world", format!("{}", path));
594        assert_eq!(r"<%TEMP%\hello.world>", format!("{:?}", path));
595
596        let path = NicePathBuf::new(
597            &std::env::temp_dir()
598                .canonicalize()
599                .unwrap()
600                .join("hello.world"),
601        );
602
603        assert_eq!(r"%TEMP%\hello.world", format!("{}", path));
604        assert_eq!(r"<%TEMP%\hello.world>", format!("{:?}", path));
605
606        let path = NicePathBuf::new(r#"C:\directory"#);
607
608        assert_eq!(r"C:\directory", format!("{}", path));
609        assert_eq!(r"<C:\directory>", format!("{:?}", path));
610    }
611
612    #[test]
613    fn test_shell_split() {
614        assert_eq!(format!("{:?}", shell_split("").unwrap()), r#"[]"#);
615        assert_eq!(format!("{:?}", shell_split("a").unwrap()), r#"["a"]"#);
616        assert_eq!(
617            format!("{:?}", shell_split("a b").unwrap()),
618            r#"["a", "b"]"#
619        );
620        assert_eq!(
621            format!("{:?}", shell_split("a b c").unwrap()),
622            r#"["a", "b", "c"]"#
623        );
624        assert_eq!(
625            format!("{:?}", shell_split("a 'b' c").unwrap()),
626            r#"["a", "b", "c"]"#
627        );
628        assert_eq!(
629            format!("{:?}", shell_split("a 'b c' d").unwrap()),
630            r#"["a", "b c", "d"]"#
631        );
632        assert_eq!(
633            format!("{:?}", shell_split(r#"a "b" c"#).unwrap()),
634            r#"["a", "b", "c"]"#
635        );
636        assert_eq!(
637            format!("{:?}", shell_split(r#"a "b c" d"#).unwrap()),
638            r#"["a", "b c", "d"]"#
639        );
640        assert_eq!(
641            format!("{:?}", shell_split(r#"a "b\"c" d"#).unwrap()),
642            r#"["a", "b\"c", "d"]"#
643        );
644        assert_eq!(
645            format!("{:?}", shell_split(r#"a "b\'c" d"#).unwrap()),
646            r#"["a", "b\\'c", "d"]"#
647        );
648        assert_eq!(
649            format!("{:?}", shell_split(r#"a "b\nc" d"#).unwrap()),
650            r#"["a", "b\nc", "d"]"#
651        );
652        assert_eq!(
653            format!("{:?}", shell_split(r#"a "a\\b" d"#).unwrap()),
654            r#"["a", "a\\\\b", "d"]"#
655        );
656        assert_eq!(
657            format!("{:?}", shell_split(r#"a 'a\\b' d"#).unwrap()),
658            r#"["a", "a\\\\b", "d"]"#
659        );
660    }
661
662    #[test]
663    fn test_shell_split_errors() {
664        assert_eq!(
665            shell_split("a 'b").unwrap_err(),
666            ShellParseError::UnmatchedQuote('\'')
667        );
668        assert_eq!(
669            shell_split("a \"b c").unwrap_err(),
670            ShellParseError::UnmatchedQuote('"')
671        );
672        assert_eq!(
673            shell_split("a '").unwrap_err(),
674            ShellParseError::UnmatchedQuote('\'')
675        );
676    }
677}