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 use std::path::PathBuf;
125
126 fn find_pwsh_in_programfiles(find_alternate: bool, find_preview: bool) -> Option<PathBuf> {
127 #[cfg(target_pointer_width = "64")]
128 let env_var = if find_alternate {
129 "ProgramFiles(x86)"
130 } else {
131 "ProgramFiles"
132 };
133
134 #[cfg(target_pointer_width = "32")]
135 let env_var = if find_alternate {
136 "ProgramW6432"
137 } else {
138 "ProgramFiles"
139 };
140
141 let install_base_dir = PathBuf::from(std::env::var_os(env_var)?).join("PowerShell");
142 install_base_dir
143 .read_dir()
144 .ok()?
145 .filter_map(Result::ok)
146 .filter(|entry| matches!(entry.file_type(), Ok(ft) if ft.is_dir()))
147 .filter_map(|entry| {
148 let dir_name = entry.file_name();
149 let dir_name = dir_name.to_string_lossy();
150
151 let version = if find_preview {
152 let dash_index = dir_name.find('-')?;
153 if &dir_name[dash_index + 1..] != "preview" {
154 return None;
155 };
156 dir_name[..dash_index].parse::<u32>().ok()?
157 } else {
158 dir_name.parse::<u32>().ok()?
159 };
160
161 let exe_path = entry.path().join("pwsh.exe");
162 if exe_path.exists() {
163 Some((version, exe_path))
164 } else {
165 None
166 }
167 })
168 .max_by_key(|(version, _)| *version)
169 .map(|(_, path)| path)
170 }
171
172 fn find_pwsh_in_msix(find_preview: bool) -> Option<PathBuf> {
173 let msix_app_dir =
174 PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Microsoft\\WindowsApps");
175 if !msix_app_dir.exists() {
176 return None;
177 }
178
179 let prefix = if find_preview {
180 "Microsoft.PowerShellPreview_"
181 } else {
182 "Microsoft.PowerShell_"
183 };
184 msix_app_dir
185 .read_dir()
186 .ok()?
187 .filter_map(|entry| {
188 let entry = entry.ok()?;
189 if !matches!(entry.file_type(), Ok(ft) if ft.is_dir()) {
190 return None;
191 }
192
193 if !entry.file_name().to_string_lossy().starts_with(prefix) {
194 return None;
195 }
196
197 let exe_path = entry.path().join("pwsh.exe");
198 exe_path.exists().then_some(exe_path)
199 })
200 .next()
201 }
202
203 fn find_pwsh_in_scoop() -> Option<PathBuf> {
204 let pwsh_exe =
205 PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\pwsh.exe");
206 pwsh_exe.exists().then_some(pwsh_exe)
207 }
208
209 static SYSTEM_SHELL: LazyLock<String> = LazyLock::new(|| {
210 let locations = [
211 || find_pwsh_in_programfiles(false, false),
212 || find_pwsh_in_programfiles(true, false),
213 || find_pwsh_in_msix(false),
214 || find_pwsh_in_programfiles(false, true),
215 || find_pwsh_in_msix(true),
216 || find_pwsh_in_programfiles(true, true),
217 || find_pwsh_in_scoop(),
218 || which::which_global("pwsh.exe").ok(),
219 || which::which_global("powershell.exe").ok(),
220 ];
221
222 locations
223 .into_iter()
224 .find_map(|f| f())
225 .map(|p| p.to_string_lossy().trim().to_owned())
226 .inspect(|shell| log::info!("Found powershell in: {}", shell))
227 .unwrap_or_else(|| {
228 log::warn!("Powershell not found, falling back to `cmd`");
229 "cmd.exe".to_string()
230 })
231 });
232
233 (*SYSTEM_SHELL).clone()
234}
235
236impl fmt::Display for ShellKind {
237 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
238 match self {
239 ShellKind::Posix => write!(f, "sh"),
240 ShellKind::Csh => write!(f, "csh"),
241 ShellKind::Tcsh => write!(f, "tcsh"),
242 ShellKind::Fish => write!(f, "fish"),
243 ShellKind::PowerShell => write!(f, "powershell"),
244 ShellKind::Pwsh => write!(f, "pwsh"),
245 ShellKind::Nushell => write!(f, "nu"),
246 ShellKind::Cmd => write!(f, "cmd"),
247 ShellKind::Rc => write!(f, "rc"),
248 ShellKind::Xonsh => write!(f, "xonsh"),
249 ShellKind::Elvish => write!(f, "elvish"),
250 }
251 }
252}
253
254impl ShellKind {
255 pub fn system() -> Self {
256 Self::new(&get_system_shell(), cfg!(windows))
257 }
258
259 pub fn new(program: impl AsRef<Path>, is_windows: bool) -> Self {
260 let program = program.as_ref();
261 let program = program
262 .file_stem()
263 .unwrap_or_else(|| program.as_os_str())
264 .to_string_lossy();
265
266 match &*program {
267 "powershell" => ShellKind::PowerShell,
268 "pwsh" => ShellKind::Pwsh,
269 "cmd" => ShellKind::Cmd,
270 "nu" => ShellKind::Nushell,
271 "fish" => ShellKind::Fish,
272 "csh" => ShellKind::Csh,
273 "tcsh" => ShellKind::Tcsh,
274 "rc" => ShellKind::Rc,
275 "xonsh" => ShellKind::Xonsh,
276 "elvish" => ShellKind::Elvish,
277 "sh" | "bash" | "zsh" => ShellKind::Posix,
278 _ if is_windows => ShellKind::PowerShell,
279 // Some other shell detected, the user might install and use a
280 // unix-like shell.
281 _ => ShellKind::Posix,
282 }
283 }
284
285 pub fn to_shell_variable(self, input: &str) -> String {
286 match self {
287 Self::PowerShell | Self::Pwsh => Self::to_powershell_variable(input),
288 Self::Cmd => Self::to_cmd_variable(input),
289 Self::Posix => input.to_owned(),
290 Self::Fish => input.to_owned(),
291 Self::Csh => input.to_owned(),
292 Self::Tcsh => input.to_owned(),
293 Self::Rc => input.to_owned(),
294 Self::Nushell => Self::to_nushell_variable(input),
295 Self::Xonsh => input.to_owned(),
296 Self::Elvish => input.to_owned(),
297 }
298 }
299
300 fn to_cmd_variable(input: &str) -> String {
301 if let Some(var_str) = input.strip_prefix("${") {
302 if var_str.find(':').is_none() {
303 // If the input starts with "${", remove the trailing "}"
304 format!("%{}%", &var_str[..var_str.len() - 1])
305 } else {
306 // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
307 // which will result in the task failing to run in such cases.
308 input.into()
309 }
310 } else if let Some(var_str) = input.strip_prefix('$') {
311 // If the input starts with "$", directly append to "$env:"
312 format!("%{}%", var_str)
313 } else {
314 // If no prefix is found, return the input as is
315 input.into()
316 }
317 }
318
319 fn to_powershell_variable(input: &str) -> String {
320 if let Some(var_str) = input.strip_prefix("${") {
321 if var_str.find(':').is_none() {
322 // If the input starts with "${", remove the trailing "}"
323 format!("$env:{}", &var_str[..var_str.len() - 1])
324 } else {
325 // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation,
326 // which will result in the task failing to run in such cases.
327 input.into()
328 }
329 } else if let Some(var_str) = input.strip_prefix('$') {
330 // If the input starts with "$", directly append to "$env:"
331 format!("$env:{}", var_str)
332 } else {
333 // If no prefix is found, return the input as is
334 input.into()
335 }
336 }
337
338 fn to_nushell_variable(input: &str) -> String {
339 let mut result = String::new();
340 let mut source = input;
341 let mut is_start = true;
342
343 loop {
344 match source.chars().next() {
345 None => return result,
346 Some('$') => {
347 source = Self::parse_nushell_var(&source[1..], &mut result, is_start);
348 is_start = false;
349 }
350 Some(_) => {
351 is_start = false;
352 let chunk_end = source.find('$').unwrap_or(source.len());
353 let (chunk, rest) = source.split_at(chunk_end);
354 result.push_str(chunk);
355 source = rest;
356 }
357 }
358 }
359 }
360
361 fn parse_nushell_var<'a>(source: &'a str, text: &mut String, is_start: bool) -> &'a str {
362 if source.starts_with("env.") {
363 text.push('$');
364 return source;
365 }
366
367 match source.chars().next() {
368 Some('{') => {
369 let source = &source[1..];
370 if let Some(end) = source.find('}') {
371 let var_name = &source[..end];
372 if !var_name.is_empty() {
373 if !is_start {
374 text.push_str("(");
375 }
376 text.push_str("$env.");
377 text.push_str(var_name);
378 if !is_start {
379 text.push_str(")");
380 }
381 &source[end + 1..]
382 } else {
383 text.push_str("${}");
384 &source[end + 1..]
385 }
386 } else {
387 text.push_str("${");
388 source
389 }
390 }
391 Some(c) if c.is_alphabetic() || c == '_' => {
392 let end = source
393 .find(|c: char| !c.is_alphanumeric() && c != '_')
394 .unwrap_or(source.len());
395 let var_name = &source[..end];
396 if !is_start {
397 text.push_str("(");
398 }
399 text.push_str("$env.");
400 text.push_str(var_name);
401 if !is_start {
402 text.push_str(")");
403 }
404 &source[end..]
405 }
406 _ => {
407 text.push('$');
408 source
409 }
410 }
411 }
412
413 pub fn args_for_shell(&self, interactive: bool, combined_command: String) -> Vec<String> {
414 match self {
415 ShellKind::PowerShell | ShellKind::Pwsh => vec!["-C".to_owned(), combined_command],
416 ShellKind::Cmd => vec![
417 "/S".to_owned(),
418 "/C".to_owned(),
419 format!("\"{combined_command}\""),
420 ],
421 ShellKind::Posix
422 | ShellKind::Nushell
423 | ShellKind::Fish
424 | ShellKind::Csh
425 | ShellKind::Tcsh
426 | ShellKind::Rc
427 | ShellKind::Xonsh
428 | ShellKind::Elvish => interactive
429 .then(|| "-i".to_owned())
430 .into_iter()
431 .chain(["-c".to_owned(), combined_command])
432 .collect(),
433 }
434 }
435
436 pub const fn command_prefix(&self) -> Option<char> {
437 match self {
438 ShellKind::PowerShell | ShellKind::Pwsh => Some('&'),
439 ShellKind::Nushell => Some('^'),
440 ShellKind::Posix
441 | ShellKind::Csh
442 | ShellKind::Tcsh
443 | ShellKind::Rc
444 | ShellKind::Fish
445 | ShellKind::Cmd
446 | ShellKind::Xonsh
447 | ShellKind::Elvish => None,
448 }
449 }
450
451 pub fn prepend_command_prefix<'a>(&self, command: &'a str) -> Cow<'a, str> {
452 match self.command_prefix() {
453 Some(prefix) if !command.starts_with(prefix) => {
454 Cow::Owned(format!("{prefix}{command}"))
455 }
456 _ => Cow::Borrowed(command),
457 }
458 }
459
460 pub const fn sequential_commands_separator(&self) -> char {
461 match self {
462 ShellKind::Cmd => '&',
463 ShellKind::Posix
464 | ShellKind::Csh
465 | ShellKind::Tcsh
466 | ShellKind::Rc
467 | ShellKind::Fish
468 | ShellKind::PowerShell
469 | ShellKind::Pwsh
470 | ShellKind::Nushell
471 | ShellKind::Xonsh
472 | ShellKind::Elvish => ';',
473 }
474 }
475
476 pub const fn sequential_and_commands_separator(&self) -> &'static str {
477 match self {
478 ShellKind::Cmd
479 | ShellKind::Posix
480 | ShellKind::Csh
481 | ShellKind::Tcsh
482 | ShellKind::Rc
483 | ShellKind::Fish
484 | ShellKind::Pwsh
485 | ShellKind::PowerShell
486 | ShellKind::Xonsh => "&&",
487 ShellKind::Nushell | ShellKind::Elvish => ";",
488 }
489 }
490
491 pub fn try_quote<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
492 match self {
493 ShellKind::PowerShell => Some(Self::quote_powershell(arg)),
494 ShellKind::Pwsh => Some(Self::quote_pwsh(arg)),
495 ShellKind::Cmd => Some(Self::quote_cmd(arg)),
496 ShellKind::Posix
497 | ShellKind::Csh
498 | ShellKind::Tcsh
499 | ShellKind::Rc
500 | ShellKind::Fish
501 | ShellKind::Nushell
502 | ShellKind::Xonsh
503 | ShellKind::Elvish => shlex::try_quote(arg).ok(),
504 }
505 }
506
507 fn quote_windows(arg: &str, enclose: bool) -> Cow<'_, str> {
508 if arg.is_empty() {
509 return Cow::Borrowed("\"\"");
510 }
511
512 let needs_quoting = arg.chars().any(|c| c == ' ' || c == '\t' || c == '"');
513 if !needs_quoting {
514 return Cow::Borrowed(arg);
515 }
516
517 let mut result = String::with_capacity(arg.len() + 2);
518
519 if enclose {
520 result.push('"');
521 }
522
523 let chars: Vec<char> = arg.chars().collect();
524 let mut i = 0;
525
526 while i < chars.len() {
527 if chars[i] == '\\' {
528 let mut num_backslashes = 0;
529 while i < chars.len() && chars[i] == '\\' {
530 num_backslashes += 1;
531 i += 1;
532 }
533
534 if i < chars.len() && chars[i] == '"' {
535 // Backslashes followed by quote: double the backslashes and escape the quote
536 for _ in 0..(num_backslashes * 2 + 1) {
537 result.push('\\');
538 }
539 result.push('"');
540 i += 1;
541 } else if i >= chars.len() {
542 // Trailing backslashes: double them (they precede the closing quote)
543 for _ in 0..(num_backslashes * 2) {
544 result.push('\\');
545 }
546 } else {
547 // Backslashes not followed by quote: output as-is
548 for _ in 0..num_backslashes {
549 result.push('\\');
550 }
551 }
552 } else if chars[i] == '"' {
553 // Quote not preceded by backslash: escape it
554 result.push('\\');
555 result.push('"');
556 i += 1;
557 } else {
558 result.push(chars[i]);
559 i += 1;
560 }
561 }
562
563 if enclose {
564 result.push('"');
565 }
566 Cow::Owned(result)
567 }
568
569 fn needs_quoting_powershell(s: &str) -> bool {
570 s.is_empty()
571 || s.chars().any(|c| {
572 c.is_whitespace()
573 || matches!(
574 c,
575 '"' | '`'
576 | '$'
577 | '&'
578 | '|'
579 | '<'
580 | '>'
581 | ';'
582 | '('
583 | ')'
584 | '['
585 | ']'
586 | '{'
587 | '}'
588 | ','
589 | '\''
590 | '@'
591 )
592 })
593 }
594
595 fn need_quotes_powershell(arg: &str) -> bool {
596 let mut quote_count = 0;
597 for c in arg.chars() {
598 if c == '"' {
599 quote_count += 1;
600 } else if c.is_whitespace() && (quote_count % 2 == 0) {
601 return true;
602 }
603 }
604 false
605 }
606
607 fn escape_powershell_quotes(s: &str) -> String {
608 let mut result = String::with_capacity(s.len() + 4);
609 result.push('\'');
610 for c in s.chars() {
611 if c == '\'' {
612 result.push('\'');
613 }
614 result.push(c);
615 }
616 result.push('\'');
617 result
618 }
619
620 pub fn quote_powershell(arg: &str) -> Cow<'_, str> {
621 let ps_will_quote = Self::need_quotes_powershell(arg);
622 let crt_quoted = Self::quote_windows(arg, !ps_will_quote);
623
624 if !Self::needs_quoting_powershell(arg) {
625 return crt_quoted;
626 }
627
628 Cow::Owned(Self::escape_powershell_quotes(&crt_quoted))
629 }
630
631 pub fn quote_pwsh(arg: &str) -> Cow<'_, str> {
632 if arg.is_empty() {
633 return Cow::Borrowed("''");
634 }
635
636 if !Self::needs_quoting_powershell(arg) {
637 return Cow::Borrowed(arg);
638 }
639
640 Cow::Owned(Self::escape_powershell_quotes(arg))
641 }
642
643 pub fn quote_cmd(arg: &str) -> Cow<'_, str> {
644 let crt_quoted = Self::quote_windows(arg, true);
645
646 let needs_cmd_escaping = crt_quoted.contains('"')
647 || crt_quoted.contains('%')
648 || crt_quoted
649 .chars()
650 .any(|c| matches!(c, '^' | '<' | '>' | '&' | '|' | '(' | ')'));
651
652 if !needs_cmd_escaping {
653 return crt_quoted;
654 }
655
656 let mut result = String::with_capacity(crt_quoted.len() * 2);
657 for c in crt_quoted.chars() {
658 match c {
659 '^' | '"' | '<' | '>' | '&' | '|' | '(' | ')' => {
660 result.push('^');
661 result.push(c);
662 }
663 '%' => {
664 result.push_str("%%cd:~,%");
665 }
666 _ => result.push(c),
667 }
668 }
669 Cow::Owned(result)
670 }
671
672 /// Quotes the given argument if necessary, taking into account the command prefix.
673 ///
674 /// In other words, this will consider quoting arg without its command prefix to not break the command.
675 /// You should use this over `try_quote` when you want to quote a shell command.
676 pub fn try_quote_prefix_aware<'a>(&self, arg: &'a str) -> Option<Cow<'a, str>> {
677 if let Some(char) = self.command_prefix() {
678 if let Some(arg) = arg.strip_prefix(char) {
679 // we have a command that is prefixed
680 for quote in ['\'', '"'] {
681 if let Some(arg) = arg
682 .strip_prefix(quote)
683 .and_then(|arg| arg.strip_suffix(quote))
684 {
685 // and the command itself is wrapped as a literal, that
686 // means the prefix exists to interpret a literal as a
687 // command. So strip the quotes, quote the command, and
688 // re-add the quotes if they are missing after requoting
689 let quoted = self.try_quote(arg)?;
690 return Some(if quoted.starts_with(['\'', '"']) {
691 Cow::Owned(self.prepend_command_prefix("ed).into_owned())
692 } else {
693 Cow::Owned(
694 self.prepend_command_prefix(&format!("{quote}{quoted}{quote}"))
695 .into_owned(),
696 )
697 });
698 }
699 }
700 return self
701 .try_quote(arg)
702 .map(|quoted| Cow::Owned(self.prepend_command_prefix("ed).into_owned()));
703 }
704 }
705 self.try_quote(arg).map(|quoted| match quoted {
706 unquoted @ Cow::Borrowed(_) => unquoted,
707 Cow::Owned(quoted) => Cow::Owned(self.prepend_command_prefix("ed).into_owned()),
708 })
709 }
710
711 pub fn split(&self, input: &str) -> Option<Vec<String>> {
712 shlex::split(input)
713 }
714
715 pub const fn activate_keyword(&self) -> &'static str {
716 match self {
717 ShellKind::Cmd => "",
718 ShellKind::Nushell => "overlay use",
719 ShellKind::PowerShell | ShellKind::Pwsh => ".",
720 ShellKind::Fish
721 | ShellKind::Csh
722 | ShellKind::Tcsh
723 | ShellKind::Posix
724 | ShellKind::Rc
725 | ShellKind::Xonsh
726 | ShellKind::Elvish => "source",
727 }
728 }
729
730 pub const fn clear_screen_command(&self) -> &'static str {
731 match self {
732 ShellKind::Cmd => "cls",
733 ShellKind::Posix
734 | ShellKind::Csh
735 | ShellKind::Tcsh
736 | ShellKind::Rc
737 | ShellKind::Fish
738 | ShellKind::PowerShell
739 | ShellKind::Pwsh
740 | ShellKind::Nushell
741 | ShellKind::Xonsh
742 | ShellKind::Elvish => "clear",
743 }
744 }
745
746 #[cfg(windows)]
747 /// We do not want to escape arguments if we are using CMD as our shell.
748 /// If we do we end up with too many quotes/escaped quotes for CMD to handle.
749 pub const fn tty_escape_args(&self) -> bool {
750 match self {
751 ShellKind::Cmd => false,
752 ShellKind::Posix
753 | ShellKind::Csh
754 | ShellKind::Tcsh
755 | ShellKind::Rc
756 | ShellKind::Fish
757 | ShellKind::PowerShell
758 | ShellKind::Pwsh
759 | ShellKind::Nushell
760 | ShellKind::Xonsh
761 | ShellKind::Elvish => true,
762 }
763 }
764}
765
766#[cfg(test)]
767mod tests {
768 use super::*;
769
770 // Examples
771 // WSL
772 // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "echo hello"
773 // wsl.exe --distribution NixOS --cd /home/user -- /usr/bin/zsh -c "\"echo hello\"" | grep hello"
774 // wsl.exe --distribution NixOS --cd ~ env RUST_LOG=info,remote=debug .zed_wsl_server/zed-remote-server-dev-build proxy --identifier dev-workspace-53
775 // PowerShell from Nushell
776 // 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\""
777 // PowerShell from CMD
778 // 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\\\"\"\"
779
780 #[test]
781 fn test_try_quote_powershell() {
782 let shell_kind = ShellKind::PowerShell;
783 assert_eq!(
784 shell_kind
785 .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
786 .unwrap()
787 .into_owned(),
788 "'C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\\"test_foo.py::test_foo\\\"'".to_string()
789 );
790 }
791
792 #[test]
793 fn test_try_quote_cmd() {
794 let shell_kind = ShellKind::Cmd;
795 assert_eq!(
796 shell_kind
797 .try_quote("C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \"test_foo.py::test_foo\"")
798 .unwrap()
799 .into_owned(),
800 "^\"C:\\Users\\johndoe\\dev\\python\\39007\\tests\\.venv\\Scripts\\python.exe -m pytest \\^\"test_foo.py::test_foo\\^\"^\"".to_string()
801 );
802 }
803
804 #[test]
805 fn test_try_quote_powershell_edge_cases() {
806 let shell_kind = ShellKind::PowerShell;
807
808 // Empty string
809 assert_eq!(
810 shell_kind.try_quote("").unwrap().into_owned(),
811 "'\"\"'".to_string()
812 );
813
814 // String without special characters (no quoting needed)
815 assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple");
816
817 // String with spaces
818 assert_eq!(
819 shell_kind.try_quote("hello world").unwrap().into_owned(),
820 "'hello world'".to_string()
821 );
822
823 // String with dollar signs
824 assert_eq!(
825 shell_kind.try_quote("$variable").unwrap().into_owned(),
826 "'$variable'".to_string()
827 );
828
829 // String with backticks
830 assert_eq!(
831 shell_kind.try_quote("test`command").unwrap().into_owned(),
832 "'test`command'".to_string()
833 );
834
835 // String with multiple special characters
836 assert_eq!(
837 shell_kind
838 .try_quote("test `\"$var`\" end")
839 .unwrap()
840 .into_owned(),
841 "'test `\\\"$var`\\\" end'".to_string()
842 );
843
844 // String with backslashes and colon (path without spaces doesn't need quoting)
845 assert_eq!(
846 shell_kind.try_quote("C:\\path\\to\\file").unwrap(),
847 "C:\\path\\to\\file"
848 );
849 }
850
851 #[test]
852 fn test_try_quote_cmd_edge_cases() {
853 let shell_kind = ShellKind::Cmd;
854
855 // Empty string
856 assert_eq!(
857 shell_kind.try_quote("").unwrap().into_owned(),
858 "^\"^\"".to_string()
859 );
860
861 // String without special characters (no quoting needed)
862 assert_eq!(shell_kind.try_quote("simple").unwrap(), "simple");
863
864 // String with spaces
865 assert_eq!(
866 shell_kind.try_quote("hello world").unwrap().into_owned(),
867 "^\"hello world^\"".to_string()
868 );
869
870 // String with space and backslash (backslash not at end, so not doubled)
871 assert_eq!(
872 shell_kind.try_quote("path\\ test").unwrap().into_owned(),
873 "^\"path\\ test^\"".to_string()
874 );
875
876 // String ending with backslash (must be doubled before closing quote)
877 assert_eq!(
878 shell_kind.try_quote("test path\\").unwrap().into_owned(),
879 "^\"test path\\\\^\"".to_string()
880 );
881
882 // String ending with multiple backslashes (all doubled before closing quote)
883 assert_eq!(
884 shell_kind.try_quote("test path\\\\").unwrap().into_owned(),
885 "^\"test path\\\\\\\\^\"".to_string()
886 );
887
888 // String with embedded quote (quote is escaped, backslash before it is doubled)
889 assert_eq!(
890 shell_kind.try_quote("test\\\"quote").unwrap().into_owned(),
891 "^\"test\\\\\\^\"quote^\"".to_string()
892 );
893
894 // String with multiple backslashes before embedded quote (all doubled)
895 assert_eq!(
896 shell_kind
897 .try_quote("test\\\\\"quote")
898 .unwrap()
899 .into_owned(),
900 "^\"test\\\\\\\\\\^\"quote^\"".to_string()
901 );
902
903 // String with backslashes not before quotes (path without spaces doesn't need quoting)
904 assert_eq!(
905 shell_kind.try_quote("C:\\path\\to\\file").unwrap(),
906 "C:\\path\\to\\file"
907 );
908 }
909
910 #[test]
911 fn test_try_quote_nu_command() {
912 let shell_kind = ShellKind::Nushell;
913 assert_eq!(
914 shell_kind.try_quote("'uname'").unwrap().into_owned(),
915 "\"'uname'\"".to_string()
916 );
917 assert_eq!(
918 shell_kind
919 .try_quote_prefix_aware("'uname'")
920 .unwrap()
921 .into_owned(),
922 "^\"'uname'\"".to_string()
923 );
924 assert_eq!(
925 shell_kind.try_quote("^uname").unwrap().into_owned(),
926 "'^uname'".to_string()
927 );
928 assert_eq!(
929 shell_kind
930 .try_quote_prefix_aware("^uname")
931 .unwrap()
932 .into_owned(),
933 "^uname".to_string()
934 );
935 assert_eq!(
936 shell_kind.try_quote("^'uname'").unwrap().into_owned(),
937 "'^'\"'uname\'\"".to_string()
938 );
939 assert_eq!(
940 shell_kind
941 .try_quote_prefix_aware("^'uname'")
942 .unwrap()
943 .into_owned(),
944 "^'uname'".to_string()
945 );
946 assert_eq!(
947 shell_kind.try_quote("'uname a'").unwrap().into_owned(),
948 "\"'uname a'\"".to_string()
949 );
950 assert_eq!(
951 shell_kind
952 .try_quote_prefix_aware("'uname a'")
953 .unwrap()
954 .into_owned(),
955 "^\"'uname a'\"".to_string()
956 );
957 assert_eq!(
958 shell_kind.try_quote("^'uname a'").unwrap().into_owned(),
959 "'^'\"'uname a'\"".to_string()
960 );
961 assert_eq!(
962 shell_kind
963 .try_quote_prefix_aware("^'uname a'")
964 .unwrap()
965 .into_owned(),
966 "^'uname a'".to_string()
967 );
968 assert_eq!(
969 shell_kind.try_quote("uname").unwrap().into_owned(),
970 "uname".to_string()
971 );
972 assert_eq!(
973 shell_kind
974 .try_quote_prefix_aware("uname")
975 .unwrap()
976 .into_owned(),
977 "uname".to_string()
978 );
979 }
980}