Skip to main content

par_term/
shell_quote.rs

1//! Shell quoting utilities for safe filename handling.
2//!
3//! Provides functions to properly quote or escape file paths for use in shell commands.
4
5use crate::config::DroppedFileQuoteStyle;
6use std::path::Path;
7
8/// Characters that require quoting/escaping in shell contexts.
9/// This covers POSIX shells (bash, zsh, sh) and most common special characters.
10const SHELL_SPECIAL_CHARS: &[char] = &[
11    ' ', '\t', '\n', '\r', // Whitespace
12    '\'', '"', '`', // Quotes and backticks
13    '$', '!', '&', '|', // Variable expansion and control operators
14    ';', '(', ')', '{', '}', '[', ']', // Grouping and subshell
15    '<', '>', // Redirection
16    '*', '?', // Glob patterns
17    '\\', '#', '~', '^', // Escape, comments, home, history
18];
19
20/// Check if a path contains any characters that need quoting.
21fn needs_quoting(path: &str) -> bool {
22    path.chars().any(|c| SHELL_SPECIAL_CHARS.contains(&c))
23}
24
25/// Quote a file path using single quotes.
26///
27/// Single quotes are the safest option for most shells - everything inside
28/// is treated literally except for single quotes themselves.
29///
30/// Single quotes inside the path are handled by ending the quoted section,
31/// adding an escaped single quote, and starting a new quoted section:
32/// `it's` becomes `'it'\''s'`
33fn quote_single(path: &str) -> String {
34    // Always quote for consistency (like iTerm2)
35    // Handle single quotes by ending quote, escaping, and starting new quote
36    let escaped = path.replace('\'', "'\\''");
37    format!("'{}'", escaped)
38}
39
40/// Quote a file path using double quotes.
41///
42/// Double quotes allow variable expansion but protect most special characters.
43/// We need to escape: $, `, \, ", and !
44fn quote_double(path: &str) -> String {
45    // Always quote for consistency (like iTerm2)
46    let mut result = String::with_capacity(path.len() + 10);
47    result.push('"');
48
49    for c in path.chars() {
50        match c {
51            '$' | '`' | '\\' | '"' | '!' => {
52                result.push('\\');
53                result.push(c);
54            }
55            _ => result.push(c),
56        }
57    }
58
59    result.push('"');
60    result
61}
62
63/// Escape a file path using backslashes.
64///
65/// Each special character is preceded by a backslash.
66/// Only escapes when necessary (no wrapping quotes).
67fn quote_backslash(path: &str) -> String {
68    if !needs_quoting(path) {
69        return path.to_string();
70    }
71
72    let mut result = String::with_capacity(path.len() * 2);
73
74    for c in path.chars() {
75        if SHELL_SPECIAL_CHARS.contains(&c) {
76            result.push('\\');
77        }
78        result.push(c);
79    }
80
81    result
82}
83
84/// Quote a file path according to the specified style.
85pub fn quote_path(path: &Path, style: DroppedFileQuoteStyle) -> String {
86    let path_str = path.to_string_lossy();
87
88    match style {
89        DroppedFileQuoteStyle::SingleQuotes => quote_single(&path_str),
90        DroppedFileQuoteStyle::DoubleQuotes => quote_double(&path_str),
91        DroppedFileQuoteStyle::Backslash => quote_backslash(&path_str),
92        DroppedFileQuoteStyle::None => path_str.into_owned(),
93    }
94}
95
96/// Quote multiple file paths, returning them space-separated.
97pub fn quote_paths(paths: &[&Path], style: DroppedFileQuoteStyle) -> String {
98    paths
99        .iter()
100        .map(|p| quote_path(p, style))
101        .collect::<Vec<_>>()
102        .join(" ")
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use std::path::Path;
109
110    #[test]
111    fn test_simple_path_always_quoted() {
112        let path = Path::new("/usr/local/bin/program");
113        // Single and double quotes always wrap for consistency (like iTerm2)
114        assert_eq!(
115            quote_path(path, DroppedFileQuoteStyle::SingleQuotes),
116            "'/usr/local/bin/program'"
117        );
118        assert_eq!(
119            quote_path(path, DroppedFileQuoteStyle::DoubleQuotes),
120            "\"/usr/local/bin/program\""
121        );
122        // Backslash only escapes when needed
123        assert_eq!(
124            quote_path(path, DroppedFileQuoteStyle::Backslash),
125            "/usr/local/bin/program"
126        );
127        // None never quotes
128        assert_eq!(
129            quote_path(path, DroppedFileQuoteStyle::None),
130            "/usr/local/bin/program"
131        );
132    }
133
134    #[test]
135    fn test_path_with_spaces() {
136        let path = Path::new("/path/to/file with spaces.txt");
137        assert_eq!(
138            quote_path(path, DroppedFileQuoteStyle::SingleQuotes),
139            "'/path/to/file with spaces.txt'"
140        );
141        assert_eq!(
142            quote_path(path, DroppedFileQuoteStyle::DoubleQuotes),
143            "\"/path/to/file with spaces.txt\""
144        );
145        assert_eq!(
146            quote_path(path, DroppedFileQuoteStyle::Backslash),
147            "/path/to/file\\ with\\ spaces.txt"
148        );
149        assert_eq!(
150            quote_path(path, DroppedFileQuoteStyle::None),
151            "/path/to/file with spaces.txt"
152        );
153    }
154
155    #[test]
156    fn test_path_with_single_quote() {
157        let path = Path::new("/path/to/it's a file.txt");
158        assert_eq!(
159            quote_path(path, DroppedFileQuoteStyle::SingleQuotes),
160            "'/path/to/it'\\''s a file.txt'"
161        );
162    }
163
164    #[test]
165    fn test_path_with_dollar_sign() {
166        let path = Path::new("/path/to/$HOME/file.txt");
167        assert_eq!(
168            quote_path(path, DroppedFileQuoteStyle::SingleQuotes),
169            "'/path/to/$HOME/file.txt'"
170        );
171        assert_eq!(
172            quote_path(path, DroppedFileQuoteStyle::DoubleQuotes),
173            "\"/path/to/\\$HOME/file.txt\""
174        );
175        assert_eq!(
176            quote_path(path, DroppedFileQuoteStyle::Backslash),
177            "/path/to/\\$HOME/file.txt"
178        );
179    }
180
181    #[test]
182    fn test_path_with_glob_chars() {
183        let path = Path::new("/path/to/*.txt");
184        assert_eq!(
185            quote_path(path, DroppedFileQuoteStyle::SingleQuotes),
186            "'/path/to/*.txt'"
187        );
188        assert_eq!(
189            quote_path(path, DroppedFileQuoteStyle::Backslash),
190            "/path/to/\\*.txt"
191        );
192    }
193
194    #[test]
195    fn test_multiple_paths() {
196        let paths: Vec<&Path> = vec![
197            Path::new("/simple/path"),
198            Path::new("/path with spaces"),
199            Path::new("/path/with$dollar"),
200        ];
201        let result = quote_paths(&paths, DroppedFileQuoteStyle::SingleQuotes);
202        // All paths are quoted for consistency
203        assert_eq!(
204            result,
205            "'/simple/path' '/path with spaces' '/path/with$dollar'"
206        );
207    }
208}