Skip to main content

fallow_engine/
shell_quote.rs

1//! Quote words for a command line that a person or an agent copies and runs.
2//!
3//! Paths in such a command come from the project config or the environment. A
4//! path must stay one argument and must never add a command of its own.
5
6/// Quote `word` for the shell of this platform: a POSIX shell on Unix, and
7/// `cmd` or PowerShell on Windows. A plain word stays as it is.
8#[must_use]
9pub fn shell_quote(word: &str) -> String {
10    if cfg!(windows) {
11        windows_quote(word)
12    } else {
13        posix_quote(word)
14    }
15}
16
17/// Single quotes, with an inner `'` written as `'\''`. A POSIX shell expands
18/// nothing inside single quotes.
19#[must_use]
20pub fn posix_quote(word: &str) -> String {
21    if is_plain(word, &[]) {
22        word.to_owned()
23    } else {
24        format!("'{}'", word.replace('\'', "'\\''"))
25    }
26}
27
28/// Quote for `cmd` and PowerShell. Backslashes stay single, because Windows
29/// paths use them.
30///
31/// Double quotes keep spaces and `& | < > ;` inside one argument in both
32/// shells, so a path such as `C:\Program Files\...` works in either one. Inside
33/// double quotes PowerShell still expands `$` and backticks, and `cmd` expands
34/// `%` and `!`. A word with one of those characters, or with `"`, gets
35/// PowerShell single quotes (an inner `'` doubled), which PowerShell reads
36/// literally. `cmd` has no literal quoting, so it can still expand `%VAR%` in
37/// such a word.
38#[must_use]
39pub fn windows_quote(word: &str) -> String {
40    if is_plain(word, &['\\', ':']) {
41        word.to_owned()
42    } else if word.contains(['$', '`', '%', '!', '"']) {
43        format!("'{}'", word.replace('\'', "''"))
44    } else {
45        format!("\"{word}\"")
46    }
47}
48
49fn is_plain(word: &str, extra: &[char]) -> bool {
50    !word.is_empty()
51        && word.chars().all(|c| {
52            c.is_ascii_alphanumeric()
53                || matches!(c, '_' | '-' | '.' | '/' | '@' | '+' | '=')
54                || extra.contains(&c)
55        })
56}
57
58#[cfg(test)]
59mod tests {
60    use super::{posix_quote, windows_quote};
61
62    #[test]
63    fn posix_quote_keeps_plain_words_and_quotes_the_rest() {
64        assert_eq!(
65            posix_quote("baselines/dead-code.json"),
66            "baselines/dead-code.json"
67        );
68        assert_eq!(posix_quote("--root=/srv/app"), "--root=/srv/app");
69        assert_eq!(posix_quote("a b.json"), "'a b.json'");
70        assert_eq!(posix_quote("x;rm -rf ~"), "'x;rm -rf ~'");
71        assert_eq!(posix_quote("a|b&c"), "'a|b&c'");
72        assert_eq!(posix_quote("$(id)"), "'$(id)'");
73        assert_eq!(posix_quote("`id`"), "'`id`'");
74        assert_eq!(posix_quote("it's"), "'it'\\''s'");
75        assert_eq!(posix_quote(""), "''");
76    }
77
78    #[test]
79    fn windows_quote_keeps_paths_and_quotes_shell_characters() {
80        assert_eq!(
81            windows_quote(r"C:\tools\fallow-mcp.exe"),
82            r"C:\tools\fallow-mcp.exe"
83        );
84        assert_eq!(
85            windows_quote(r"C:\Program Files\nodejs\npx.cmd"),
86            r#""C:\Program Files\nodejs\npx.cmd""#
87        );
88        assert_eq!(windows_quote("a&b"), "\"a&b\"");
89        assert_eq!(windows_quote("x;y|z"), "\"x;y|z\"");
90        assert_eq!(windows_quote("say \"hi\""), "'say \"hi\"'");
91        assert_eq!(windows_quote("$(calc)"), "'$(calc)'");
92        assert_eq!(windows_quote("`calc`"), "'`calc`'");
93        assert_eq!(windows_quote("%PATH%"), "'%PATH%'");
94        assert_eq!(windows_quote("it's $x"), "'it''s $x'");
95        assert_eq!(windows_quote(""), "\"\"");
96    }
97}