Skip to main content

mk_rs_shell/
lib.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::process::Command;
4use mk_rs_core::shell::{Shell, ShellResult, ShellError};
5
6/// POSIX /bin/sh shell implementation.
7#[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")           // exit on first error
23           .arg("-c")           // read command from argument
24           .arg(recipe)
25           .current_dir(dir);
26
27        // Clear and set environment
28        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                    // Backslash escapes next char in sh
56                    i += 2; // skip both
57                    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        // sh quoting: wrap in single quotes, escape embedded single quotes as '\''
77        if token.is_empty() {
78            return "''".to_string();
79        }
80        if !token.contains('\'') {
81            return format!("'{}'", token);
82        }
83        // Contains single quotes: break out of quoting, insert escaped quote
84        let escaped = token.replace('\'', "'\\''");
85        format!("'{}'", escaped)
86    }
87}
88
89// ── Custom shell (MKSHELL) ─────────────────────────────────────────────────
90
91/// Custom shell that uses the command from $MKSHELL.
92/// E.g., MKSHELL=/bin/bash → runs /bin/bash -ec <recipe>
93#[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 no flags specified, default to -c (POSIX shell convention)
119        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) // same quoting as sh
144    }
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] // node path depends on environment (nvm)
165    fn custom_shell_node() {
166        // MKSHELL=node -e → runs node -e "recipe"
167        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        // MKSHELL=/bin/sh → defaults to /bin/sh -c "recipe"
176        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// ── duckscript shell ────────────────────────────────────────────────────────
183
184/// duckscript embedded shell implementation.
185#[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        // Load all env vars into duckscript context
203        for (k, v) in env {
204            context.variables.insert(k.clone(), v.clone());
205        }
206        // Load SDK commands (exec, cp, mv, mkdir, etc.)
207        duckscriptsdk::load(&mut context.commands)
208            .map_err(|e| ShellError::Io(std::io::Error::other(e.to_string())))?;
209
210        // Set working directory
211        std::env::set_current_dir(dir)
212            .map_err(ShellError::Io)?;
213
214        // Run script
215        duckscript::runner::run_script(recipe, context, None)
216            .map_err(|e| ShellError::Io(std::io::Error::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        // duckscript doesn't have shell quoting — simple scan
227        input.match_indices(ch).map(|(i, _)| i).collect()
228    }
229
230    fn quote(&self, token: &str) -> String {
231        token.to_string() // duckscript doesn't need shell quoting
232    }
233}
234
235// ── tests ──────────────────────────────────────────────────────────────────
236
237#[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        // Recipe output goes to terminal (stdout inherited), not captured
269        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        // "CC=gcc" → '=' at position 2
277        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        // "foo '=' bar" → the '=' inside quotes is ignored
285        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        // "foo \\= bar" → escaped = is ignored
293        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        // Recipe output goes to terminal (status() inherits stdout).
317        // ShellResult.stdout/stderr should be empty.
318        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}