Skip to main content

osp_cli/core/
shell_words.rs

1/// Quoting style to use when formatting a shell argument.
2#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3pub enum QuoteStyle {
4    /// Wraps the value in single quotes and escapes embedded single quotes.
5    Single,
6    /// Wraps the value in double quotes and escapes shell-sensitive characters.
7    Double,
8}
9
10/// Quotes `value` for shell reuse using the requested quoting style.
11///
12/// Use [`QuoteStyle::Single`] when you want the most literal shell-safe output,
13/// and [`QuoteStyle::Double`] when interpolation-style shell syntax should stay
14/// visually recognizable to the user.
15///
16/// # Examples
17///
18/// ```
19/// use osp_cli::core::shell_words::{QuoteStyle, quote_for_shell};
20///
21/// assert_eq!(quote_for_shell("O'Brien", QuoteStyle::Single), "'O'\\''Brien'");
22/// assert_eq!(quote_for_shell("hello world", QuoteStyle::Double), "\"hello world\"");
23/// ```
24pub fn quote_for_shell(value: &str, style: QuoteStyle) -> String {
25    match style {
26        QuoteStyle::Single => quote_single(value),
27        QuoteStyle::Double => quote_double(value),
28    }
29}
30
31/// Escapes shell-sensitive characters without adding surrounding quotes.
32///
33/// This is useful for tab-completion and history displays where adding full
34/// quotes would be noisier than backslash-escaping.
35///
36/// # Examples
37///
38/// ```
39/// use osp_cli::core::shell_words::escape_for_shell;
40///
41/// assert_eq!(
42///     escape_for_shell("team docs/file name.txt"),
43///     "team\\ docs/file\\ name.txt"
44/// );
45/// ```
46pub fn escape_for_shell(value: &str) -> String {
47    let mut out = String::with_capacity(value.len());
48    for ch in value.chars() {
49        if is_unquoted_safe(ch) {
50            out.push(ch);
51        } else {
52            out.push('\\');
53            out.push(ch);
54        }
55    }
56    out
57}
58
59fn quote_single(value: &str) -> String {
60    let mut out = String::from("'");
61    for ch in value.chars() {
62        if ch == '\'' {
63            out.push_str("'\\''");
64        } else {
65            out.push(ch);
66        }
67    }
68    out.push('\'');
69    out
70}
71
72fn quote_double(value: &str) -> String {
73    let mut out = String::from("\"");
74    for ch in value.chars() {
75        match ch {
76            '"' | '\\' | '$' | '`' => {
77                out.push('\\');
78                out.push(ch);
79            }
80            _ => out.push(ch),
81        }
82    }
83    out.push('"');
84    out
85}
86
87fn is_unquoted_safe(ch: char) -> bool {
88    ch.is_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | '~' | ':' | '+')
89}
90
91#[cfg(test)]
92mod tests {
93    use super::{QuoteStyle, escape_for_shell, quote_for_shell};
94
95    #[test]
96    fn escape_for_shell_backslashes_unsafe_characters() {
97        assert_eq!(
98            escape_for_shell("team docs/file name.txt"),
99            "team\\ docs/file\\ name.txt"
100        );
101        assert_eq!(escape_for_shell("rød"), "rød");
102    }
103
104    #[test]
105    fn quote_for_shell_handles_single_quotes() {
106        assert_eq!(
107            quote_for_shell("O'Brien", QuoteStyle::Single),
108            "'O'\\''Brien'"
109        );
110    }
111
112    #[test]
113    fn quote_for_shell_handles_double_quotes() {
114        assert_eq!(
115            quote_for_shell("a\"b$`c\\d", QuoteStyle::Double),
116            "\"a\\\"b\\$\\`c\\\\d\""
117        );
118    }
119}