1use std::collections::HashSet;
7use std::sync::{Mutex, OnceLock};
8
9pub fn quote(value: &str) -> String {
33 if value.is_empty() {
34 return "''".to_string();
35 }
36
37 if value.starts_with('\'') && value.ends_with('\'') && value.len() >= 2 {
39 let inner = &value[1..value.len() - 1];
40 if !inner.contains('\'') {
41 return value.to_string();
42 }
43 }
44
45 if value.starts_with('"') && value.ends_with('"') && value.len() > 2 {
47 return format!("'{}'", value);
48 }
49
50 let safe_pattern = regex::Regex::new(r"^[a-zA-Z0-9_\-./=,+@:]+$").unwrap();
53
54 if safe_pattern.is_match(value) {
55 return value.to_string();
56 }
57
58 format!("'{}'", value.replace('\'', "'\\''"))
62}
63
64pub fn quote_all(values: &[&str]) -> String {
77 values
78 .iter()
79 .map(|v| quote(v))
80 .collect::<Vec<_>>()
81 .join(" ")
82}
83
84pub fn needs_quoting(value: &str) -> bool {
99 if value.is_empty() {
100 return true;
101 }
102
103 let safe_pattern = regex::Regex::new(r"^[a-zA-Z0-9_\-./=,+@:]+$").unwrap();
104 !safe_pattern.is_match(value)
105}
106
107pub fn find_split_template_token(command: &str) -> Option<String> {
133 if !command.contains("{{") {
134 return None;
135 }
136
137 let chars: Vec<char> = command.chars().collect();
138 let n = chars.len();
139 let mut in_single = false;
140 let mut in_double = false;
141 let mut i = 0;
142 while i < n {
143 let c = chars[i];
144 if in_single {
145 in_single = c != '\'';
146 i += 1;
147 continue;
148 }
149 if in_double {
150 in_double = c != '"';
151 i += 1;
152 continue;
153 }
154 if c == '\'' {
155 in_single = true;
156 i += 1;
157 continue;
158 }
159 if c == '"' {
160 in_double = true;
161 i += 1;
162 continue;
163 }
164
165 if c == '{' && i + 1 < n && chars[i + 1] == '{' {
168 let (splits, end) = scan_template_close(&chars, i + 2);
169 if splits {
170 return Some(chars[i..=end + 1].iter().collect());
171 }
172 i = end + 1;
173 continue;
174 }
175 i += 1;
176 }
177
178 None
179}
180
181fn scan_template_close(chars: &[char], start: usize) -> (bool, usize) {
188 let n = chars.len();
189 let mut j = start;
190 let mut has_unquoted_space = false;
191 let mut in_single = false;
192 let mut in_double = false;
193 while j < n {
194 let c = chars[j];
195 if in_single {
196 in_single = c != '\'';
197 } else if in_double {
198 in_double = c != '"';
199 } else if c == '\'' {
200 in_single = true;
201 } else if c == '"' {
202 in_double = true;
203 } else if c == '}' && j + 1 < n && chars[j + 1] == '}' {
204 return (has_unquoted_space, j);
205 } else if c.is_whitespace() {
206 has_unquoted_space = true;
207 }
208 j += 1;
209 }
210 (false, j)
211}
212
213fn warned_template_snippets() -> &'static Mutex<HashSet<String>> {
214 static WARNED: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
215 WARNED.get_or_init(|| Mutex::new(HashSet::new()))
216}
217
218pub fn warn_on_split_template(command: &str) {
224 if std::env::var_os("COMMAND_STREAM_NO_TEMPLATE_WARNING").is_some() {
225 return;
226 }
227 let snippet = match find_split_template_token(command) {
228 Some(s) => s,
229 None => return,
230 };
231 {
232 let mut warned = warned_template_snippets().lock().unwrap();
233 if !warned.insert(snippet.clone()) {
234 return;
235 }
236 }
237 eprintln!(
238 "[command-stream] Warning: template token `{snippet}` contains an \
239unquoted space, so the shell splits it into multiple arguments (just like \
240bash would). Quote it ('{snippet}') or interpolate it as a single ${{value}} \
241to pass it as one argument. See README \"Go templates & {{{{ }}}} arguments\". \
242Set COMMAND_STREAM_NO_TEMPLATE_WARNING=1 to silence."
243 );
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn test_quote_empty() {
252 assert_eq!(quote(""), "''");
253 }
254
255 #[test]
256 fn test_quote_safe_chars() {
257 assert_eq!(quote("hello"), "hello");
258 assert_eq!(quote("/path/to/file"), "/path/to/file");
259 assert_eq!(quote("file.txt"), "file.txt");
260 assert_eq!(quote("key=value"), "key=value");
261 assert_eq!(quote("user@host"), "user@host");
262 }
263
264 #[test]
265 fn test_quote_special_chars() {
266 assert_eq!(quote("hello world"), "'hello world'");
267 assert_eq!(quote("it's"), "'it'\\''s'");
268 assert_eq!(quote("$var"), "'$var'");
269 assert_eq!(quote("test*"), "'test*'");
270 }
271
272 #[test]
273 fn test_quote_already_quoted() {
274 assert_eq!(quote("'already quoted'"), "'already quoted'");
275 assert_eq!(quote("\"double quoted\""), "'\"double quoted\"'");
276 }
277
278 #[test]
279 fn test_quote_all() {
280 let args = vec!["echo", "hello world", "test"];
281 assert_eq!(quote_all(&args), "echo 'hello world' test");
282 }
283
284 #[test]
285 fn test_needs_quoting() {
286 assert!(!needs_quoting("hello"));
287 assert!(!needs_quoting("/path/to/file"));
288 assert!(needs_quoting("hello world"));
289 assert!(needs_quoting("$PATH"));
290 assert!(needs_quoting(""));
291 assert!(needs_quoting("test*"));
292 }
293
294 #[test]
295 fn test_quote_with_newlines() {
296 assert_eq!(quote("line1\nline2"), "'line1\nline2'");
297 }
298
299 #[test]
300 fn test_quote_with_tabs() {
301 assert_eq!(quote("col1\tcol2"), "'col1\tcol2'");
302 }
303
304 #[test]
305 fn test_find_split_template_unquoted_with_space() {
306 assert_eq!(
307 find_split_template_token("docker inspect --format {{json .Config.Env}}"),
308 Some("{{json .Config.Env}}".to_string())
309 );
310 }
311
312 #[test]
313 fn test_find_split_template_space_free() {
314 assert_eq!(
315 find_split_template_token("docker inspect --format {{.Id}}"),
316 None
317 );
318 }
319
320 #[test]
321 fn test_find_split_template_single_quoted() {
322 assert_eq!(
323 find_split_template_token("docker inspect --format '{{json .Config.Env}}'"),
324 None
325 );
326 }
327
328 #[test]
329 fn test_find_split_template_double_quoted() {
330 assert_eq!(
331 find_split_template_token("docker inspect --format \"{{json .Config.Env}}\""),
332 None
333 );
334 }
335
336 #[test]
337 fn test_find_split_template_none_without_braces() {
338 assert_eq!(find_split_template_token("echo hello world"), None);
339 }
340}