Skip to main content

wasi_shell/
lib.rs

1use std::collections::HashMap;
2use std::env;
3use std::fs::File;
4use std::io::{self, Read, Write};
5use std::sync::{Arc, Mutex};
6use colored::*;
7pub use wasibox_core::IoContext;
8
9// ---------------------------------------------------------------------------
10// CommandRegistry
11// ---------------------------------------------------------------------------
12
13/// A function that handles a shell command.
14///
15/// Receives the full argument list (argv\[0\] = command name) and an I/O context
16/// whose stdin/stdout/stderr are already wired to the pipeline.
17pub type CommandFn = Arc<dyn Fn(&[String], &mut IoContext) -> Result<(), String> + Send + Sync>;
18
19/// Registry of named commands.
20///
21/// Commands registered here are used by [`handle_pipeline`] and
22/// [`handle_parallel`] to dispatch each stage of a pipeline.
23///
24/// # Examples
25///
26/// ```ignore
27/// use wasi_shell::{CommandRegistry, handle_pipeline, IoContext};
28/// use std::io::{self, Write};
29///
30/// let mut reg = CommandRegistry::with_builtins();
31///
32/// // Add a custom command
33/// reg.register("greet", |args, ctx| {
34///     let name = args.get(1).map(|s| s.as_str()).unwrap_or("world");
35///     writeln!(ctx.stdout, "Hello, {}!", name).map_err(|e| e.to_string())
36/// });
37///
38/// handle_pipeline("greet Rust", Box::new(io::empty()), Box::new(io::stdout()), &reg).unwrap();
39/// ```
40pub struct CommandRegistry {
41    commands: HashMap<String, CommandFn>,
42    fallback: Option<CommandFn>,
43}
44
45impl CommandRegistry {
46    /// Create an empty registry **with** a `wasibox_core` fallback.
47    ///
48    /// Any command name not explicitly registered will be forwarded to
49    /// `wasibox_core::execute_with_context`.
50    pub fn new() -> Self {
51        Self {
52            commands: HashMap::new(),
53            fallback: Some(Arc::new(|args: &[String], ctx: &mut IoContext| {
54                wasibox_core::execute_with_context(args.iter().cloned(), ctx)
55            })),
56        }
57    }
58
59    /// Create a registry pre-loaded with shell built-in commands
60    /// (`cd`, `help`, `exit`, `sl`) and a `wasibox_core` fallback for all
61    /// core utilities (echo, cat, grep, seq, head, …).
62    pub fn with_builtins() -> Self {
63        let mut reg = Self::new();
64
65        reg.register("help", |_args, ctx| {
66            writeln!(ctx.stdout, "{}", "Available Commands:".yellow().bold()).map_err(|e| e.to_string())?;
67            
68            let mut shell_builtins = vec!["cd", "help", "exit"];
69            #[cfg(feature = "clear")] shell_builtins.push("clear");
70            shell_builtins.sort();
71            writeln!(ctx.stdout, "  Shell Built-ins: {}", shell_builtins.join(", ")).map_err(|e| e.to_string())?;
72
73            #[cfg(feature = "sl")]
74            writeln!(ctx.stdout, "  Animations: sl").map_err(|e| e.to_string())?;
75            
76            let mut utils = Vec::new();
77            #[cfg(feature = "arch")] utils.push("arch");
78            #[cfg(feature = "basename")] utils.push("basename");
79            #[cfg(feature = "cat")] utils.push("cat");
80            #[cfg(feature = "cp")] utils.push("cp");
81            #[cfg(feature = "dir")] utils.push("dir");
82            #[cfg(feature = "dirname")] utils.push("dirname");
83            #[cfg(feature = "echo")] utils.push("echo");
84            #[cfg(feature = "env")] utils.push("env");
85            #[cfg(feature = "false")] utils.push("false");
86            #[cfg(feature = "grep")] utils.push("grep");
87            #[cfg(feature = "head")] utils.push("head");
88            #[cfg(feature = "link")] utils.push("link");
89            #[cfg(feature = "ln")] utils.push("ln");
90            #[cfg(feature = "ls")] utils.push("ls");
91            #[cfg(feature = "mkdir")] utils.push("mkdir");
92            #[cfg(feature = "mv")] utils.push("mv");
93            #[cfg(feature = "pwd")] utils.push("pwd");
94            #[cfg(feature = "rm")] utils.push("rm");
95            #[cfg(feature = "rmdir")] utils.push("rmdir");
96            #[cfg(feature = "seq")] utils.push("seq");
97            #[cfg(feature = "sleep")] utils.push("sleep");
98            #[cfg(feature = "tail")] utils.push("tail");
99            #[cfg(feature = "tee")] utils.push("tee");
100            #[cfg(feature = "touch")] utils.push("touch");
101            #[cfg(feature = "tree")] utils.push("tree");
102            #[cfg(feature = "true")] utils.push("true");
103            #[cfg(feature = "uname")] utils.push("uname");
104            #[cfg(feature = "unlink")] utils.push("unlink");
105            #[cfg(feature = "wc")] utils.push("wc");
106            #[cfg(feature = "whoami")] utils.push("whoami");
107            #[cfg(feature = "yes")] utils.push("yes");
108            
109            if !utils.is_empty() {
110                utils.sort();
111                writeln!(ctx.stdout, "  Core Utilities: {}", utils.join(", ")).map_err(|e| e.to_string())?;
112            }
113            Ok(())
114        });
115
116        reg.register("cd", |args, _ctx| {
117            let new_dir = args.get(1).map(|s| s.as_str()).unwrap_or(".");
118            env::set_current_dir(new_dir).map_err(|e| format!("cd: {}", e))
119        });
120
121        reg.register("exit", |_args, _ctx| Ok(()));
122
123        #[cfg(feature = "clear")]
124        reg.register("clear", |_args, ctx| {
125            write!(ctx.stdout, "\x1B[2J\x1B[1;1H").map_err(|e| e.to_string())?;
126            ctx.stdout.flush().map_err(|e| e.to_string())
127        });
128
129        #[cfg(feature = "sl")]
130        reg.register("sl", |args, _ctx| {
131            let _ = sl::run(args.iter().cloned());
132            Ok(())
133        });
134
135        // Register coreutils from wasibox-core
136        #[cfg(feature = "arch")]
137        reg.register("arch", |args, ctx| wasibox_core::utils::arch::execute_with_context(args.iter().cloned(), ctx));
138        #[cfg(feature = "basename")]
139        reg.register("basename", |args, ctx| wasibox_core::utils::basename::execute_with_context(args.iter().cloned(), ctx));
140        #[cfg(feature = "cat")]
141        reg.register("cat", |args, ctx| wasibox_core::utils::cat::execute_with_context(args.iter().cloned(), ctx));
142        #[cfg(feature = "cp")]
143        reg.register("cp", |args, ctx| wasibox_core::utils::cp::execute_with_context(args.iter().cloned(), ctx));
144        #[cfg(feature = "dir")]
145        reg.register("dir", |args, ctx| wasibox_core::utils::dir::execute_with_context(args.iter().cloned(), ctx));
146        #[cfg(feature = "dirname")]
147        reg.register("dirname", |args, ctx| wasibox_core::utils::dirname::execute_with_context(args.iter().cloned(), ctx));
148        #[cfg(feature = "echo")]
149        reg.register("echo", |args, ctx| wasibox_core::utils::echo::execute_with_context(args.iter().cloned(), ctx));
150        #[cfg(feature = "env")]
151        reg.register("env", |args, ctx| wasibox_core::utils::env::execute_with_context(args.iter().cloned(), ctx));
152        #[cfg(feature = "false")]
153        reg.register("false", |args, ctx| wasibox_core::utils::r#false::execute_with_context(args.iter().cloned(), ctx));
154        #[cfg(feature = "grep")]
155        reg.register("grep", |args, ctx| wasibox_core::utils::grep::execute_with_context(args.iter().cloned(), ctx));
156        #[cfg(feature = "head")]
157        reg.register("head", |args, ctx| wasibox_core::utils::head::execute_with_context(args.iter().cloned(), ctx));
158        #[cfg(feature = "link")]
159        reg.register("link", |args, ctx| wasibox_core::utils::link::execute_with_context(args.iter().cloned(), ctx));
160        #[cfg(feature = "ln")]
161        reg.register("ln", |args, ctx| wasibox_core::utils::ln::execute_with_context(args.iter().cloned(), ctx));
162        #[cfg(feature = "ls")]
163        reg.register("ls", |args, ctx| wasibox_core::utils::ls::execute_with_context(args.iter().cloned(), ctx));
164        #[cfg(feature = "mkdir")]
165        reg.register("mkdir", |args, ctx| wasibox_core::utils::mkdir::execute_with_context(args.iter().cloned(), ctx));
166        #[cfg(feature = "mv")]
167        reg.register("mv", |args, ctx| wasibox_core::utils::mv::execute_with_context(args.iter().cloned(), ctx));
168        #[cfg(feature = "pwd")]
169        reg.register("pwd", |args, ctx| wasibox_core::utils::pwd::execute_with_context(args.iter().cloned(), ctx));
170        #[cfg(feature = "rm")]
171        reg.register("rm", |args, ctx| wasibox_core::utils::rm::execute_with_context(args.iter().cloned(), ctx));
172        #[cfg(feature = "rmdir")]
173        reg.register("rmdir", |args, ctx| wasibox_core::utils::rmdir::execute_with_context(args.iter().cloned(), ctx));
174        #[cfg(feature = "seq")]
175        reg.register("seq", |args, ctx| wasibox_core::utils::seq::execute_with_context(args.iter().cloned(), ctx));
176        #[cfg(feature = "sleep")]
177        reg.register("sleep", |args, ctx| wasibox_core::utils::sleep::execute_with_context(args.iter().cloned(), ctx));
178        #[cfg(feature = "tail")]
179        reg.register("tail", |args, ctx| wasibox_core::utils::tail::execute_with_context(args.iter().cloned(), ctx));
180        #[cfg(feature = "tee")]
181        reg.register("tee", |args, ctx| wasibox_core::utils::tee::execute_with_context(args.iter().cloned(), ctx));
182        #[cfg(feature = "touch")]
183        reg.register("touch", |args, ctx| wasibox_core::utils::touch::execute_with_context(args.iter().cloned(), ctx));
184        #[cfg(feature = "tree")]
185        reg.register("tree", |args, ctx| wasibox_core::utils::tree::execute_with_context(args.iter().cloned(), ctx));
186        #[cfg(feature = "true")]
187        reg.register("true", |args, ctx| wasibox_core::utils::r#true::execute_with_context(args.iter().cloned(), ctx));
188        #[cfg(feature = "uname")]
189        reg.register("uname", |args, ctx| wasibox_core::utils::uname::execute_with_context(args.iter().cloned(), ctx));
190        #[cfg(feature = "unlink")]
191        reg.register("unlink", |args, ctx| wasibox_core::utils::unlink::execute_with_context(args.iter().cloned(), ctx));
192        #[cfg(feature = "wc")]
193        reg.register("wc", |args, ctx| wasibox_core::utils::wc::execute_with_context(args.iter().cloned(), ctx));
194        #[cfg(feature = "whoami")]
195        reg.register("whoami", |args, ctx| wasibox_core::utils::whoami::execute_with_context(args.iter().cloned(), ctx));
196        #[cfg(feature = "yes")]
197        reg.register("yes", |args, ctx| wasibox_core::utils::yes::execute_with_context(args.iter().cloned(), ctx));
198
199        reg
200    }
201
202    /// Register (or replace) a command.
203    pub fn register<F>(&mut self, name: impl Into<String>, handler: F)
204    where
205        F: Fn(&[String], &mut IoContext) -> Result<(), String> + Send + Sync + 'static,
206    {
207        self.commands.insert(name.into(), Arc::new(handler));
208    }
209
210    /// Set a fallback handler invoked when no explicit command matches.
211    pub fn set_fallback<F>(&mut self, handler: F)
212    where
213        F: Fn(&[String], &mut IoContext) -> Result<(), String> + Send + Sync + 'static,
214    {
215        self.fallback = Some(Arc::new(handler));
216    }
217
218    /// Remove the fallback handler. Unknown commands will return an error.
219    pub fn remove_fallback(&mut self) {
220        self.fallback = None;
221    }
222
223    /// Look up and execute a command.
224    pub fn execute(&self, args: &[String], ctx: &mut IoContext) -> Result<(), String> {
225        if args.is_empty() {
226            return Ok(());
227        }
228        let cmd = &args[0];
229
230        if let Some(handler) = self.commands.get(cmd.as_str()) {
231            return handler(args, ctx);
232        }
233        if let Some(ref fallback) = self.fallback {
234            return fallback(args, ctx);
235        }
236        Err(format!("command not found: {}", cmd))
237    }
238}
239
240impl Default for CommandRegistry {
241    fn default() -> Self {
242        Self::new()
243    }
244}
245
246// ---------------------------------------------------------------------------
247// Pipeline execution
248// ---------------------------------------------------------------------------
249
250/// Check if an error string represents a BrokenPipe (normal pipeline termination).
251fn is_broken_pipe(err: &str) -> bool {
252    err.contains("Broken pipe")
253        || err.contains("BrokenPipe")
254        || err.contains("broken pipe")
255}
256
257/// Execute a shell pipeline (commands separated by `|`).
258///
259/// Each stage runs in its own thread; stages are connected by in-process
260/// pipes. The last stage supports `>` / `>>` redirection.
261pub fn handle_pipeline(
262    line: &str,
263    initial_stdin: Box<dyn Read + Send + 'static>,
264    final_stdout: Box<dyn Write + Send + 'static>,
265    registry: &CommandRegistry,
266) -> Result<(), String> {
267    let stages_str: Vec<&str> = line.split('|').collect();
268
269    let mut final_stdout = Some(final_stdout);
270
271    std::thread::scope(|s| {
272        let mut prev_reader: Box<dyn Read + Send> = initial_stdin;
273        let mut threads = Vec::new();
274
275        for (i, stage_str) in stages_str.iter().enumerate() {
276            let is_last = i == stages_str.len() - 1;
277            let mut tokens = shlex::split(stage_str.trim())
278                .ok_or_else(|| "Error: Invalid input (quoting error)".to_string())?;
279
280            let stdin = std::mem::replace(&mut prev_reader, Box::new(io::empty()));
281            let (stdout, next_reader): (Box<dyn Write + Send>, Option<Box<dyn Read + Send>>) = if is_last {
282                let mut out: Box<dyn Write + Send> = final_stdout.take().unwrap();
283                if let Some(pos) = tokens.iter().position(|t| t == ">" || t == ">>") {
284                    let append = tokens[pos] == ">>";
285                    let filename = tokens.get(pos + 1).ok_or("Error: Missing file for redirection")?;
286                    let file = if append {
287                        std::fs::OpenOptions::new().create(true).append(true).open(filename)
288                    } else {
289                        File::create(filename)
290                    }.map_err(|e| format!("Error opening file: {}", e))?;
291                    out = Box::new(file);
292                    tokens.truncate(pos);
293                }
294                (out, None)
295            } else {
296                let (reader, writer) = create_pipe();
297                (Box::new(writer), Some(Box::new(reader)))
298            };
299
300            if let Some(r) = next_reader {
301                prev_reader = r;
302            }
303
304            let thread = s.spawn(move || {
305                let mut ctx = IoContext::new(stdin, stdout, Box::new(io::stderr()));
306                registry.execute(&tokens, &mut ctx)
307            });
308            threads.push((i, thread));
309        }
310
311        // Collect results: BrokenPipe in non-last stages is normal
312        // (upstream terminated by downstream closing the pipe).
313        let last_idx = stages_str.len() - 1;
314        let mut final_res = Ok(());
315        for (idx, thread) in threads {
316            match thread.join() {
317                Ok(Ok(())) => {}
318                Ok(Err(e)) => {
319                    if idx != last_idx && is_broken_pipe(&e) {
320                        continue;
321                    }
322                    if final_res.is_ok() {
323                        final_res = Err(e);
324                    }
325                }
326                Err(_) => {
327                    if final_res.is_ok() {
328                        final_res = Err("Thread panicked".to_string());
329                    }
330                }
331            }
332        }
333        final_res
334    })
335}
336
337/// Execute multiple independent command lines in parallel.
338///
339/// The first line receives `initial_stdin` / `final_stdout`; all subsequent
340/// lines use `io::empty()` / `io::stdout()`.
341pub fn handle_parallel(
342    lines: Vec<String>,
343    initial_stdin: Box<dyn Read + Send + 'static>,
344    final_stdout: Box<dyn Write + Send + 'static>,
345    registry: Arc<CommandRegistry>,
346) -> Vec<Result<(), String>> {
347    let mut handles = Vec::new();
348    let mut stdin_opt: Option<Box<dyn Read + Send>> = Some(initial_stdin);
349    let mut stdout_opt: Option<Box<dyn Write + Send>> = Some(final_stdout);
350
351    for line in lines {
352        let registry = Arc::clone(&registry);
353        let stdin: Box<dyn Read + Send> = stdin_opt.take().unwrap_or_else(|| Box::new(io::empty()));
354        let stdout: Box<dyn Write + Send> = stdout_opt.take().unwrap_or_else(|| Box::new(io::stdout()));
355        let handle = std::thread::spawn(move || {
356            handle_pipeline(&line, stdin, stdout, &*registry)
357        });
358        handles.push(handle);
359    }
360
361    handles.into_iter()
362        .map(|h| h.join().unwrap_or(Err("Thread panicked".to_string())))
363        .collect()
364}
365
366// ---------------------------------------------------------------------------
367// Pipe implementation
368// ---------------------------------------------------------------------------
369
370struct PipeReader {
371    rx: std::sync::mpsc::Receiver<Vec<u8>>,
372    buffer: Vec<u8>,
373    pos: usize,
374}
375impl Read for PipeReader {
376    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
377        if self.pos >= self.buffer.len() {
378            match self.rx.recv() {
379                Ok(data) => {
380                    self.buffer = data;
381                    self.pos = 0;
382                }
383                Err(_) => return Ok(0), // Writer dropped = EOF
384            }
385        }
386        let available = self.buffer.len() - self.pos;
387        let to_copy = std::cmp::min(available, buf.len());
388        buf[..to_copy].copy_from_slice(&self.buffer[self.pos..self.pos + to_copy]);
389        self.pos += to_copy;
390        Ok(to_copy)
391    }
392}
393
394struct PipeWriter {
395    tx: std::sync::mpsc::Sender<Vec<u8>>,
396}
397impl Write for PipeWriter {
398    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
399        self.tx.send(buf.to_vec()).map_err(|e| io::Error::new(io::ErrorKind::BrokenPipe, e))?;
400        Ok(buf.len())
401    }
402    fn flush(&mut self) -> io::Result<()> { Ok(()) }
403}
404
405fn create_pipe() -> (PipeReader, PipeWriter) {
406    let (tx, rx) = std::sync::mpsc::channel();
407    (PipeReader { rx, buffer: Vec::new(), pos: 0 }, PipeWriter { tx })
408}
409
410// ---------------------------------------------------------------------------
411// Utility types
412// ---------------------------------------------------------------------------
413
414/// A thread-safe writer backed by a shared `Vec<u8>`. Useful for tests.
415pub struct ArcVecWriter {
416    pub inner: Arc<Mutex<Vec<u8>>>,
417}
418impl Write for ArcVecWriter {
419    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
420        let mut inner = self.inner.lock().map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
421        inner.extend_from_slice(buf);
422        Ok(buf.len())
423    }
424    fn flush(&mut self) -> io::Result<()> { Ok(()) }
425}
426
427// ---------------------------------------------------------------------------
428// Tests
429// ---------------------------------------------------------------------------
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use std::io::{BufRead, BufReader, Cursor};
435
436    fn get_temp_dir() -> tempfile::TempDir {
437        #[cfg(target_os = "wasi")]
438        {
439            tempfile::Builder::new()
440                .prefix("test_")
441                .tempdir_in(".")
442                .expect("Failed to create temp dir in current directory")
443        }
444        #[cfg(not(target_os = "wasi"))]
445        {
446            tempfile::tempdir().expect("Failed to create system temp dir")
447        }
448    }
449
450    /// Shorthand: registry with all builtins (shell + wasibox-core)
451    fn builtins() -> CommandRegistry {
452        CommandRegistry::with_builtins()
453    }
454
455    // ── basic pipeline tests ────────────────────────────────────────────
456
457    #[test]
458    fn test_simple_echo() {
459        let out = Arc::new(Mutex::new(Vec::new()));
460        handle_pipeline("echo hello", Box::new(Cursor::new("")), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins()).unwrap();
461        let buf = out.lock().unwrap();
462        assert_eq!(String::from_utf8_lossy(&buf).trim(), "hello");
463    }
464
465    #[test]
466    fn test_pipe_echo_cat() {
467        let out = Arc::new(Mutex::new(Vec::new()));
468        handle_pipeline("echo hello | cat", Box::new(Cursor::new("")), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins()).unwrap();
469        let buf = out.lock().unwrap();
470        assert_eq!(String::from_utf8_lossy(&buf).trim(), "hello");
471    }
472
473    #[test]
474    fn test_pipe_grep() {
475        let out = Arc::new(Mutex::new(Vec::new()));
476        handle_pipeline("echo world | grep world", Box::new(Cursor::new("")), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins()).unwrap();
477        let buf = out.lock().unwrap();
478        assert_eq!(String::from_utf8_lossy(&buf).trim(), "world");
479    }
480
481    #[test]
482    fn test_redirection_create() {
483        let dir = get_temp_dir();
484        let file_path = dir.path().join("test_create.txt");
485        let cmd = format!("echo hello > \"{}\"", file_path.display());
486        handle_pipeline(&cmd, Box::new(Cursor::new("")), Box::new(io::sink()), &builtins()).unwrap();
487        let content = std::fs::read_to_string(file_path).unwrap();
488        assert_eq!(content.trim(), "hello");
489    }
490
491    #[test]
492    fn test_redirection_append() {
493        let dir = get_temp_dir();
494        let file_path = dir.path().join("test_append.txt");
495        let cmd1 = format!("echo hello > \"{}\"", file_path.display());
496        handle_pipeline(&cmd1, Box::new(Cursor::new("")), Box::new(io::sink()), &builtins()).unwrap();
497        let cmd2 = format!("echo world >> \"{}\"", file_path.display());
498        handle_pipeline(&cmd2, Box::new(Cursor::new("")), Box::new(io::sink()), &builtins()).unwrap();
499        let content = std::fs::read_to_string(file_path).unwrap();
500        assert!(content.contains("hello"));
501        assert!(content.contains("world"));
502    }
503
504    #[test]
505    fn test_complex_pipeline() {
506        let out = Arc::new(Mutex::new(Vec::new()));
507        handle_pipeline("echo hello | grep h | wc -c", Box::new(Cursor::new("")), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins()).unwrap();
508        let buf = out.lock().unwrap();
509        assert_eq!(String::from_utf8_lossy(&buf).trim(), "6");
510    }
511
512    // ── custom command tests ────────────────────────────────────────────
513
514    #[test]
515    fn test_custom_command() {
516        let mut reg = CommandRegistry::with_builtins();
517        reg.register("magic", |_args, ctx| {
518            write!(ctx.stdout, "magic happen").map_err(|e| e.to_string())
519        });
520
521        let out = Arc::new(Mutex::new(Vec::new()));
522        handle_pipeline("magic", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &reg).unwrap();
523        let buf = out.lock().unwrap();
524        assert_eq!(String::from_utf8_lossy(&buf), "magic happen");
525    }
526
527    #[test]
528    fn test_custom_command_in_pipeline() {
529        // Custom "double" command that duplicates each input line
530        let mut reg = CommandRegistry::with_builtins();
531        reg.register("double", |_args, ctx| {
532            for line in BufReader::new(&mut ctx.stdin).lines() {
533                let line = match line {
534                    Ok(l) => l,
535                    Err(_) => break,
536                };
537                if writeln!(ctx.stdout, "{}", line).is_err() { break; }
538                if writeln!(ctx.stdout, "{}", line).is_err() { break; }
539            }
540            Ok(())
541        });
542
543        let out = Arc::new(Mutex::new(Vec::new()));
544        handle_pipeline("echo hello | double", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &reg).unwrap();
545        let buf = out.lock().unwrap();
546        assert_eq!(String::from_utf8_lossy(&buf).trim(), "hello\nhello");
547    }
548
549    #[test]
550    fn test_override_builtin() {
551        // Override "echo" with a custom version
552        let mut reg = CommandRegistry::with_builtins();
553        reg.register("echo", |args, ctx| {
554            let msg = args[1..].join(" ").to_uppercase();
555            writeln!(ctx.stdout, "{}", msg).map_err(|e| e.to_string())
556        });
557
558        let out = Arc::new(Mutex::new(Vec::new()));
559        handle_pipeline("echo hello", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &reg).unwrap();
560        let buf = out.lock().unwrap();
561        assert_eq!(String::from_utf8_lossy(&buf).trim(), "HELLO");
562    }
563
564    // ── fully external pipeline (no builtins at all) ────────────────────
565
566    #[test]
567    fn test_all_external_pipeline() {
568        let mut reg = CommandRegistry::new();
569        reg.remove_fallback(); // no wasibox-core fallback
570
571        // "count" — infinite counter
572        reg.register("count", |_args, ctx| {
573            for i in 1u64.. {
574                if writeln!(ctx.stdout, "{}", i).is_err() { break; }
575            }
576            Ok(())
577        });
578
579        // "filter2" — keep lines containing '2'
580        reg.register("filter2", |_args, ctx| {
581            for line in BufReader::new(&mut ctx.stdin).lines() {
582                let line = match line {
583                    Ok(l) => l,
584                    Err(_) => break,
585                };
586                if line.contains('2') {
587                    if writeln!(ctx.stdout, "{}", line).is_err() { break; }
588                }
589            }
590            Ok(())
591        });
592
593        // "take N" — first N lines
594        reg.register("take", |args, ctx| {
595            let n: usize = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(5);
596            for (i, line) in BufReader::new(&mut ctx.stdin).lines().enumerate() {
597                if i >= n { break; }
598                let line = match line {
599                    Ok(l) => l,
600                    Err(_) => break,
601                };
602                if writeln!(ctx.stdout, "{}", line).is_err() { break; }
603            }
604            Ok(())
605        });
606
607        let out = Arc::new(Mutex::new(Vec::new()));
608        handle_pipeline(
609            "count | filter2 | take 3",
610            Box::new(io::empty()),
611            Box::new(ArcVecWriter { inner: Arc::clone(&out) }),
612            &reg,
613        ).unwrap();
614
615        let buf = out.lock().unwrap();
616        let result = String::from_utf8_lossy(&buf);
617        let lines: Vec<&str> = result.trim().lines().collect();
618        assert_eq!(lines.len(), 3);
619        for line in &lines {
620            assert!(line.contains('2'), "expected '2' in line, got: {}", line);
621        }
622        assert_eq!(lines[0], "2");
623        assert_eq!(lines[1], "12");
624        assert_eq!(lines[2], "20");
625    }
626
627    // ── parallel tests ──────────────────────────────────────────────────
628
629    #[test]
630    fn test_parallel_execution() {
631        let mut reg = CommandRegistry::with_builtins();
632        reg.register("slow", |_args, _ctx| {
633            std::thread::sleep(std::time::Duration::from_millis(100));
634            Ok(())
635        });
636        let registry = Arc::new(reg);
637
638        let lines = vec!["slow".to_string(), "slow".to_string(), "slow".to_string()];
639        let start = std::time::Instant::now();
640        let results = handle_parallel(lines, Box::new(io::empty()), Box::new(io::sink()), registry);
641        let duration = start.elapsed();
642
643        assert_eq!(results.len(), 3);
644        for res in results {
645            assert!(res.is_ok());
646        }
647        assert!(duration < std::time::Duration::from_millis(250));
648    }
649
650    // ── streaming / infinite pipeline tests ─────────────────────────────
651
652    #[test]
653    fn test_streaming_pipeline() {
654        let out = Arc::new(Mutex::new(Vec::new()));
655        handle_pipeline("yes | head -n 2", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins()).unwrap();
656        let buf = out.lock().unwrap();
657        assert_eq!(String::from_utf8_lossy(&buf).trim(), "y\ny");
658    }
659
660    #[test]
661    fn test_seq_pipeline_head() {
662        let out = Arc::new(Mutex::new(Vec::new()));
663        handle_pipeline("seq | head -n 5", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins()).unwrap();
664        let buf = out.lock().unwrap();
665        assert_eq!(String::from_utf8_lossy(&buf).trim(), "1\n2\n3\n4\n5");
666    }
667
668    #[test]
669    fn test_seq_pipeline_grep_head() {
670        let out = Arc::new(Mutex::new(Vec::new()));
671        handle_pipeline("seq | grep 2 | head -n 3", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins()).unwrap();
672        let buf = out.lock().unwrap();
673        let result = String::from_utf8_lossy(&buf);
674        let lines: Vec<&str> = result.trim().lines().collect();
675        assert_eq!(lines.len(), 3);
676        for line in &lines {
677            assert!(line.contains('2'));
678        }
679        assert_eq!(lines[0], "2");
680        assert_eq!(lines[1], "12");
681        assert_eq!(lines[2], "20");
682    }
683
684    #[test]
685    fn test_seq_finite() {
686        let out = Arc::new(Mutex::new(Vec::new()));
687        handle_pipeline("seq 3", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins()).unwrap();
688        let buf = out.lock().unwrap();
689        assert_eq!(String::from_utf8_lossy(&buf).trim(), "1\n2\n3");
690    }
691
692    #[test]
693    fn test_seq_range() {
694        let out = Arc::new(Mutex::new(Vec::new()));
695        handle_pipeline("seq 5 8", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins()).unwrap();
696        let buf = out.lock().unwrap();
697        assert_eq!(String::from_utf8_lossy(&buf).trim(), "5\n6\n7\n8");
698    }
699
700    #[test]
701    fn test_seq_step() {
702        let out = Arc::new(Mutex::new(Vec::new()));
703        handle_pipeline("seq 1 2 10", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins()).unwrap();
704        let buf = out.lock().unwrap();
705        assert_eq!(String::from_utf8_lossy(&buf).trim(), "1\n3\n5\n7\n9");
706    }
707    #[test]
708    fn test_cd_and_ls() {
709        let dir = get_temp_dir();
710        // Create files inside the temp dir
711        std::fs::write(dir.path().join("aaa.txt"), "hello").unwrap();
712        std::fs::write(dir.path().join("bbb.txt"), "world").unwrap();
713
714        let original_cwd = env::current_dir().unwrap();
715        let reg = builtins();
716
717        // cd into the temp directory
718        let cd_cmd = format!("cd \"{}\"", dir.path().display());
719        handle_pipeline(&cd_cmd, Box::new(io::empty()), Box::new(io::sink()), &reg).unwrap();
720
721        // ls the current directory (should now be the temp dir)
722        let out = Arc::new(Mutex::new(Vec::new()));
723        handle_pipeline("ls", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &reg).unwrap();
724
725        let buf = out.lock().unwrap();
726        let output = String::from_utf8_lossy(&buf);
727        assert!(output.contains("aaa.txt"), "expected aaa.txt in ls output, got: {}", output);
728        assert!(output.contains("bbb.txt"), "expected bbb.txt in ls output, got: {}", output);
729
730        // Restore original cwd
731        env::set_current_dir(original_cwd).unwrap();
732    }
733}