Skip to main content

mk_lib/schema/
shell.rs

1use schemars::JsonSchema;
2use serde::Deserialize;
3use std::process::Command as ProcessCommand;
4
5#[derive(Debug, Default, Deserialize, Clone, PartialEq, Eq, JsonSchema)]
6/// Shell command with optional flags.
7pub struct ShellArgs {
8  /// The shell command to run
9  pub command: String,
10
11  /// The flags to pass to the shell command
12  pub args: Option<Vec<String>>,
13}
14
15#[derive(Debug, Deserialize, Clone, PartialEq, Eq, JsonSchema)]
16#[serde(untagged)]
17/// The shell to use. Either a string name (e.g. "bash") or an object with `command` and optional `args`.
18pub enum Shell {
19  String(String),
20  Shell(Box<ShellArgs>),
21}
22
23impl Default for Shell {
24  fn default() -> Self {
25    Shell::String(default_shell_command().to_string())
26  }
27}
28
29impl Shell {
30  pub fn new() -> anyhow::Result<Self> {
31    Ok(Shell::default())
32  }
33
34  pub fn new_with_flags(command: &str, args: Vec<String>) -> anyhow::Result<Self> {
35    let shell_def = ShellArgs {
36      command: command.to_string(),
37      args: Some(args),
38    };
39    Ok(Shell::Shell(Box::new(shell_def)))
40  }
41
42  pub fn from_shell(shell: &Shell) -> Self {
43    match shell {
44      Shell::String(command) => Shell::String(command.to_string()),
45      Shell::Shell(args) => Shell::Shell(args.clone()),
46    }
47  }
48
49  pub fn cmd(&self) -> String {
50    match self {
51      Shell::String(command) => ShellArgs {
52        command: command.to_string(),
53        args: None,
54      }
55      .cmd(),
56      Shell::Shell(args) => args.cmd(),
57    }
58  }
59
60  pub fn args(&self) -> Vec<String> {
61    match self {
62      Shell::String(command) => ShellArgs {
63        command: command.to_string(),
64        args: None,
65      }
66      .shell_args(),
67      Shell::Shell(args) => args.shell_args(),
68    }
69  }
70
71  pub fn proc(&self) -> ProcessCommand {
72    let shell = self.cmd();
73    let args = self.args();
74
75    let mut cmd = ProcessCommand::new(&shell);
76    for arg in args {
77      cmd.arg(arg);
78    }
79
80    cmd
81  }
82}
83
84impl From<Shell> for ProcessCommand {
85  fn from(shell: Shell) -> Self {
86    shell.proc()
87  }
88}
89
90impl ShellArgs {
91  pub fn cmd(&self) -> String {
92    self.command.clone()
93  }
94
95  pub fn shell_args(&self) -> Vec<String> {
96    let command = self.command.clone();
97    let args = self.args.clone().unwrap_or_default();
98    let Some(eval_flag) = shell_eval_flag(&command) else {
99      return args;
100    };
101
102    if args.iter().any(|arg| arg.eq_ignore_ascii_case(eval_flag)) {
103      return args;
104    }
105
106    let mut args = args;
107    args.push(eval_flag.to_string());
108    args
109  }
110}
111
112fn default_shell_command() -> &'static str {
113  if cfg!(windows) {
114    "cmd"
115  } else {
116    "sh"
117  }
118}
119
120fn shell_eval_flag(command: &str) -> Option<&'static str> {
121  let shell = command
122    .rsplit(['/', '\\'])
123    .next()
124    .unwrap_or(command)
125    .to_ascii_lowercase();
126
127  match shell.as_str() {
128    "sh" | "bash" | "zsh" | "fish" => Some("-c"),
129    "cmd" | "cmd.exe" => Some("/C"),
130    "powershell" | "powershell.exe" | "pwsh" | "pwsh.exe" => Some("-Command"),
131    _ => None,
132  }
133}
134
135#[cfg(test)]
136mod tests {
137  use super::{
138    default_shell_command,
139    Shell,
140    ShellArgs,
141  };
142
143  #[test]
144  fn shell_default_matches_platform() {
145    let shell = Shell::default();
146    assert_eq!(shell.cmd(), default_shell_command().to_string());
147  }
148
149  #[test]
150  fn posix_shell_adds_dash_c() {
151    let args = ShellArgs {
152      command: "bash".to_string(),
153      args: None,
154    };
155
156    assert_eq!(args.shell_args(), vec!["-c".to_string()]);
157  }
158
159  #[test]
160  fn cmd_shell_adds_slash_c() {
161    let args = ShellArgs {
162      command: "cmd.exe".to_string(),
163      args: None,
164    };
165
166    assert_eq!(args.shell_args(), vec!["/C".to_string()]);
167  }
168
169  #[test]
170  fn powershell_adds_command_flag() {
171    let args = ShellArgs {
172      command: "pwsh".to_string(),
173      args: Some(vec!["-NoProfile".to_string()]),
174    };
175
176    assert_eq!(
177      args.shell_args(),
178      vec!["-NoProfile".to_string(), "-Command".to_string()]
179    );
180  }
181
182  #[test]
183  fn existing_eval_flag_is_preserved() {
184    let args = ShellArgs {
185      command: "cmd".to_string(),
186      args: Some(vec!["/C".to_string()]),
187    };
188
189    assert_eq!(args.shell_args(), vec!["/C".to_string()]);
190  }
191}