Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:53:19.264Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

shell.rs

947 lines · 32.6 KB · rust
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(&quoted).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(&quoted).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(&quoted).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(&quoted),
930                Some(vec![path_with_quote.to_string()])
931            );
932
933            if shell_kind == ShellKind::Nushell {
934                let prefixed = shell_kind.prepend_command_prefix(&quoted);
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
Served at tenant.openagents/omega Member data and write actions are omitted.