Skip to main content

mk_rs_shell/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Concrete [`Shell`] implementations for mk-core.
4//!
5//! Provides `ShShell` (POSIX `/bin/sh -ec`), `CustomShell` (user-configurable
6//! via `$MKSHELL`), and `DuckShell` (embedded duckscript, feature-gated behind
7//! the `duckscript` feature). All implement the [`mk_rs_core::shell::Shell`]
8//! trait.
9//!
10//! # Design
11//!
12//! **ShShell** wraps `std::process::Command` with `/bin/sh -ec`, passing
13//! environment variables via `cmd.env()`. Recipe text is supplied as the `-c`
14//! argument.
15//!
16//! **Quote escaping.** sh single-quotes with `'...'` (embedded `'` escaped
17//! as `'\''`). rc was originally planned but is not shipped — sh +
18//! `$MKSHELL` covers all practical use cases, and rc would require an `rc`
19//! binary on the host.
20//!
21//! **`find_unescaped`** scans for a character outside of shell quotes.
22//! Used by the attribute parser in mk-core to detect quoted `=` characters
23//! in assignment values. The escaping rules are shell-specific (backslash in
24//! sh, double-quote in rc).
25//!
26//! [`mk_rs_core::shell::Shell`]: ../mk_rs_core/shell/trait.Shell.html
27
28use mk_rs_core::shell::{Shell, ShellError, ShellResult};
29use std::collections::HashMap;
30use std::path::Path;
31use std::process::Command;
32
33/// POSIX /bin/sh shell implementation.
34#[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") // exit on first error
50            .arg("-c") // read command from argument
51            .arg(recipe)
52            .current_dir(dir);
53
54        // Clear and set environment
55        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                    // Backslash escapes next char in sh
83                    i += 2; // skip both
84                    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        // sh quoting: wrap in single quotes, escape embedded single quotes as '\''
104        if token.is_empty() {
105            return "''".to_string();
106        }
107        if !token.contains('\'') {
108            return format!("'{}'", token);
109        }
110        // Contains single quotes: break out of quoting, insert escaped quote
111        let escaped = token.replace('\'', "'\\''");
112        format!("'{}'", escaped)
113    }
114}
115
116// ── Custom shell (MKSHELL) ─────────────────────────────────────────────────
117
118/// Custom shell that uses the command from $MKSHELL.
119/// E.g., MKSHELL=/bin/bash → runs /bin/bash -ec `<recipe placeholder>`
120#[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 no flags specified, default to -c (POSIX shell convention)
152        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) // same quoting as sh
177    }
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] // node path depends on environment (nvm)
200    fn custom_shell_node() {
201        // MKSHELL=node -e → runs node -e "recipe"
202        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        // MKSHELL=/bin/sh → defaults to /bin/sh -c "recipe"
213        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// ── duckscript shell ────────────────────────────────────────────────────────
222
223/// duckscript embedded shell implementation.
224#[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        // Load all env vars into duckscript context
242        for (k, v) in env {
243            context.variables.insert(k.clone(), v.clone());
244        }
245        // Load SDK commands (exec, cp, mv, mkdir, etc.)
246        duckscriptsdk::load(&mut context.commands)
247            .map_err(|e| ShellError::Io(std::io::Error::other(e.to_string())))?;
248
249        // Set working directory
250        std::env::set_current_dir(dir).map_err(ShellError::Io)?;
251
252        // Run script
253        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        // duckscript doesn't have shell quoting — simple scan
265        input.match_indices(ch).map(|(i, _)| i).collect()
266    }
267
268    fn quote(&self, token: &str) -> String {
269        token.to_string() // duckscript doesn't need shell quoting
270    }
271}
272
273// ── tests ──────────────────────────────────────────────────────────────────
274
275#[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        // Recipe output goes to terminal (stdout inherited), not captured
307        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        // "CC=gcc" → '=' at position 2
315        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        // "foo '=' bar" → the '=' inside quotes is ignored
323        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        // "foo \\= bar" → escaped = is ignored
331        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        // Recipe output goes to terminal (status() inherits stdout).
355        // ShellResult.stdout/stderr should be empty.
356        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}