Skip to repository content947 lines · 32.6 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:53:19.264Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
shell.rs
1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::{borrow::Cow, fmt, path::Path, sync::LazyLock};
4
5/// Shell configuration to open the terminal with.
6#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Hash)]
7#[serde(rename_all = "snake_case")]
8pub enum Shell {
9 /// Use the system's default terminal configuration in /etc/passwd
10 #[default]
11 System,
12 /// Use a specific program with no arguments.
13 Program(String),
14 /// Use a specific program with arguments.
15 WithArguments {
16 /// The program to run.
17 program: String,
18 /// The arguments to pass to the program.
19 args: Vec<String>,
20 /// An optional string to override the title of the terminal tab
21 title_override: Option<String>,
22 },
23}
24
25impl Shell {
26 pub fn program(&self) -> String {
27 match self {
28 Shell::Program(program) => program.clone(),
29 Shell::WithArguments { program, .. } => program.clone(),
30 Shell::System => get_system_shell(),
31 }
32 }
33
34 pub fn program_and_args(&self) -> (String, &[String]) {
35 match self {
36 Shell::Program(program) => (program.clone(), &[]),
37 Shell::WithArguments { program, args, .. } => (program.clone(), args),
38 Shell::System => (get_system_shell(), &[]),
39 }
40 }
41
42 pub fn shell_kind(&self, is_windows: bool) -> ShellKind {
43 match self {
44 Shell::Program(program) => ShellKind::new(program, is_windows),
45 Shell::WithArguments { program, .. } => ShellKind::new(program, is_windows),
46 Shell::System => ShellKind::system(),
47 }
48 }
49}
50
51#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
52pub enum ShellKind {
53 #[default]
54 Posix,
55 Csh,
56 Tcsh,
57 Rc,
58 Fish,
59 /// Pre-installed "legacy" powershell for windows
60 PowerShell,
61 /// PowerShell 7.x
62 Pwsh,
63 Nushell,
64 Cmd,
65 Xonsh,
66 Elvish,
67}
68
69pub fn get_system_shell() -> String {
70 if cfg!(windows) {
71 get_windows_system_shell()
72 } else {
73 std::env::var("SHELL").unwrap_or("/bin/sh".to_string())
74 }
75}
76
77pub fn get_default_system_shell() -> String {
78 if cfg!(windows) {
79 get_windows_system_shell()
80 } else {
81 "/bin/sh".to_string()
82 }
83}
84
85/// Get the default system shell, preferring bash on Windows.
86pub fn get_default_system_shell_preferring_bash() -> String {
87 if cfg!(windows) {
88 get_windows_bash().unwrap_or_else(|| get_windows_system_shell())
89 } else {
90 "/bin/sh".to_string()
91 }
92}
93
94pub fn get_windows_bash() -> Option<String> {
95 use std::path::PathBuf;
96
97 fn find_bash_in_scoop() -> Option<PathBuf> {
98 let bash_exe =
99 PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\bash.exe");
100 bash_exe.exists().then_some(bash_exe)
101 }
102
103 fn find_bash_in_git() -> Option<PathBuf> {
104 // /path/to/git/cmd/git.exe/../../bin/bash.exe
105 let git = which::which("git").ok()?;
106 let git_bash = git.parent()?.parent()?.join("bin").join("bash.exe");
107 git_bash.exists().then_some(git_bash)
108 }
109
110 static BASH: LazyLock<Option<String>> = LazyLock::new(|| {
111 let bash = find_bash_in_scoop()
112 .or_else(|| find_bash_in_git())
113 .map(|p| p.to_string_lossy().into_owned());
114 if let Some(ref path) = bash {
115 log::info!("Found bash at {}", path);
116 }
117 bash
118 });
119
120 (*BASH).clone()
121}
122
123pub fn get_windows_system_shell() -> String {
124 #[cfg(windows)]
125 return gpui_util::get_windows_system_shell();
126
127 #[cfg(not(windows))]
128 return "cmd.exe".to_string();
129}
130
131impl fmt::Display for ShellKind {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 match self {
134 ShellKind::Posix => write!(f, "sh"),
135 ShellKind::Csh => write!(f, "csh"),
136 ShellKind::Tcsh => write!(f, "tcsh"),
137 ShellKind::Fish => write!(f, "fish"),
138 ShellKind::PowerShell => write!(f, "powershell"),
139 ShellKind::Pwsh => write!(f, "pwsh"),
140 ShellKind::Nushell => write!(f, "nu"),
141 ShellKind::Cmd => write!(f, "cmd"),
142 ShellKind::Rc => write!(f, "rc"),
143 ShellKind::Xonsh => write!(f, "xonsh"),
144 ShellKind::Elvish => write!(f, "elvish"),
145 }
146 }
147}
148
149impl ShellKind {
150 pub fn system() -> Self {
151 Self::new(&get_system_shell(), cfg!(windows))
152 }
153
154 /// Returns whether this shell's command chaining syntax can be parsed by brush-parser.
155 ///
156 /// This is used to determine if we can safely parse shell commands to extract sub-commands
157 /// for security purposes (e.g., preventing shell injection in "always allow" patterns).
158 ///
159 /// The brush-parser handles `;` (sequential execution) and `|` (piping), which are
160 /// supported by all common shells. It also handles `&&` and `||` for conditional
161 /// execution, `$()` and backticks for command substitution, and process substitution.
162 ///
163 /// # Shell Notes
164 ///
165 /// - **Nushell**: Uses `;` for sequential execution. The `and`/`or` keywords are boolean
166 /// operators on values (e.g., `$true and $false`), not command chaining operators.
167 /// - **Elvish**: Uses `;` to separate pipelines, which brush-parser handles. Elvish does
168 /// not have `&&` or `||` operators. Its `and`/`or` are special commands that operate
169 /// on values, not command chaining (e.g., `and $true $false`).
170 /// - **Rc (Plan 9)**: Uses `;` for sequential execution and `|` for piping. Does not
171 /// have `&&`/`||` operators for conditional chaining.
172 /// All current shell variants are listed here because brush-parser can handle
173 /// their syntax. If a new `ShellKind` variant is added, evaluate whether
174 /// brush-parser can safely parse its command chaining syntax before including
175 /// it. Omitting a variant will cause `tool_permissions::from_input` to deny
176 /// terminal commands that have `always_allow` patterns configured.
177 pub fn supports_posix_chaining(&self) -> bool {
178 matches!(
179 self,
180 ShellKind::Posix
181 | ShellKind::Fish
182 | ShellKind::PowerShell
183 | ShellKind::Pwsh
184 | ShellKind::Cmd
185 | ShellKind::Xonsh
186 | ShellKind::Csh
187 | ShellKind::Tcsh
188 | ShellKind::Nushell
189 | ShellKind::Elvish
190 | ShellKind::Rc
191 )
192 }
193
194 pub fn new(program: impl AsRef<Path>, is_windows: bool) -> Self {
195 let program = program.as_ref();
196 let program = program
197 .file_stem()
198 .unwrap_or_else(|| program.as_os_str())
199 .to_string_lossy();
200
201 match &*program {
202 "powershell" => ShellKind::PowerShell,
203 "pwsh" => ShellKind::Pwsh,
204 "cmd" => ShellKind::Cmd,
205 "nu" => ShellKind::Nushell,
206 "fish" => ShellKind::Fish,
207 "csh" => ShellKind::Csh,
208 "tcsh" => ShellKind::Tcsh,
209 "rc" => ShellKind::Rc,
210 "xonsh" => ShellKind::Xonsh,
211 "elvish" => ShellKind::Elvish,
212 "sh" | "bash" | "zsh" => ShellKind::Posix,
213 _ if is_windows => ShellKind::PowerShell,
214 // Some other shell detected, the user might install and use a
215 // unix-like shell.
216 _ => ShellKind::Posix,
217 }
218 }
219
220 pub fn to_shell_variable(self, input: &str) -> String {
221 match self {
222 Self::PowerShell | Self::Pwsh => Self::to_powershell_variable(input),
223 Self::Cmd => Self::to_cmd_variable(input),
224 Self::Posix => input.to_owned(),
225 Self::Fish => input.to_owned(),
226 Self::Csh => input.to_owned(),
227 Self::Tcsh => input.to_owned(),
228 Self::Rc => input.to_owned(),
229 Self::Nushell => Self::to_nushell_variable(input),
230 Self::Xonsh => input.to_owned(),
231 Self::Elvish => input.to_owned(),
232 }
233 }
234
235 fn to_cmd_variable(input: &str) -> String {
236 if let Some(var_str) = input.strip_prefix("${") {
237 if var_str.find(':').is_none() {
238 // If the input starts with "${", remove the trailing "}"
239 format!("%{}%", &var_str[..var_str.len() - 1])
240 } else {
241 // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
242 // which will result in the task failing to run in such cases.
243 input.into()
244 }
245 } else if let Some(var_str) = input.strip_prefix('$') {
246 // If the input starts with "$", directly append to "$env:"
247 format!("%{}%", var_str)
248 } else {
249 // If no prefix is found, return the input as is
250 input.into()
251 }
252 }
253
254 fn to_powershell_variable(input: &str) -> String {
255 if let Some(var_str) = input.strip_prefix("${") {
256 if var_str.find(':').is_none() {
257 // If the input starts with "${", remove the trailing "}"
258 format!("$env:{}", &var_str[..var_str.len() - 1])
259 } else {
260 // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
261 // which will result in the task failing to run in such cases.
262 input.into()
263 }
264 } else if let Some(var_str) = input.strip_prefix('$') {
265 // If the input starts with "$", directly append to "$env:"
266 format!("$env:{}", var_str)
267 } else {
268 // If no prefix is found, return the input as is
269 input.into()
270 }
271 }
272
273 fn to_nushell_variable(input: &str) -> String {
274 let mut result = String::new();
275 let mut source = input;
276 let mut is_start = true;
277
278 loop {
279 match source.chars().next() {
280 None => return result,
281 Some('$') => {
282 source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
283 is_start = false;
284 }
285 Some(_) => {
286 is_start = false;
287 let chunk_end = source.find('$').unwrap_or(source.len());
288 let (chunk, rest) = source.split_at(chunk_end);
289 result.push_str(chunk);
290 source = rest;
291 }
292 }
293 }
294 }
295
296 fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
297 if source.starts_with("env.") {
298 text.push('$');
299 return source;
300 }
301
302 match source.chars().next() {
303 Some('{') => {
304 let source = &source[1..];
305 if let Some(end) = source.find('}') {
306 let var_name = &source[..end];
307 if !var_name.is_empty() {
308 if !is_start {
309 text.push_str("(");
310 }
311 text.push_str("$env.");
312 text.push_str(var_name);
313 if !is_start {
314 text.push_str(")");
315 }
316 &source[end + 1..]
317 } else {
318 text.push_str("${}");
319 &source[end + 1..]
320 }
321 } else {
322 text.push_str("${");
323 source
324 }
325 }
326 Some(c) if c.is_alphabetic() || c == '_' => {
327 let end = source
328 .find(|c: char| !c.is_alphanumeric() && c != '_')
329 .unwrap_or(source.len());
330 let var_name = &source[..end];
331 if !is_start {
332 text.push_str("(");
333 }
334 text.push_str("$env.");
335 text.push_str(var_name);
336 if !is_start {
337 text.push_str(")");
338 }
339 &source[end..]
340 }
341 _ => {
342 text.push('$');
343 source
344 }
345 }
346 }
347
348 pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
349 match self {
350 ShellKind::PowerShell | ShellKind::Pwsh => vec!["-C".to_owned(), combined_command],
351 ShellKind::Cmd => vec![
352 "/S".to_owned(),
353 "/C".to_owned(),
354 format!("\"{combined_command}\""),
355 ],
356 ShellKind::Posix
357 | ShellKind::Nushell
358 | ShellKind::Fish
359 | ShellKind::Csh
360 | ShellKind::Tcsh
361 | ShellKind::Rc
362 | ShellKind::Xonsh
363 | ShellKind::Elvish => interactive
364 .then(|| "-i".to_owned())
365 .into_iter()
366 .chain(["-c".to_owned(), combined_command])
367 .collect(),
368 }
369 }
370
371 pub const fn command_prefix(&self) -> Option<char> {
372 match self {
373 ShellKind::PowerShell | ShellKind::Pwsh => Some('&'),
374 ShellKind::Nushell => Some('^'),
375 ShellKind::Posix
376 | ShellKind::Csh
377 | ShellKind::Tcsh
378 | ShellKind::Rc
379 | ShellKind::Fish
380 | ShellKind::Cmd
381 | ShellKind::Xonsh
382 | ShellKind::Elvish => None,
383 }
384 }
385
386 pub fn prepend_command_prefix<'a>(&self, command: &'a str) -> Cow<'a, str> {
387 match self.command_prefix() {
388 Some(prefix) if !command.starts_with(prefix) => {
389 Cow::Owned(format!("{prefix}{command}"))
390 }
391 _ => Cow::Borrowed(command),
392 }
393 }
394
395 pub const fn sequential_commands_separator(&self) -> char {
396 match self {
397 ShellKind::Cmd => '&',
398 ShellKind::Posix
399 | ShellKind::Csh
400 | ShellKind::Tcsh
401 | ShellKind::Rc
402 | ShellKind::Fish
403 | ShellKind::PowerShell
404 | ShellKind::Pwsh
405 | ShellKind::Nushell
406 | ShellKind::Xonsh
407 | ShellKind::Elvish => ';',
408 }
409 }
410
411 pub const fn sequential_and_commands_separator(&self) -> &'static str {
412 match self {
413 ShellKind::Cmd
414 | ShellKind::Posix
415 | ShellKind::Csh
416 | ShellKind::Tcsh
417 | ShellKind::Rc
418 | ShellKind::Fish
419 | ShellKind::Pwsh
420 | ShellKind::Xonsh => "&&",
421 ShellKind::PowerShell | ShellKind::Nushell | ShellKind::Elvish => ";",
422 }
423 }
424
425 pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
426 match self {
427 ShellKind::PowerShell => Some(Self::quote_powershell(arg)),
428 ShellKind::Pwsh => Some(Self::quote_pwsh(arg)),
429 ShellKind::Cmd => Some(Self::quote_cmd(arg)),
430 ShellKind::Posix
431 | ShellKind::Csh
432 | ShellKind::Tcsh
433 | ShellKind::Rc
434 | ShellKind::Fish
435 | ShellKind::Nushell
436 | ShellKind::Xonsh
437 | ShellKind::Elvish => shlex::try_quote(arg).ok(),
438 }
439 }
440
441 fn quote_windows(arg: &str, enclose: bool) -> Cow<'_, str> {
442 if arg.is_empty() {
443 return Cow::Borrowed("\"\"");
444 }
445
446 let needs_quoting = arg.chars().any(|c| c == ' ' || c == '\t' || c == '"');
447 if !needs_quoting {
448 return Cow::Borrowed(arg);
449 }
450
451 let mut result = String::with_capacity(arg.len() + 2);
452
453 if enclose {
454 result.push('"');
455 }
456
457 let chars: Vec<char> = arg.chars().collect();
458 let mut i = 0;
459
460 while i < chars.len() {
461 if chars[i] == '\\' {
462 let mut num_backslashes = 0;
463 while i < chars.len() && chars[i] == '\\' {
464 num_backslashes += 1;
465 i += 1;
466 }
467
468 if i < chars.len() && chars[i] == '"' {
469 // Backslashes followed by quote: double the backslashes and escape the quote
470 for _ in 0..(num_backslashes * 2 + 1) {
471 result.push('\\');
472 }
473 result.push('"');
474 i += 1;
475 } else if i >= chars.len() {
476 // Trailing backslashes: double them (they precede the closing quote)
477 for _ in 0..(num_backslashes * 2) {
478 result.push('\\');
479 }
480 } else {
481 // Backslashes not followed by quote: output as-is
482 for _ in 0..num_backslashes {
483 result.push('\\');
484 }
485 }
486 } else if chars[i] == '"' {
487 // Quote not preceded by backslash: escape it
488 result.push('\\');
489 result.push('"');
490 i += 1;
491 } else {
492 result.push(chars[i]);
493 i += 1;
494 }
495 }
496
497 if enclose {
498 result.push('"');
499 }
500 Cow::Owned(result)
501 }
502
503 fn needs_quoting_powershell(s: &str) -> bool {
504 s.is_empty()
505 || s.chars().any(|c| {
506 c.is_whitespace()
507 || matches!(
508 c,
509 '"' | '`'
510 | '$'
511 | '&'
512 | '|'
513 | '<'
514 | '>'
515 | ';'
516 | '('
517 | ')'
518 | '['
519 | ']'
520 | '{'
521 | '}'
522 | ','
523 | '\''
524 | '@'
525 )
526 })
527 }
528
529 fn need_quotes_powershell(arg: &str) -> bool {
530 let mut quote_count = 0;
531 for c in arg.chars() {
532 if c == '"' {
533 quote_count += 1;
534 } else if c.is_whitespace() && (quote_count % 2 == 0) {
535 return true;
536 }
537 }
538 false
539 }
540
541 fn escape_powershell_quotes(s: &str) -> String {
542 let mut result = String::with_capacity(s.len() + 4);
543 result.push('\'');
544 for c in s.chars() {
545 if c == '\'' {
546 result.push('\'');
547 }
548 result.push(c);
549 }
550 result.push('\'');
551 result
552 }
553
554 pub fn quote_powershell(arg: &str) -> Cow<'_, str> {
555 let ps_will_quote = Self::need_quotes_powershell(arg);
556 let crt_quoted = Self::quote_windows(arg, !ps_will_quote);
557
558 if !Self::needs_quoting_powershell(arg) {
559 return crt_quoted;
560 }
561
562 Cow::Owned(Self::escape_powershell_quotes(&crt_quoted))
563 }
564
565 pub fn quote_pwsh(arg: &str) -> Cow<'_, str> {
566 if arg.is_empty() {
567 return Cow::Borrowed("''");
568 }
569
570 if !Self::needs_quoting_powershell(arg) {
571 return Cow::Borrowed(arg);
572 }
573
574 Cow::Owned(Self::escape_powershell_quotes(arg))
575 }
576
577 pub fn quote_cmd(arg: &str) -> Cow<'_, str> {
578 let crt_quoted = Self::quote_windows(arg, true);
579
580 let needs_cmd_escaping = crt_quoted.contains(['"', '%', '^', '<', '>', '&', '|', '(', ')']);
581
582 if !needs_cmd_escaping {
583 return crt_quoted;
584 }
585
586 let mut result = String::with_capacity(crt_quoted.len() * 2);
587 for c in crt_quoted.chars() {
588 match c {
589 '^' | '"' | '<' | '>' | '&' | '|' | '(' | ')' => {
590 result.push('^');
591 result.push(c);
592 }
593 '%' => {
594 result.push_str("%%cd:~,%");
595 }
596 _ => result.push(c),
597 }
598 }
599 Cow::Owned(result)
600 }
601
602 /// Quotes the given argument if necessary, taking into account the command prefix.
603 ///
604 /// In other words, this will consider quoting arg without its command prefix to not break the command.
605 /// You should use this over `try_quote` when you want to quote a shell command.
606 pub fn try_quote_prefix_aware<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
607 if let Some(char) = self.command_prefix() {
608 if let Some(arg) = arg.strip_prefix(char) {
609 // we have a command that is prefixed
610 for quote in ['\'', '"'] {
611 if let Some(arg) = arg
612 .strip_prefix(quote)
613 .and_then(|arg| arg.strip_suffix(quote))
614 {
615 // and the command itself is wrapped as a literal, that
616 // means the prefix exists to interpret a literal as a
617 // command. So strip the quotes, quote the command, and
618 // re-add the quotes if they are missing after requoting
619 let quoted = self.try_quote(arg)?;
620 return Some(if quoted.starts_with(['\'', '"']) {
621 Cow::Owned(self.prepend_command_prefix("ed).into_owned())
622 } else {
623 Cow::Owned(
624 self.prepend_command_prefix(&format!("{quote}{quoted}{quote}"))
625 .into_owned(),
626 )
627 });
628 }
629 }
630 return self
631 .try_quote(arg)
632 .map(|quoted| Cow::Owned(self.prepend_command_prefix("ed).into_owned()));
633 }
634 }
635 self.try_quote(arg).map(|quoted| match quoted {
636 unquoted @ Cow::Borrowed(_) => unquoted,
637 Cow::Owned(quoted) => Cow::Owned(self.prepend_command_prefix("ed).into_owned()),
638 })
639 }
640
641 pub fn split(&self, input: &str) -> Option<Vec<String>> {
642 shlex::split(input)
643 }
644
645 pub const fn activate_keyword(&self) -> &'static str {
646 match self {
647 ShellKind::Cmd => "",
648 ShellKind::Nushell => "overlay use",
649 ShellKind::PowerShell | ShellKind::Pwsh => ".",
650 ShellKind::Fish
651 | ShellKind::Csh
652 | ShellKind::Tcsh
653 | ShellKind::Posix
654 | ShellKind::Rc
655 | ShellKind::Xonsh
656 | ShellKind::Elvish => "source",
657 }
658 }
659
660 pub const fn clear_screen_command(&self) -> &'static str {
661 match self {
662 ShellKind::Cmd => "cls",
663 ShellKind::Posix
664 | ShellKind::Csh
665 | ShellKind::Tcsh
666 | ShellKind::Rc
667 | ShellKind::Fish
668 | ShellKind::PowerShell
669 | ShellKind::Pwsh
670 | ShellKind::Nushell
671 | ShellKind::Xonsh
672 | ShellKind::Elvish => "clear",
673 }
674 }
675
676 #[cfg(windows)]
677 /// We do not want to escape arguments if we are using CMD as our shell.
678 /// If we do we end up with too many quotes/escaped quotes for CMD to handle.
679 pub const fn tty_escape_args(&self) -> bool {
680 match self {
681 ShellKind::Cmd => false,
682 ShellKind::Posix
683 | ShellKind::Csh
684 | ShellKind::Tcsh
685 | ShellKind::Rc
686 | ShellKind::Fish
687 | ShellKind::PowerShell
688 | ShellKind::Pwsh
689 | ShellKind::Nushell
690 | ShellKind::Xonsh
691 | ShellKind::Elvish => true,
692 }
693 }
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699
700 // Examples
701 // WSL
702 // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "echo hello"
703 // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "\"echo hello\"" | grep hello"
704 // wsl.exe --distribution NixOS --cd ~ env RUST_LOG=info,remote=debug .zed_wsl_server/zed-remote-server-dev-build proxy --identifier dev-workspace-53
705 // PowerShell from Nushell
706 // nu -c overlay use "C:\Users\kubko\dev\python\39007\tests\.venv\Scripts\activate.nu"; ^"C:\Program Files\PowerShell\7\pwsh.exe" -C "C:\Users\kubko\dev\python\39007\tests\.venv\Scripts\python.exe -m pytest \"test_foo.py::test_foo\""
707 // PowerShell from CMD
708 // cmd /C \" \"C:\\\\Users\\\\kubko\\\\dev\\\\python\\\\39007\\\\tests\\\\.venv\\\\Scripts\\\\activate.bat\"& \"C:\\\\Program Files\\\\PowerShell\\\\7\\\\pwsh.exe\" -C \"C:\\\\Users\\\\kubko\\\\dev\\\\python\\\\39007\\\\tests\\\\.venv\\\\Scripts\\\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"\"\"
709
710 #[test]
711 fn test_try_quote_powershell() {
712 let shell_kind = ShellKind::PowerShell;
713 assert_eq!(
714 shell_kind
715 .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
716 .unwrap()
717 .into_owned(),
718 "'C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"'".to_string()
719 );
720 }
721
722 #[test]
723 fn test_try_quote_cmd() {
724 let shell_kind = ShellKind::Cmd;
725 assert_eq!(
726 shell_kind
727 .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
728 .unwrap()
729 .into_owned(),
730 "^\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\^\"test_foo.py::test_foo\\^\"^\"".to_string()
731 );
732 }
733
734 #[test]
735 fn test_try_quote_powershell_edge_cases() {
736 let shell_kind = ShellKind::PowerShell;
737
738 // Empty string
739 assert_eq!(
740 shell_kind.try_quote("").unwrap().into_owned(),
741 "'\"\"'".to_string()
742 );
743
744 // String without special characters (no quoting needed)
745 assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple");
746
747 // String with spaces
748 assert_eq!(
749 shell_kind.try_quote("hello world").unwrap().into_owned(),
750 "'hello world'".to_string()
751 );
752
753 // String with dollar signs
754 assert_eq!(
755 shell_kind.try_quote("$variable").unwrap().into_owned(),
756 "'$variable'".to_string()
757 );
758
759 // String with backticks
760 assert_eq!(
761 shell_kind.try_quote("test`command").unwrap().into_owned(),
762 "'test`command'".to_string()
763 );
764
765 // String with multiple special characters
766 assert_eq!(
767 shell_kind
768 .try_quote("test `\"$var`\" end")
769 .unwrap()
770 .into_owned(),
771 "'test `\\\"$var`\\\" end'".to_string()
772 );
773
774 // String with backslashes and colon (path without spaces doesn't need quoting)
775 assert_eq!(
776 shell_kind.try_quote("C:\\path\\to\\file").unwrap(),
777 "C:\\path\\to\\file"
778 );
779 }
780
781 #[test]
782 fn test_try_quote_cmd_edge_cases() {
783 let shell_kind = ShellKind::Cmd;
784
785 // Empty string
786 assert_eq!(
787 shell_kind.try_quote("").unwrap().into_owned(),
788 "^\"^\"".to_string()
789 );
790
791 // String without special characters (no quoting needed)
792 assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple");
793
794 // String with spaces
795 assert_eq!(
796 shell_kind.try_quote("hello world").unwrap().into_owned(),
797 "^\"hello world^\"".to_string()
798 );
799
800 // String with space and backslash (backslash not at end, so not doubled)
801 assert_eq!(
802 shell_kind.try_quote("path\\ test").unwrap().into_owned(),
803 "^\"path\\ test^\"".to_string()
804 );
805
806 // String ending with backslash (must be doubled before closing quote)
807 assert_eq!(
808 shell_kind.try_quote("test path\\").unwrap().into_owned(),
809 "^\"test path\\\\^\"".to_string()
810 );
811
812 // String ending with multiple backslashes (all doubled before closing quote)
813 assert_eq!(
814 shell_kind.try_quote("test path\\\\").unwrap().into_owned(),
815 "^\"test path\\\\\\\\^\"".to_string()
816 );
817
818 // String with embedded quote (quote is escaped, backslash before it is doubled)
819 assert_eq!(
820 shell_kind.try_quote("test\\\"quote").unwrap().into_owned(),
821 "^\"test\\\\\\^\"quote^\"".to_string()
822 );
823
824 // String with multiple backslashes before embedded quote (all doubled)
825 assert_eq!(
826 shell_kind
827 .try_quote("test\\\\\"quote")
828 .unwrap()
829 .into_owned(),
830 "^\"test\\\\\\\\\\^\"quote^\"".to_string()
831 );
832
833 // String with backslashes not before quotes (path without spaces doesn't need quoting)
834 assert_eq!(
835 shell_kind.try_quote("C:\\path\\to\\file").unwrap(),
836 "C:\\path\\to\\file"
837 );
838 }
839
840 #[test]
841 fn test_try_quote_nu_command() {
842 let shell_kind = ShellKind::Nushell;
843 assert_eq!(
844 shell_kind.try_quote("'uname'").unwrap().into_owned(),
845 "\"'uname'\"".to_string()
846 );
847 assert_eq!(
848 shell_kind
849 .try_quote_prefix_aware("'uname'")
850 .unwrap()
851 .into_owned(),
852 "^\"'uname'\"".to_string()
853 );
854 assert_eq!(
855 shell_kind.try_quote("^uname").unwrap().into_owned(),
856 "'^uname'".to_string()
857 );
858 assert_eq!(
859 shell_kind
860 .try_quote_prefix_aware("^uname")
861 .unwrap()
862 .into_owned(),
863 "^uname".to_string()
864 );
865 assert_eq!(
866 shell_kind.try_quote("^'uname'").unwrap().into_owned(),
867 "'^'\"'uname\'\"".to_string()
868 );
869 assert_eq!(
870 shell_kind
871 .try_quote_prefix_aware("^'uname'")
872 .unwrap()
873 .into_owned(),
874 "^'uname'".to_string()
875 );
876 assert_eq!(
877 shell_kind.try_quote("'uname a'").unwrap().into_owned(),
878 "\"'uname a'\"".to_string()
879 );
880 assert_eq!(
881 shell_kind
882 .try_quote_prefix_aware("'uname a'")
883 .unwrap()
884 .into_owned(),
885 "^\"'uname a'\"".to_string()
886 );
887 assert_eq!(
888 shell_kind.try_quote("^'uname a'").unwrap().into_owned(),
889 "'^'\"'uname a'\"".to_string()
890 );
891 assert_eq!(
892 shell_kind
893 .try_quote_prefix_aware("^'uname a'")
894 .unwrap()
895 .into_owned(),
896 "^'uname a'".to_string()
897 );
898 assert_eq!(
899 shell_kind.try_quote("uname").unwrap().into_owned(),
900 "uname".to_string()
901 );
902 assert_eq!(
903 shell_kind
904 .try_quote_prefix_aware("uname")
905 .unwrap()
906 .into_owned(),
907 "uname".to_string()
908 );
909 }
910
911 #[test]
912 fn test_try_quote_single_quote_paths() {
913 let path_with_quote = r"C:\Temp\O'Brien\repo";
914 let shlex_shells = [
915 ShellKind::Posix,
916 ShellKind::Fish,
917 ShellKind::Csh,
918 ShellKind::Tcsh,
919 ShellKind::Rc,
920 ShellKind::Xonsh,
921 ShellKind::Elvish,
922 ShellKind::Nushell,
923 ];
924
925 for shell_kind in shlex_shells {
926 let quoted = shell_kind.try_quote(path_with_quote).unwrap().into_owned();
927 assert_ne!(quoted, path_with_quote);
928 assert_eq!(
929 shlex::split("ed),
930 Some(vec![path_with_quote.to_string()])
931 );
932
933 if shell_kind == ShellKind::Nushell {
934 let prefixed = shell_kind.prepend_command_prefix("ed);
935 assert!(prefixed.starts_with('^'));
936 }
937 }
938
939 for shell_kind in [ShellKind::PowerShell, ShellKind::Pwsh] {
940 let quoted = shell_kind.try_quote(path_with_quote).unwrap().into_owned();
941 assert!(quoted.starts_with('\''));
942 assert!(quoted.ends_with('\''));
943 assert!(quoted.contains("O''Brien"));
944 }
945 }
946}
947