1use std::collections::HashMap;
2use std::path::Path;
3use std::process::Command;
4use mk_rs_core::shell::{Shell, ShellResult, ShellError};
5
6#[derive(Debug, Clone)]
8pub struct ShShell;
9
10impl Shell for ShShell {
11 fn name(&self) -> &str {
12 "sh"
13 }
14
15 fn execute(
16 &self,
17 recipe: &str,
18 env: &HashMap<String, String>,
19 dir: &Path,
20 ) -> Result<ShellResult, ShellError> {
21 let mut cmd = Command::new("/bin/sh");
22 cmd.arg("-e") .arg("-c") .arg(recipe)
25 .current_dir(dir);
26
27 cmd.env_clear();
29 for (k, v) in env {
30 cmd.env(k, v);
31 }
32 if !env.contains_key("PATH") {
33 cmd.env("PATH", "/usr/local/bin:/usr/bin:/bin");
34 }
35
36 let status = cmd.status()?;
37
38 Ok(ShellResult {
39 exit_code: status.code().unwrap_or(-1),
40 stdout: String::new(),
41 stderr: String::new(),
42 })
43 }
44
45 fn find_unescaped(&self, input: &str, ch: char) -> Vec<usize> {
46 let mut positions = Vec::new();
47 let bytes = input.as_bytes();
48 let mut in_single = false;
49 let mut in_double = false;
50 let mut i = 0;
51
52 while i < bytes.len() {
53 match bytes[i] {
54 b'\\' if !in_single => {
55 i += 2; continue;
58 }
59 b'\'' if !in_double => {
60 in_single = !in_single;
61 }
62 b'"' if !in_single => {
63 in_double = !in_double;
64 }
65 c if c == ch as u8 && !in_single && !in_double => {
66 positions.push(i);
67 }
68 _ => {}
69 }
70 i += 1;
71 }
72 positions
73 }
74
75 fn quote(&self, token: &str) -> String {
76 if token.is_empty() {
78 return "''".to_string();
79 }
80 if !token.contains('\'') {
81 return format!("'{}'", token);
82 }
83 let escaped = token.replace('\'', "'\\''");
85 format!("'{}'", escaped)
86 }
87}
88
89#[derive(Debug, Clone)]
94pub struct CustomShell {
95 cmd: String,
96}
97
98impl CustomShell {
99 pub fn new(cmd: &str) -> Self {
100 Self { cmd: cmd.to_string() }
101 }
102}
103
104impl Shell for CustomShell {
105 fn name(&self) -> &str { &self.cmd }
106
107 fn execute(
108 &self,
109 recipe: &str,
110 env: &HashMap<String, String>,
111 dir: &Path,
112 ) -> Result<ShellResult, ShellError> {
113 let parts: Vec<&str> = self.cmd.split_whitespace().collect();
114 if parts.is_empty() {
115 return Err(ShellError::ShellNotFound { name: "empty MKSHELL".into() });
116 }
117 let mut cmd = Command::new(parts[0]);
118 if parts.len() > 1 {
120 for arg in &parts[1..] {
121 cmd.arg(arg);
122 }
123 } else {
124 cmd.arg("-c");
125 }
126 cmd.arg(recipe).current_dir(dir);
127 cmd.env_clear();
128 for (k, v) in env {
129 cmd.env(k, v);
130 }
131 if !env.contains_key("PATH") {
132 cmd.env("PATH", "/usr/local/bin:/usr/bin:/bin");
133 }
134 let status = cmd.status()?;
135 Ok(ShellResult {
136 exit_code: status.code().unwrap_or(-1),
137 stdout: String::new(),
138 stderr: String::new(),
139 })
140 }
141
142 fn find_unescaped(&self, input: &str, ch: char) -> Vec<usize> {
143 ShShell.find_unescaped(input, ch) }
145
146 fn quote(&self, token: &str) -> String {
147 ShShell.quote(token)
148 }
149}
150
151#[cfg(test)]
152mod custom_shell_tests {
153 use super::*;
154
155 #[test]
156 fn custom_shell_bash() {
157 let shell = CustomShell::new("/bin/bash -c");
158 assert_eq!(shell.name(), "/bin/bash -c");
159 let result = shell.execute("echo hello", &HashMap::new(), Path::new(".")).unwrap();
160 assert_eq!(result.exit_code, 0);
161 }
162
163 #[test]
164 #[ignore] fn custom_shell_node() {
166 let shell = CustomShell::new("node -e");
168 assert_eq!(shell.name(), "node -e");
169 let result = shell.execute("console.log('hello')", &HashMap::new(), Path::new(".")).unwrap();
170 assert_eq!(result.exit_code, 0);
171 }
172
173 #[test]
174 fn custom_shell_no_flags_defaults_to_c() {
175 let shell = CustomShell::new("/bin/bash");
177 let result = shell.execute("echo hi", &HashMap::new(), Path::new(".")).unwrap();
178 assert_eq!(result.exit_code, 0);
179 }
180}
181
182#[cfg(feature = "duckscript")]
186#[derive(Debug, Clone)]
187pub struct DuckShell;
188
189#[cfg(feature = "duckscript")]
190impl Shell for DuckShell {
191 fn name(&self) -> &str {
192 "duckscript"
193 }
194
195 fn execute(
196 &self,
197 recipe: &str,
198 env: &HashMap<String, String>,
199 dir: &Path,
200 ) -> Result<ShellResult, ShellError> {
201 let mut context = duckscript::types::runtime::Context::new();
202 for (k, v) in env {
204 context.variables.insert(k.clone(), v.clone());
205 }
206 duckscriptsdk::load(&mut context.commands)
208 .map_err(|e| ShellError::Io(std::io::Error::new(std::io::ErrorKind::Other, e.to_string())))?;
209
210 std::env::set_current_dir(dir)
212 .map_err(ShellError::Io)?;
213
214 duckscript::runner::run_script(recipe, context, None)
216 .map_err(|e| ShellError::Io(std::io::Error::new(std::io::ErrorKind::Other, e.to_string())))?;
217
218 Ok(ShellResult {
219 exit_code: 0,
220 stdout: String::new(),
221 stderr: String::new(),
222 })
223 }
224
225 fn find_unescaped(&self, input: &str, ch: char) -> Vec<usize> {
226 input.match_indices(ch).map(|(i, _)| i).collect()
228 }
229
230 fn quote(&self, token: &str) -> String {
231 token.to_string() }
233}
234
235#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn sh_shell_name() {
243 assert_eq!(ShShell.name(), "sh");
244 }
245
246 #[test]
247 fn execute_echo() {
248 let shell = ShShell;
249 let env = HashMap::new();
250 let result = shell.execute("echo hello", &env, Path::new(".")).unwrap();
251 assert_eq!(result.exit_code, 0);
252 assert_eq!(result.exit_code, 0);
253 }
254
255 #[test]
256 fn execute_error() {
257 let shell = ShShell;
258 let env = HashMap::new();
259 let result = shell.execute("exit 1", &env, Path::new(".")).unwrap();
260 assert_eq!(result.exit_code, 1);
261 }
262
263 #[test]
264 fn execute_with_env() {
265 let shell = ShShell;
266 let mut env = HashMap::new();
267 env.insert("MYVAR".into(), "myval".into());
268 let result = shell.execute("echo $MYVAR", &env, Path::new(".")).unwrap();
270 assert_eq!(result.exit_code, 0);
271 }
272
273 #[test]
274 fn find_unescaped_equal() {
275 let shell = ShShell;
276 let pos = shell.find_unescaped("CC=gcc", '=');
278 assert_eq!(pos, vec![2]);
279 }
280
281 #[test]
282 fn find_unescaped_ignores_quoted() {
283 let shell = ShShell;
284 let pos = shell.find_unescaped("foo '=' bar", '=');
286 assert!(pos.is_empty());
287 }
288
289 #[test]
290 fn find_unescaped_ignores_escaped() {
291 let shell = ShShell;
292 let pos = shell.find_unescaped("foo \\= bar", '=');
294 assert!(pos.is_empty());
295 }
296
297 #[test]
298 fn quote_simple() {
299 let shell = ShShell;
300 assert_eq!(shell.quote("hello"), "'hello'");
301 }
302
303 #[test]
304 fn quote_empty() {
305 assert_eq!(ShShell.quote(""), "''");
306 }
307
308 #[test]
309 fn quote_with_single_quote() {
310 let shell = ShShell;
311 assert_eq!(shell.quote("it's"), "'it'\\''s'");
312 }
313
314 #[test]
315 fn execute_stdout_inherited_not_captured() {
316 let shell = ShShell;
319 let env = HashMap::new();
320 let result = shell.execute("echo visible", &env, Path::new(".")).unwrap();
321 assert_eq!(result.exit_code, 0);
322 assert!(result.stdout.is_empty());
323 assert!(result.stderr.is_empty());
324 }
325
326 #[cfg(feature = "duckscript")]
327 #[test]
328 fn duck_shell_name() {
329 assert_eq!(DuckShell.name(), "duckscript");
330 }
331
332 #[cfg(feature = "duckscript")]
333 #[test]
334 fn duck_shell_execute_simple() {
335 let shell = DuckShell;
336 let env = HashMap::new();
337 let result = shell.execute("echo hello", &env, Path::new(".")).unwrap();
338 assert_eq!(result.exit_code, 0);
339 }
340}