1
use schemars::JsonSchema;
2
use serde::Deserialize;
3
use std::process::Command as ProcessCommand;
4

            
5
#[derive(Debug, Default, Deserialize, Clone, PartialEq, Eq, JsonSchema)]
6
/// Shell command with optional flags.
7
pub 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`.
18
pub enum Shell {
19
  String(String),
20
  Shell(Box<ShellArgs>),
21
}
22

            
23
impl Default for Shell {
24
142
  fn default() -> Self {
25
142
    Shell::String(default_shell_command().to_string())
26
142
  }
27
}
28

            
29
impl 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
2
  pub fn from_shell(shell: &Shell) -> Self {
43
2
    match shell {
44
2
      Shell::String(command) => Shell::String(command.to_string()),
45
      Shell::Shell(args) => Shell::Shell(args.clone()),
46
    }
47
2
  }
48

            
49
156
  pub fn cmd(&self) -> String {
50
156
    match self {
51
156
      Shell::String(command) => ShellArgs {
52
156
        command: command.to_string(),
53
156
        args: None,
54
156
      }
55
156
      .cmd(),
56
      Shell::Shell(args) => args.cmd(),
57
    }
58
156
  }
59

            
60
72
  pub fn args(&self) -> Vec<String> {
61
72
    match self {
62
72
      Shell::String(command) => ShellArgs {
63
72
        command: command.to_string(),
64
72
        args: None,
65
72
      }
66
72
      .shell_args(),
67
      Shell::Shell(args) => args.shell_args(),
68
    }
69
72
  }
70

            
71
70
  pub fn proc(&self) -> ProcessCommand {
72
70
    let shell = self.cmd();
73
70
    let args = self.args();
74

            
75
70
    let mut cmd = ProcessCommand::new(&shell);
76
70
    for arg in args {
77
70
      cmd.arg(arg);
78
70
    }
79

            
80
70
    cmd
81
70
  }
82
}
83

            
84
impl From<Shell> for ProcessCommand {
85
  fn from(shell: Shell) -> Self {
86
    shell.proc()
87
  }
88
}
89

            
90
impl ShellArgs {
91
156
  pub fn cmd(&self) -> String {
92
156
    self.command.clone()
93
156
  }
94

            
95
80
  pub fn shell_args(&self) -> Vec<String> {
96
80
    let command = self.command.clone();
97
80
    let args = self.args.clone().unwrap_or_default();
98
80
    let Some(eval_flag) = shell_eval_flag(&command) else {
99
      return args;
100
    };
101

            
102
80
    if args.iter().any(|arg| arg.eq_ignore_ascii_case(eval_flag)) {
103
2
      return args;
104
78
    }
105

            
106
78
    let mut args = args;
107
78
    args.push(eval_flag.to_string());
108
78
    args
109
80
  }
110
}
111

            
112
144
fn default_shell_command() -> &'static str {
113
144
  if cfg!(windows) {
114
    "cmd"
115
  } else {
116
144
    "sh"
117
  }
118
144
}
119

            
120
80
fn shell_eval_flag(command: &str) -> Option<&'static str> {
121
80
  let shell = command
122
80
    .rsplit(['/', '\\'])
123
80
    .next()
124
80
    .unwrap_or(command)
125
80
    .to_ascii_lowercase();
126

            
127
80
  match shell.as_str() {
128
80
    "sh" | "bash" | "zsh" | "fish" => Some("-c"),
129
6
    "cmd" | "cmd.exe" => Some("/C"),
130
2
    "powershell" | "powershell.exe" | "pwsh" | "pwsh.exe" => Some("-Command"),
131
    _ => None,
132
  }
133
80
}
134

            
135
#[cfg(test)]
136
mod tests {
137
  use super::{
138
    default_shell_command,
139
    Shell,
140
    ShellArgs,
141
  };
142

            
143
  #[test]
144
2
  fn shell_default_matches_platform() {
145
2
    let shell = Shell::default();
146
2
    assert_eq!(shell.cmd(), default_shell_command().to_string());
147
2
  }
148

            
149
  #[test]
150
2
  fn posix_shell_adds_dash_c() {
151
2
    let args = ShellArgs {
152
2
      command: "bash".to_string(),
153
2
      args: None,
154
2
    };
155

            
156
2
    assert_eq!(args.shell_args(), vec!["-c".to_string()]);
157
2
  }
158

            
159
  #[test]
160
2
  fn cmd_shell_adds_slash_c() {
161
2
    let args = ShellArgs {
162
2
      command: "cmd.exe".to_string(),
163
2
      args: None,
164
2
    };
165

            
166
2
    assert_eq!(args.shell_args(), vec!["/C".to_string()]);
167
2
  }
168

            
169
  #[test]
170
2
  fn powershell_adds_command_flag() {
171
2
    let args = ShellArgs {
172
2
      command: "pwsh".to_string(),
173
2
      args: Some(vec!["-NoProfile".to_string()]),
174
2
    };
175

            
176
2
    assert_eq!(
177
2
      args.shell_args(),
178
2
      vec!["-NoProfile".to_string(), "-Command".to_string()]
179
    );
180
2
  }
181

            
182
  #[test]
183
2
  fn existing_eval_flag_is_preserved() {
184
2
    let args = ShellArgs {
185
2
      command: "cmd".to_string(),
186
2
      args: Some(vec!["/C".to_string()]),
187
2
    };
188

            
189
2
    assert_eq!(args.shell_args(), vec!["/C".to_string()]);
190
2
  }
191
}