1#![forbid(unsafe_code)]
2
3use mk_rs_core::shell::{Shell, ShellError, ShellResult};
29use std::collections::HashMap;
30use std::path::Path;
31use std::process::Command;
32
33#[derive(Debug, Clone)]
35pub struct ShShell;
36
37impl Shell for ShShell {
38 fn name(&self) -> &str {
39 "sh"
40 }
41
42 fn execute(
43 &self,
44 recipe: &str,
45 env: &HashMap<String, String>,
46 dir: &Path,
47 ) -> Result<ShellResult, ShellError> {
48 let mut cmd = Command::new("/bin/sh");
49 cmd.arg("-e") .arg("-c") .arg(recipe)
52 .current_dir(dir);
53
54 cmd.env_clear();
56 for (k, v) in env {
57 cmd.env(k, v);
58 }
59 if !env.contains_key("PATH") {
60 cmd.env("PATH", "/usr/local/bin:/usr/bin:/bin");
61 }
62
63 let status = cmd.status()?;
64
65 Ok(ShellResult {
66 exit_code: status.code().unwrap_or(-1),
67 stdout: String::new(),
68 stderr: String::new(),
69 })
70 }
71
72 fn find_unescaped(&self, input: &str, ch: char) -> Vec<usize> {
73 let mut positions = Vec::new();
74 let bytes = input.as_bytes();
75 let mut in_single = false;
76 let mut in_double = false;
77 let mut i = 0;
78
79 while i < bytes.len() {
80 match bytes[i] {
81 b'\\' if !in_single => {
82 i += 2; continue;
85 }
86 b'\'' if !in_double => {
87 in_single = !in_single;
88 }
89 b'"' if !in_single => {
90 in_double = !in_double;
91 }
92 c if c == ch as u8 && !in_single && !in_double => {
93 positions.push(i);
94 }
95 _ => {}
96 }
97 i += 1;
98 }
99 positions
100 }
101
102 fn quote(&self, token: &str) -> String {
103 if token.is_empty() {
105 return "''".to_string();
106 }
107 if !token.contains('\'') {
108 return format!("'{}'", token);
109 }
110 let escaped = token.replace('\'', "'\\''");
112 format!("'{}'", escaped)
113 }
114}
115
116#[derive(Debug, Clone)]
121pub struct CustomShell {
122 cmd: String,
123}
124
125impl CustomShell {
126 pub fn new(cmd: &str) -> Self {
127 Self {
128 cmd: cmd.to_string(),
129 }
130 }
131}
132
133impl Shell for CustomShell {
134 fn name(&self) -> &str {
135 &self.cmd
136 }
137
138 fn execute(
139 &self,
140 recipe: &str,
141 env: &HashMap<String, String>,
142 dir: &Path,
143 ) -> Result<ShellResult, ShellError> {
144 let parts: Vec<&str> = self.cmd.split_whitespace().collect();
145 if parts.is_empty() {
146 return Err(ShellError::ShellNotFound {
147 name: "empty MKSHELL".into(),
148 });
149 }
150 let mut cmd = Command::new(parts[0]);
151 if parts.len() > 1 {
153 for arg in &parts[1..] {
154 cmd.arg(arg);
155 }
156 } else {
157 cmd.arg("-c");
158 }
159 cmd.arg(recipe).current_dir(dir);
160 cmd.env_clear();
161 for (k, v) in env {
162 cmd.env(k, v);
163 }
164 if !env.contains_key("PATH") {
165 cmd.env("PATH", "/usr/local/bin:/usr/bin:/bin");
166 }
167 let status = cmd.status()?;
168 Ok(ShellResult {
169 exit_code: status.code().unwrap_or(-1),
170 stdout: String::new(),
171 stderr: String::new(),
172 })
173 }
174
175 fn find_unescaped(&self, input: &str, ch: char) -> Vec<usize> {
176 ShShell.find_unescaped(input, ch) }
178
179 fn quote(&self, token: &str) -> String {
180 ShShell.quote(token)
181 }
182}
183
184#[cfg(test)]
185mod custom_shell_tests {
186 use super::*;
187
188 #[test]
189 fn custom_shell_bash() {
190 let shell = CustomShell::new("/bin/bash -c");
191 assert_eq!(shell.name(), "/bin/bash -c");
192 let result = shell
193 .execute("echo hello", &HashMap::new(), Path::new("."))
194 .unwrap();
195 assert_eq!(result.exit_code, 0);
196 }
197
198 #[test]
199 #[ignore] fn custom_shell_node() {
201 let shell = CustomShell::new("node -e");
203 assert_eq!(shell.name(), "node -e");
204 let result = shell
205 .execute("console.log('hello')", &HashMap::new(), Path::new("."))
206 .unwrap();
207 assert_eq!(result.exit_code, 0);
208 }
209
210 #[test]
211 fn custom_shell_no_flags_defaults_to_c() {
212 let shell = CustomShell::new("/bin/bash");
214 let result = shell
215 .execute("echo hi", &HashMap::new(), Path::new("."))
216 .unwrap();
217 assert_eq!(result.exit_code, 0);
218 }
219}
220
221#[cfg(feature = "duckscript")]
225#[derive(Debug, Clone)]
226pub struct DuckShell;
227
228#[cfg(feature = "duckscript")]
229impl Shell for DuckShell {
230 fn name(&self) -> &str {
231 "duckscript"
232 }
233
234 fn execute(
235 &self,
236 recipe: &str,
237 env: &HashMap<String, String>,
238 dir: &Path,
239 ) -> Result<ShellResult, ShellError> {
240 let mut context = duckscript::types::runtime::Context::new();
241 for (k, v) in env {
243 context.variables.insert(k.clone(), v.clone());
244 }
245 duckscriptsdk::load(&mut context.commands)
247 .map_err(|e| ShellError::Io(std::io::Error::other(e.to_string())))?;
248
249 std::env::set_current_dir(dir).map_err(ShellError::Io)?;
251
252 duckscript::runner::run_script(recipe, context, None)
254 .map_err(|e| ShellError::Io(std::io::Error::other(e.to_string())))?;
255
256 Ok(ShellResult {
257 exit_code: 0,
258 stdout: String::new(),
259 stderr: String::new(),
260 })
261 }
262
263 fn find_unescaped(&self, input: &str, ch: char) -> Vec<usize> {
264 input.match_indices(ch).map(|(i, _)| i).collect()
266 }
267
268 fn quote(&self, token: &str) -> String {
269 token.to_string() }
271}
272
273#[cfg(test)]
276mod tests {
277 use super::*;
278
279 #[test]
280 fn sh_shell_name() {
281 assert_eq!(ShShell.name(), "sh");
282 }
283
284 #[test]
285 fn execute_echo() {
286 let shell = ShShell;
287 let env = HashMap::new();
288 let result = shell.execute("echo hello", &env, Path::new(".")).unwrap();
289 assert_eq!(result.exit_code, 0);
290 assert_eq!(result.exit_code, 0);
291 }
292
293 #[test]
294 fn execute_error() {
295 let shell = ShShell;
296 let env = HashMap::new();
297 let result = shell.execute("exit 1", &env, Path::new(".")).unwrap();
298 assert_eq!(result.exit_code, 1);
299 }
300
301 #[test]
302 fn execute_with_env() {
303 let shell = ShShell;
304 let mut env = HashMap::new();
305 env.insert("MYVAR".into(), "myval".into());
306 let result = shell.execute("echo $MYVAR", &env, Path::new(".")).unwrap();
308 assert_eq!(result.exit_code, 0);
309 }
310
311 #[test]
312 fn find_unescaped_equal() {
313 let shell = ShShell;
314 let pos = shell.find_unescaped("CC=gcc", '=');
316 assert_eq!(pos, vec![2]);
317 }
318
319 #[test]
320 fn find_unescaped_ignores_quoted() {
321 let shell = ShShell;
322 let pos = shell.find_unescaped("foo '=' bar", '=');
324 assert!(pos.is_empty());
325 }
326
327 #[test]
328 fn find_unescaped_ignores_escaped() {
329 let shell = ShShell;
330 let pos = shell.find_unescaped("foo \\= bar", '=');
332 assert!(pos.is_empty());
333 }
334
335 #[test]
336 fn quote_simple() {
337 let shell = ShShell;
338 assert_eq!(shell.quote("hello"), "'hello'");
339 }
340
341 #[test]
342 fn quote_empty() {
343 assert_eq!(ShShell.quote(""), "''");
344 }
345
346 #[test]
347 fn quote_with_single_quote() {
348 let shell = ShShell;
349 assert_eq!(shell.quote("it's"), "'it'\\''s'");
350 }
351
352 #[test]
353 fn execute_stdout_inherited_not_captured() {
354 let shell = ShShell;
357 let env = HashMap::new();
358 let result = shell.execute("echo visible", &env, Path::new(".")).unwrap();
359 assert_eq!(result.exit_code, 0);
360 assert!(result.stdout.is_empty());
361 assert!(result.stderr.is_empty());
362 }
363
364 #[cfg(feature = "duckscript")]
365 #[test]
366 fn duck_shell_name() {
367 assert_eq!(DuckShell.name(), "duckscript");
368 }
369
370 #[cfg(feature = "duckscript")]
371 #[test]
372 fn duck_shell_execute_simple() {
373 let shell = DuckShell;
374 let env = HashMap::new();
375 let result = shell.execute("echo hello", &env, Path::new(".")).unwrap();
376 assert_eq!(result.exit_code, 0);
377 }
378}