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
9mod readline;
10pub use readline::*;
11
12// ---------------------------------------------------------------------------
13// CommandRegistry
14// ---------------------------------------------------------------------------
15
16/// A function that handles a shell command.
17///
18/// Receives the full argument list (argv\[0\] = command name) and an I/O context
19/// whose stdin/stdout/stderr are already wired to the pipeline.
20pub type CommandFn = Arc<dyn Fn(&[String], &mut IoContext) -> Result<(), String> + Send + Sync>;
21
22/// Registry of named commands.
23///
24/// Commands registered here are used by [`handle_pipeline`] and
25/// [`handle_parallel`] to dispatch each stage of a pipeline.
26///
27/// # Examples
28///
29/// ```ignore
30/// use wasi_shell::{CommandRegistry, handle_pipeline, IoContext};
31/// use std::io::{self, Write};
32///
33/// let mut reg = CommandRegistry::with_builtins();
34///
35/// // Add a custom command
36/// reg.register("greet", |args, ctx| {
37///     let name = args.get(1).map(|s| s.as_str()).unwrap_or("world");
38///     writeln!(ctx.stdout, "Hello, {}!", name).map_err(|e| e.to_string())
39/// });
40///
41/// handle_pipeline("greet Rust", Box::new(io::empty()), Box::new(io::stdout()), &reg, wasibox_core::CancellationToken::new()).unwrap();
42/// ```
43pub struct CommandRegistry {
44    commands: HashMap<String, CommandFn>,
45    fallback: Option<CommandFn>,
46}
47
48impl CommandRegistry {
49    /// Create an empty registry **with** a `wasibox_core` fallback.
50    ///
51    /// Any command name not explicitly registered will be forwarded to
52    /// `wasibox_core::execute_with_context`.
53    pub fn new() -> Self {
54        Self {
55            commands: HashMap::new(),
56            fallback: Some(Arc::new(|args: &[String], ctx: &mut IoContext| {
57                wasibox_core::execute_with_context(args.iter().cloned(), ctx)
58            })),
59        }
60    }
61
62    /// Create a registry pre-loaded with shell built-in commands
63    /// (`cd`, `help`, `exit`, `sl`) and a `wasibox_core` fallback for all
64    /// core utilities (echo, cat, grep, seq, head, …).
65    pub fn with_builtins() -> Self {
66        let mut reg = Self::new();
67
68        reg.register("help", |_args, ctx| {
69            writeln!(ctx.stdout, "{}", "Available Commands:".yellow().bold()).map_err(|e| e.to_string())?;
70
71            let mut shell_builtins = vec!["cd", "help", "exit"];
72            #[cfg(feature = "clear")] shell_builtins.push("clear");
73            shell_builtins.sort();
74            writeln!(ctx.stdout, "  Shell Built-ins: {}", shell_builtins.join(", ")).map_err(|e| e.to_string())?;
75
76            #[cfg(feature = "sl")]
77            writeln!(ctx.stdout, "  Animations: sl").map_err(|e| e.to_string())?;
78
79            let mut utils = Vec::new();
80            #[cfg(feature = "arch")] utils.push("arch");
81            #[cfg(feature = "basename")] utils.push("basename");
82            #[cfg(feature = "cat")] utils.push("cat");
83            #[cfg(feature = "cp")] utils.push("cp");
84            #[cfg(feature = "dir")] utils.push("dir");
85            #[cfg(feature = "dirname")] utils.push("dirname");
86            #[cfg(feature = "echo")] utils.push("echo");
87            #[cfg(feature = "env")] utils.push("env");
88            #[cfg(feature = "false")] utils.push("false");
89            #[cfg(feature = "grep")] utils.push("grep");
90            #[cfg(feature = "head")] utils.push("head");
91            #[cfg(feature = "link")] utils.push("link");
92            #[cfg(feature = "ln")] utils.push("ln");
93            #[cfg(feature = "ls")] utils.push("ls");
94            #[cfg(feature = "mkdir")] utils.push("mkdir");
95            #[cfg(feature = "mv")] utils.push("mv");
96            #[cfg(feature = "pwd")] utils.push("pwd");
97            #[cfg(feature = "rm")] utils.push("rm");
98            #[cfg(feature = "rmdir")] utils.push("rmdir");
99            #[cfg(feature = "seq")] utils.push("seq");
100            #[cfg(feature = "sleep")] utils.push("sleep");
101            #[cfg(feature = "tail")] utils.push("tail");
102            #[cfg(feature = "tee")] utils.push("tee");
103            #[cfg(feature = "touch")] utils.push("touch");
104            #[cfg(feature = "tree")] utils.push("tree");
105            #[cfg(feature = "true")] utils.push("true");
106            #[cfg(feature = "uname")] utils.push("uname");
107            #[cfg(feature = "unlink")] utils.push("unlink");
108            #[cfg(feature = "wc")] utils.push("wc");
109            #[cfg(feature = "whoami")] utils.push("whoami");
110            #[cfg(feature = "yes")] utils.push("yes");
111
112            if !utils.is_empty() {
113                utils.sort();
114                writeln!(ctx.stdout, "  Core Utilities: {}", utils.join(", ")).map_err(|e| e.to_string())?;
115            }
116            Ok(())
117        });
118
119        reg.register("cd", |args, _ctx| {
120            let new_dir = args.get(1).map(|s| s.as_str()).unwrap_or(".");
121            env::set_current_dir(new_dir).map_err(|e| format!("cd: {}", e))
122        });
123
124        reg.register("exit", |_args, _ctx| Ok(()));
125
126        #[cfg(feature = "clear")]
127        reg.register("clear", |_args, ctx| {
128            write!(ctx.stdout, "\x1B[2J\x1B[1;1H").map_err(|e| e.to_string())?;
129            ctx.stdout.flush().map_err(|e| e.to_string())
130        });
131
132        #[cfg(feature = "sl")]
133        reg.register("sl", |args, ctx| {
134            let _ = sl::run_with_token(args.iter().cloned(), Some(ctx.cancel_token.clone()));
135            Ok(())
136        });
137
138        // Register coreutils from wasibox-core
139        #[cfg(feature = "arch")]
140        reg.register("arch", |args, ctx| wasibox_core::utils::arch::execute_with_context(args.iter().cloned(), ctx));
141        #[cfg(feature = "basename")]
142        reg.register("basename", |args, ctx| wasibox_core::utils::basename::execute_with_context(args.iter().cloned(), ctx));
143        #[cfg(feature = "cat")]
144        reg.register("cat", |args, ctx| wasibox_core::utils::cat::execute_with_context(args.iter().cloned(), ctx));
145        #[cfg(feature = "cp")]
146        reg.register("cp", |args, ctx| wasibox_core::utils::cp::execute_with_context(args.iter().cloned(), ctx));
147        #[cfg(feature = "dir")]
148        reg.register("dir", |args, ctx| wasibox_core::utils::dir::execute_with_context(args.iter().cloned(), ctx));
149        #[cfg(feature = "dirname")]
150        reg.register("dirname", |args, ctx| wasibox_core::utils::dirname::execute_with_context(args.iter().cloned(), ctx));
151        #[cfg(feature = "echo")]
152        reg.register("echo", |args, ctx| wasibox_core::utils::echo::execute_with_context(args.iter().cloned(), ctx));
153        #[cfg(feature = "env")]
154        reg.register("env", |args, ctx| wasibox_core::utils::env::execute_with_context(args.iter().cloned(), ctx));
155        #[cfg(feature = "false")]
156        reg.register("false", |args, ctx| wasibox_core::utils::r#false::execute_with_context(args.iter().cloned(), ctx));
157        #[cfg(feature = "grep")]
158        reg.register("grep", |args, ctx| wasibox_core::utils::grep::execute_with_context(args.iter().cloned(), ctx));
159        #[cfg(feature = "head")]
160        reg.register("head", |args, ctx| wasibox_core::utils::head::execute_with_context(args.iter().cloned(), ctx));
161        #[cfg(feature = "link")]
162        reg.register("link", |args, ctx| wasibox_core::utils::link::execute_with_context(args.iter().cloned(), ctx));
163        #[cfg(feature = "ln")]
164        reg.register("ln", |args, ctx| wasibox_core::utils::ln::execute_with_context(args.iter().cloned(), ctx));
165        #[cfg(feature = "ls")]
166        reg.register("ls", |args, ctx| wasibox_core::utils::ls::execute_with_context(args.iter().cloned(), ctx));
167        #[cfg(feature = "mkdir")]
168        reg.register("mkdir", |args, ctx| wasibox_core::utils::mkdir::execute_with_context(args.iter().cloned(), ctx));
169        #[cfg(feature = "mv")]
170        reg.register("mv", |args, ctx| wasibox_core::utils::mv::execute_with_context(args.iter().cloned(), ctx));
171        #[cfg(feature = "pwd")]
172        reg.register("pwd", |args, ctx| wasibox_core::utils::pwd::execute_with_context(args.iter().cloned(), ctx));
173        #[cfg(feature = "rm")]
174        reg.register("rm", |args, ctx| wasibox_core::utils::rm::execute_with_context(args.iter().cloned(), ctx));
175        #[cfg(feature = "rmdir")]
176        reg.register("rmdir", |args, ctx| wasibox_core::utils::rmdir::execute_with_context(args.iter().cloned(), ctx));
177        #[cfg(feature = "seq")]
178        reg.register("seq", |args, ctx| wasibox_core::utils::seq::execute_with_context(args.iter().cloned(), ctx));
179        #[cfg(feature = "sleep")]
180        reg.register("sleep", |args, ctx| wasibox_core::utils::sleep::execute_with_context(args.iter().cloned(), ctx));
181        #[cfg(feature = "tail")]
182        reg.register("tail", |args, ctx| wasibox_core::utils::tail::execute_with_context(args.iter().cloned(), ctx));
183        #[cfg(feature = "tee")]
184        reg.register("tee", |args, ctx| wasibox_core::utils::tee::execute_with_context(args.iter().cloned(), ctx));
185        #[cfg(feature = "touch")]
186        reg.register("touch", |args, ctx| wasibox_core::utils::touch::execute_with_context(args.iter().cloned(), ctx));
187        #[cfg(feature = "tree")]
188        reg.register("tree", |args, ctx| wasibox_core::utils::tree::execute_with_context(args.iter().cloned(), ctx));
189        #[cfg(feature = "true")]
190        reg.register("true", |args, ctx| wasibox_core::utils::r#true::execute_with_context(args.iter().cloned(), ctx));
191        #[cfg(feature = "uname")]
192        reg.register("uname", |args, ctx| wasibox_core::utils::uname::execute_with_context(args.iter().cloned(), ctx));
193        #[cfg(feature = "unlink")]
194        reg.register("unlink", |args, ctx| wasibox_core::utils::unlink::execute_with_context(args.iter().cloned(), ctx));
195        #[cfg(feature = "wc")]
196        reg.register("wc", |args, ctx| wasibox_core::utils::wc::execute_with_context(args.iter().cloned(), ctx));
197        #[cfg(feature = "whoami")]
198        reg.register("whoami", |args, ctx| wasibox_core::utils::whoami::execute_with_context(args.iter().cloned(), ctx));
199        #[cfg(feature = "yes")]
200        reg.register("yes", |args, ctx| wasibox_core::utils::yes::execute_with_context(args.iter().cloned(), ctx));
201
202        reg
203    }
204
205    /// Returns the names of all explicitly registered commands
206    /// (excluding the fallback).
207    pub fn command_names(&self) -> Vec<&str> {
208        self.commands.keys().map(|s| s.as_str()).collect()
209    }
210
211    /// Register (or replace) a command.
212    pub fn register<F>(&mut self, name: impl Into<String>, handler: F)
213    where
214        F: Fn(&[String], &mut IoContext) -> Result<(), String> + Send + Sync + 'static,
215    {
216        self.commands.insert(name.into(), Arc::new(handler));
217    }
218
219    /// Set a fallback handler invoked when no explicit command matches.
220    pub fn set_fallback<F>(&mut self, handler: F)
221    where
222        F: Fn(&[String], &mut IoContext) -> Result<(), String> + Send + Sync + 'static,
223    {
224        self.fallback = Some(Arc::new(handler));
225    }
226
227    /// Remove the fallback handler. Unknown commands will return an error.
228    pub fn remove_fallback(&mut self) {
229        self.fallback = None;
230    }
231
232    /// Look up and execute a command.
233    pub fn execute(&self, args: &[String], ctx: &mut IoContext) -> Result<(), String> {
234        if args.is_empty() {
235            return Ok(());
236        }
237        let cmd = &args[0];
238
239        if let Some(handler) = self.commands.get(cmd.as_str()) {
240            return handler(args, ctx);
241        }
242        if let Some(ref fallback) = self.fallback {
243            return fallback(args, ctx);
244        }
245        Err(format!("command not found: {}", cmd))
246    }
247}
248
249impl Default for CommandRegistry {
250    fn default() -> Self {
251        Self::new()
252    }
253}
254
255// ---------------------------------------------------------------------------
256// Pipeline execution
257// ---------------------------------------------------------------------------
258
259/// Check if an error string represents a BrokenPipe (normal pipeline termination).
260fn is_broken_pipe(err: &str) -> bool {
261    err.contains("Broken pipe")
262        || err.contains("BrokenPipe")
263        || err.contains("broken pipe")
264}
265
266/// Execute a shell pipeline (commands separated by `|`).
267///
268/// Each stage runs in its own thread; stages are connected by in-process
269/// pipes. The last stage supports `>` / `>>` redirection.
270pub fn handle_pipeline(
271    line: &str,
272    initial_stdin: Box<dyn Read + Send + 'static>,
273    final_stdout: Box<dyn Write + Send + 'static>,
274    registry: &CommandRegistry,
275    cancel_token: wasibox_core::CancellationToken,
276) -> Result<(), String> {
277    let stages_str: Vec<&str> = line.split('|').collect();
278
279    let mut final_stdout = Some(final_stdout);
280
281    std::thread::scope(|s| {
282        let mut prev_reader: Box<dyn Read + Send> = initial_stdin;
283        let mut threads = Vec::new();
284
285        for (i, stage_str) in stages_str.iter().enumerate() {
286            let is_last = i == stages_str.len() - 1;
287            let mut tokens = shlex::split(stage_str.trim())
288                .ok_or_else(|| "Error: Invalid input (quoting error)".to_string())?;
289
290            let stdin = std::mem::replace(&mut prev_reader, Box::new(io::empty()));
291            let (stdout, next_reader): (Box<dyn Write + Send>, Option<Box<dyn Read + Send>>) = if is_last {
292                let mut out: Box<dyn Write + Send> = final_stdout.take().unwrap();
293                if let Some(pos) = tokens.iter().position(|t| t == ">" || t == ">>") {
294                    let append = tokens[pos] == ">>";
295                    let filename = tokens.get(pos + 1).ok_or("Error: Missing file for redirection")?;
296                    let file = if append {
297                        std::fs::OpenOptions::new().create(true).append(true).open(filename)
298                    } else {
299                        File::create(filename)
300                    }.map_err(|e| format!("Error opening file: {}", e))?;
301                    out = Box::new(file);
302                    tokens.truncate(pos);
303                }
304                (out, None)
305            } else {
306                let (reader, writer) = create_pipe(cancel_token.clone());
307                (Box::new(writer), Some(Box::new(reader)))
308            };
309
310            if let Some(r) = next_reader {
311                prev_reader = r;
312            }
313
314            let cancel_clone = cancel_token.clone();
315            let thread = s.spawn(move || {
316                if cancel_clone.is_cancelled() {
317                    return Err("Interrupted".to_string());
318                }
319                let wrapped_stdin = Box::new(CancelReader { inner: stdin, token: cancel_clone.clone() });
320                let wrapped_stdout = Box::new(CancelWriter { inner: stdout, token: cancel_clone.clone() });
321                let wrapped_stderr = Box::new(CancelWriter { inner: Box::new(io::stderr()), token: cancel_clone.clone() });
322
323                let mut ctx = IoContext::with_cancel(wrapped_stdin, wrapped_stdout, wrapped_stderr, cancel_clone.clone());
324                let res = registry.execute(&tokens, &mut ctx);
325                if cancel_clone.is_cancelled() {
326                    return Err("Interrupted".to_string());
327                }
328                res
329            });
330            threads.push((i, thread));
331        }
332
333        // Collect results: BrokenPipe in non-last stages is normal
334        // (upstream terminated by downstream closing the pipe).
335        let last_idx = stages_str.len() - 1;
336        let mut final_res = Ok(());
337        for (idx, thread) in threads {
338            match thread.join() {
339                Ok(Ok(())) => {}
340                Ok(Err(e)) => {
341                    if idx != last_idx && is_broken_pipe(&e) {
342                        continue;
343                    }
344                    if final_res.is_ok() {
345                        final_res = Err(e);
346                    }
347                }
348                Err(_) => {
349                    if final_res.is_ok() {
350                        final_res = Err("Thread panicked".to_string());
351                    }
352                }
353            }
354        }
355        final_res
356    })
357}
358
359/// Execute multiple independent command lines in parallel.
360///
361/// The first line receives `initial_stdin` / `final_stdout`; all subsequent
362/// lines use `io::empty()` / `io::stdout()`.
363pub fn handle_parallel(
364    lines: Vec<String>,
365    initial_stdin: Box<dyn Read + Send + 'static>,
366    final_stdout: Box<dyn Write + Send + 'static>,
367    registry: Arc<CommandRegistry>,
368    cancel_token: wasibox_core::CancellationToken,
369) -> Vec<Result<(), String>> {
370    let mut handles = Vec::new();
371    let mut stdin_opt: Option<Box<dyn Read + Send>> = Some(initial_stdin);
372    let mut stdout_opt: Option<Box<dyn Write + Send>> = Some(final_stdout);
373
374    for line in lines {
375        let registry = Arc::clone(&registry);
376        let mut stdin_for_thread: Option<Box<dyn Read + Send>> = Some(stdin_opt.take().unwrap_or_else(|| Box::new(io::empty())));
377        let mut stdout_for_thread: Option<Box<dyn Write + Send>> = Some(stdout_opt.take().unwrap_or_else(|| Box::new(io::stdout())));
378
379        let cancel_clone = cancel_token.clone();
380        let handle = std::thread::spawn(move || {
381            let commands: Vec<&str> = line.split("&&").collect();
382            for cmd in commands {
383                let cmd = cmd.trim();
384                if cmd.is_empty() { continue; }
385                if cancel_clone.is_cancelled() { return Err("Interrupted".to_string()); }
386                let stdin = stdin_for_thread.take().unwrap_or_else(|| Box::new(io::empty()));
387                let stdout = stdout_for_thread.take().unwrap_or_else(|| Box::new(io::stdout()));
388                handle_pipeline(cmd, stdin, stdout, &*registry, cancel_clone.clone())?;
389            }
390            Ok(())
391        });
392        handles.push(handle);
393    }
394
395    handles.into_iter()
396        .map(|h| h.join().unwrap_or(Err("Thread panicked".to_string())))
397        .collect()
398}
399
400/// Execute a complete command line, splitting by `&&` and running sequentially.
401/// Stops execution if any command in the sequence fails.
402pub fn handle_command_line(
403    line: &str,
404    registry: &CommandRegistry,
405    cancel_token: wasibox_core::CancellationToken,
406) -> Result<(), String> {
407    let commands: Vec<&str> = line.split("&&").collect();
408    for cmd in commands {
409        let cmd = cmd.trim();
410        if cmd.is_empty() {
411            continue;
412        }
413        if cancel_token.is_cancelled() { return Err("Interrupted".to_string()); }
414        handle_pipeline(cmd, Box::new(io::stdin()), Box::new(io::stdout()), registry, cancel_token.clone())?;
415    }
416    Ok(())
417}
418
419// ---------------------------------------------------------------------------
420// Pipe implementation
421// ---------------------------------------------------------------------------
422
423struct CancelReader<R: Read> {
424    inner: R,
425    token: wasibox_core::CancellationToken,
426}
427impl<R: Read> Read for CancelReader<R> {
428    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
429        if self.token.is_cancelled() {
430            return Err(io::Error::new(io::ErrorKind::BrokenPipe, "Interrupted"));
431        }
432        self.inner.read(buf)
433    }
434}
435
436struct CancelWriter<W: Write> {
437    inner: W,
438    token: wasibox_core::CancellationToken,
439}
440impl<W: Write> Write for CancelWriter<W> {
441    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
442        if self.token.is_cancelled() {
443            return Err(io::Error::new(io::ErrorKind::BrokenPipe, "Interrupted"));
444        }
445        self.inner.write(buf)
446    }
447    fn flush(&mut self) -> io::Result<()> {
448        if self.token.is_cancelled() {
449            return Err(io::Error::new(io::ErrorKind::BrokenPipe, "Interrupted"));
450        }
451        self.inner.flush()
452    }
453}
454
455struct PipeReader {
456    rx: std::sync::mpsc::Receiver<Vec<u8>>,
457    buffer: Vec<u8>,
458    pos: usize,
459    token: wasibox_core::CancellationToken,
460}
461impl Read for PipeReader {
462    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
463        if self.token.is_cancelled() {
464            return Err(io::Error::new(io::ErrorKind::BrokenPipe, "Interrupted"));
465        }
466        if self.pos >= self.buffer.len() {
467            loop {
468                if self.token.is_cancelled() {
469                    return Err(io::Error::new(io::ErrorKind::BrokenPipe, "Interrupted"));
470                }
471                match self.rx.recv_timeout(std::time::Duration::from_millis(100)) {
472                    Ok(data) => {
473                        self.buffer = data;
474                        self.pos = 0;
475                        break;
476                    }
477                    Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
478                    Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return Ok(0),
479                }
480            }
481        }
482        let available = self.buffer.len() - self.pos;
483        let to_copy = std::cmp::min(available, buf.len());
484        buf[..to_copy].copy_from_slice(&self.buffer[self.pos..self.pos + to_copy]);
485        self.pos += to_copy;
486        Ok(to_copy)
487    }
488}
489
490struct PipeWriter {
491    tx: std::sync::mpsc::SyncSender<Vec<u8>>,
492    token: wasibox_core::CancellationToken,
493}
494impl Write for PipeWriter {
495    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
496        if self.token.is_cancelled() {
497            return Err(io::Error::new(io::ErrorKind::BrokenPipe, "Interrupted"));
498        }
499        self.tx.send(buf.to_vec()).map_err(|e| io::Error::new(io::ErrorKind::BrokenPipe, e))?;
500        Ok(buf.len())
501    }
502    fn flush(&mut self) -> io::Result<()> { Ok(()) }
503}
504
505fn create_pipe(token: wasibox_core::CancellationToken) -> (PipeReader, PipeWriter) {
506    let (tx, rx) = std::sync::mpsc::sync_channel(1024);
507    (PipeReader { rx, buffer: Vec::new(), pos: 0, token: token.clone() }, PipeWriter { tx, token })
508}
509
510// ---------------------------------------------------------------------------
511// Stdin Multiplexer (for signals on platforms without them)
512// ---------------------------------------------------------------------------
513
514use std::sync::mpsc::{self, Sender, Receiver};
515
516/// A shared stdin reader that can be monitored by a background thread
517/// to intercept control characters (like Ctrl-C) on platforms without signals.
518pub struct StdinMultiplexer {
519    tx_subscribers: Mutex<Vec<Sender<Vec<u8>>>>,
520    cancel_token: wasibox_core::CancellationToken,
521}
522
523impl StdinMultiplexer {
524    pub fn new(cancel_token: wasibox_core::CancellationToken) -> Arc<Self> {
525        let mux = Arc::new(Self {
526            tx_subscribers: Mutex::new(Vec::new()),
527            cancel_token,
528        });
529
530        let mux_clone = Arc::clone(&mux);
531        std::thread::spawn(move || {
532            let mut buf = [0u8; 1024];
533            let mut stdin = io::stdin();
534            loop {
535                match stdin.read(&mut buf) {
536                    Ok(0) => break, // EOF
537                    Ok(n) => {
538                        let data = &buf[..n];
539                        // Check for Ctrl-C (byte 3)
540                        for &b in data {
541                            if b == 3 {
542                                mux_clone.cancel_token.cancel();
543                            }
544                        }
545                        // Broadcast to all active subscribers
546                        let mut subs = mux_clone.tx_subscribers.lock().unwrap();
547                        subs.retain(|tx| {
548                            tx.send(data.to_vec()).is_ok()
549                        });
550                    }
551                    Err(_) => {
552                        // On some platforms, read error might be temporary,
553                        // but for stdin it usually means we should stop.
554                        std::thread::sleep(std::time::Duration::from_millis(100));
555                    }
556                }
557            }
558        });
559
560        mux
561    }
562
563    pub fn subscribe(&self) -> MultiplexedReader {
564        let (tx, rx) = mpsc::channel();
565        self.tx_subscribers.lock().unwrap().push(tx);
566        MultiplexedReader { rx, buffer: Vec::new(), pos: 0 }
567    }
568}
569
570pub struct MultiplexedReader {
571    rx: Receiver<Vec<u8>>,
572    buffer: Vec<u8>,
573    pos: usize,
574}
575
576impl Read for MultiplexedReader {
577    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
578        if self.pos >= self.buffer.len() {
579            match self.rx.recv() {
580                Ok(data) => {
581                    self.buffer = data;
582                    self.pos = 0;
583                }
584                Err(_) => return Ok(0),
585            }
586        }
587        let available = self.buffer.len() - self.pos;
588        let to_copy = std::cmp::min(available, buf.len());
589        buf[..to_copy].copy_from_slice(&self.buffer[self.pos..self.pos + to_copy]);
590        self.pos += to_copy;
591        Ok(to_copy)
592    }
593}
594
595/// A thread-safe writer backed by a shared `Vec<u8>`. Useful for tests.
596pub struct ArcVecWriter {
597    pub inner: Arc<Mutex<Vec<u8>>>,
598}
599impl Write for ArcVecWriter {
600    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
601        let mut inner = self.inner.lock().map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
602        inner.extend_from_slice(buf);
603        Ok(buf.len())
604    }
605    fn flush(&mut self) -> io::Result<()> { Ok(()) }
606}
607
608// ---------------------------------------------------------------------------
609// Tests
610// ---------------------------------------------------------------------------
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use std::io::{BufRead, BufReader, Cursor};
616
617    fn get_temp_dir() -> tempfile::TempDir {
618        #[cfg(target_os = "wasi")]
619        {
620            tempfile::Builder::new()
621                .prefix("test_")
622                .tempdir_in(".")
623                .expect("Failed to create temp dir in current directory")
624        }
625        #[cfg(not(target_os = "wasi"))]
626        {
627            tempfile::tempdir().expect("Failed to create system temp dir")
628        }
629    }
630
631    /// Shorthand: registry with all builtins (shell + wasibox-core)
632    fn builtins() -> CommandRegistry {
633        CommandRegistry::with_builtins()
634    }
635
636    // ── basic pipeline tests ────────────────────────────────────────────
637
638    #[test]
639    fn test_simple_echo() {
640        let out = Arc::new(Mutex::new(Vec::new()));
641        handle_pipeline("echo hello", Box::new(Cursor::new("")), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
642        let buf = out.lock().unwrap();
643        assert_eq!(String::from_utf8_lossy(&buf).trim(), "hello");
644    }
645
646    #[test]
647    fn test_pipe_echo_cat() {
648        let out = Arc::new(Mutex::new(Vec::new()));
649        handle_pipeline("echo hello | cat", Box::new(Cursor::new("")), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
650        let buf = out.lock().unwrap();
651        assert_eq!(String::from_utf8_lossy(&buf).trim(), "hello");
652    }
653
654    #[test]
655    fn test_pipe_grep() {
656        let out = Arc::new(Mutex::new(Vec::new()));
657        handle_pipeline("echo world | grep world", Box::new(Cursor::new("")), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
658        let buf = out.lock().unwrap();
659        assert_eq!(String::from_utf8_lossy(&buf).trim(), "world");
660    }
661
662    #[test]
663    fn test_redirection_create() {
664        let dir = get_temp_dir();
665        let file_path = dir.path().join("test_create.txt");
666        let cmd = format!("echo hello > \"{}\"", file_path.display());
667        handle_pipeline(&cmd, Box::new(Cursor::new("")), Box::new(io::sink()), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
668        let content = std::fs::read_to_string(file_path).unwrap();
669        assert_eq!(content.trim(), "hello");
670    }
671
672    #[test]
673    fn test_redirection_append() {
674        let dir = get_temp_dir();
675        let file_path = dir.path().join("test_append.txt");
676        let cmd1 = format!("echo hello > \"{}\"", file_path.display());
677        handle_pipeline(&cmd1, Box::new(Cursor::new("")), Box::new(io::sink()), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
678        let cmd2 = format!("echo world >> \"{}\"", file_path.display());
679        handle_pipeline(&cmd2, Box::new(Cursor::new("")), Box::new(io::sink()), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
680        let content = std::fs::read_to_string(file_path).unwrap();
681        assert!(content.contains("hello"));
682        assert!(content.contains("world"));
683    }
684
685    #[test]
686    fn test_complex_pipeline() {
687        let out = Arc::new(Mutex::new(Vec::new()));
688        handle_pipeline("echo hello | grep h | wc -c", Box::new(Cursor::new("")), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
689        let buf = out.lock().unwrap();
690        assert_eq!(String::from_utf8_lossy(&buf).trim(), "6");
691    }
692
693    // ── custom command tests ────────────────────────────────────────────
694
695    #[test]
696    fn test_custom_command() {
697        let mut reg = CommandRegistry::with_builtins();
698        reg.register("magic", |_args, ctx| {
699            write!(ctx.stdout, "magic happen").map_err(|e| e.to_string())
700        });
701
702        let out = Arc::new(Mutex::new(Vec::new()));
703        handle_pipeline("magic", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &reg, wasibox_core::CancellationToken::new()).unwrap();
704        let buf = out.lock().unwrap();
705        assert_eq!(String::from_utf8_lossy(&buf), "magic happen");
706    }
707
708    #[test]
709    fn test_custom_command_in_pipeline() {
710        // Custom "double" command that duplicates each input line
711        let mut reg = CommandRegistry::with_builtins();
712        reg.register("double", |_args, ctx| {
713            for line in BufReader::new(&mut ctx.stdin).lines() {
714                let line = match line {
715                    Ok(l) => l,
716                    Err(_) => break,
717                };
718                if writeln!(ctx.stdout, "{}", line).is_err() { break; }
719                if writeln!(ctx.stdout, "{}", line).is_err() { break; }
720            }
721            Ok(())
722        });
723
724        let out = Arc::new(Mutex::new(Vec::new()));
725        handle_pipeline("echo hello | double", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &reg, wasibox_core::CancellationToken::new()).unwrap();
726        let buf = out.lock().unwrap();
727        assert_eq!(String::from_utf8_lossy(&buf).trim(), "hello\nhello");
728    }
729
730    #[test]
731    fn test_override_builtin() {
732        // Override "echo" with a custom version
733        let mut reg = CommandRegistry::with_builtins();
734        reg.register("echo", |args, ctx| {
735            let msg = args[1..].join(" ").to_uppercase();
736            writeln!(ctx.stdout, "{}", msg).map_err(|e| e.to_string())
737        });
738
739        let out = Arc::new(Mutex::new(Vec::new()));
740        handle_pipeline("echo hello", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &reg, wasibox_core::CancellationToken::new()).unwrap();
741        let buf = out.lock().unwrap();
742        assert_eq!(String::from_utf8_lossy(&buf).trim(), "HELLO");
743    }
744
745    // ── fully external pipeline (no builtins at all) ────────────────────
746
747    #[test]
748    fn test_all_external_pipeline() {
749        let mut reg = CommandRegistry::new();
750        reg.remove_fallback(); // no wasibox-core fallback
751
752        // "count" — infinite counter
753        reg.register("count", |_args, ctx| {
754            for i in 1u64.. {
755                if writeln!(ctx.stdout, "{}", i).is_err() { break; }
756            }
757            Ok(())
758        });
759
760        // "filter2" — keep lines containing '2'
761        reg.register("filter2", |_args, ctx| {
762            for line in BufReader::new(&mut ctx.stdin).lines() {
763                let line = match line {
764                    Ok(l) => l,
765                    Err(_) => break,
766                };
767                if line.contains('2') {
768                    if writeln!(ctx.stdout, "{}", line).is_err() { break; }
769                }
770            }
771            Ok(())
772        });
773
774        // "take N" — first N lines
775        reg.register("take", |args, ctx| {
776            let n: usize = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(5);
777            for (i, line) in BufReader::new(&mut ctx.stdin).lines().enumerate() {
778                if i >= n { break; }
779                let line = match line {
780                    Ok(l) => l,
781                    Err(_) => break,
782                };
783                if writeln!(ctx.stdout, "{}", line).is_err() { break; }
784            }
785            Ok(())
786        });
787
788        let out = Arc::new(Mutex::new(Vec::new()));
789        handle_pipeline(
790            "count | filter2 | take 3",
791            Box::new(io::empty()),
792            Box::new(ArcVecWriter { inner: Arc::clone(&out) }),
793            &reg,
794            wasibox_core::CancellationToken::new(),
795        ).unwrap();
796
797        let buf = out.lock().unwrap();
798        let result = String::from_utf8_lossy(&buf);
799        let lines: Vec<&str> = result.trim().lines().collect();
800        assert_eq!(lines.len(), 3);
801        for line in &lines {
802            assert!(line.contains('2'), "expected '2' in line, got: {}", line);
803        }
804        assert_eq!(lines[0], "2");
805        assert_eq!(lines[1], "12");
806        assert_eq!(lines[2], "20");
807    }
808
809    // ── parallel tests ──────────────────────────────────────────────────
810
811    #[test]
812    fn test_parallel_execution() {
813        let mut reg = CommandRegistry::with_builtins();
814        reg.register("slow", |_args, _ctx| {
815            std::thread::sleep(std::time::Duration::from_millis(100));
816            Ok(())
817        });
818        let registry = Arc::new(reg);
819
820        let lines = vec!["slow".to_string(), "slow".to_string(), "slow".to_string()];
821        let start = std::time::Instant::now();
822        let results = handle_parallel(lines, Box::new(io::empty()), Box::new(io::sink()), registry, wasibox_core::CancellationToken::new());
823        let duration = start.elapsed();
824
825        assert_eq!(results.len(), 3);
826        for res in results {
827            assert!(res.is_ok());
828        }
829        assert!(duration < std::time::Duration::from_millis(250));
830    }
831
832    // ── streaming / infinite pipeline tests ─────────────────────────────
833
834    #[test]
835    fn test_streaming_pipeline() {
836        let out = Arc::new(Mutex::new(Vec::new()));
837        handle_pipeline("yes | head -n 2", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
838        let buf = out.lock().unwrap();
839        assert_eq!(String::from_utf8_lossy(&buf).trim(), "y\ny");
840    }
841
842    #[test]
843    fn test_seq_pipeline_head() {
844        let out = Arc::new(Mutex::new(Vec::new()));
845        handle_pipeline("seq | head -n 5", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
846        let buf = out.lock().unwrap();
847        assert_eq!(String::from_utf8_lossy(&buf).trim(), "1\n2\n3\n4\n5");
848    }
849
850    #[test]
851    fn test_seq_pipeline_grep_head() {
852        let out = Arc::new(Mutex::new(Vec::new()));
853        handle_pipeline("seq | grep 2 | head -n 3", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
854        let buf = out.lock().unwrap();
855        let result = String::from_utf8_lossy(&buf);
856        let lines: Vec<&str> = result.trim().lines().collect();
857        assert_eq!(lines.len(), 3);
858        for line in &lines {
859            assert!(line.contains('2'));
860        }
861        assert_eq!(lines[0], "2");
862        assert_eq!(lines[1], "12");
863        assert_eq!(lines[2], "20");
864    }
865
866    #[test]
867    fn test_seq_pipeline_grep_head_5() {
868        let out = Arc::new(Mutex::new(Vec::new()));
869        handle_pipeline("seq | grep 2 | head -n 5", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
870        let buf = out.lock().unwrap();
871        let result = String::from_utf8_lossy(&buf);
872        let lines: Vec<&str> = result.trim().lines().collect();
873        assert_eq!(lines.len(), 5);
874        assert_eq!(lines[0], "2");
875        assert_eq!(lines[1], "12");
876        assert_eq!(lines[2], "20");
877        assert_eq!(lines[3], "21");
878        assert_eq!(lines[4], "22");
879    }
880
881    #[test]
882    fn test_seq_finite() {
883        let out = Arc::new(Mutex::new(Vec::new()));
884        handle_pipeline("seq 3", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
885        let buf = out.lock().unwrap();
886        assert_eq!(String::from_utf8_lossy(&buf).trim(), "1\n2\n3");
887    }
888
889    #[test]
890    fn test_seq_range() {
891        let out = Arc::new(Mutex::new(Vec::new()));
892        handle_pipeline("seq 5 8", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
893        let buf = out.lock().unwrap();
894        assert_eq!(String::from_utf8_lossy(&buf).trim(), "5\n6\n7\n8");
895    }
896
897    #[test]
898    fn test_seq_step() {
899        let out = Arc::new(Mutex::new(Vec::new()));
900        handle_pipeline("seq 1 2 10", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
901        let buf = out.lock().unwrap();
902        assert_eq!(String::from_utf8_lossy(&buf).trim(), "1\n3\n5\n7\n9");
903    }
904    #[test]
905    fn test_cd_and_ls() {
906        let dir = get_temp_dir();
907        // Create files inside the temp dir
908        std::fs::write(dir.path().join("aaa.txt"), "hello").unwrap();
909        std::fs::write(dir.path().join("bbb.txt"), "world").unwrap();
910
911        let original_cwd = env::current_dir().unwrap();
912        let reg = builtins();
913
914        // cd into the temp directory
915        let cd_cmd = format!("cd \"{}\"", dir.path().display());
916        handle_pipeline(&cd_cmd, Box::new(io::empty()), Box::new(io::sink()), &reg, wasibox_core::CancellationToken::new()).unwrap();
917
918        // ls the current directory (should now be the temp dir)
919        let out = Arc::new(Mutex::new(Vec::new()));
920        handle_pipeline("ls", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &reg, wasibox_core::CancellationToken::new()).unwrap();
921
922        let buf = out.lock().unwrap();
923        let output = String::from_utf8_lossy(&buf);
924        assert!(output.contains("aaa.txt"), "expected aaa.txt in ls output, got: {}", output);
925        assert!(output.contains("bbb.txt"), "expected bbb.txt in ls output, got: {}", output);
926
927        // Restore original cwd
928        env::set_current_dir(original_cwd).unwrap();
929    }
930
931    // ── sequence (&&) tests ─────────────────────────────────────────────
932
933    #[test]
934    fn test_command_line_sequence() {
935        let mut reg = CommandRegistry::with_builtins();
936        let counter = Arc::new(Mutex::new(0));
937        let c1 = Arc::clone(&counter);
938        reg.register("inc", move |_args, _ctx| {
939            *c1.lock().unwrap() += 1;
940            Ok(())
941        });
942
943        // inc && inc && inc
944        crate::handle_command_line("inc && inc && inc", &reg, wasibox_core::CancellationToken::new()).unwrap();
945        assert_eq!(*counter.lock().unwrap(), 3);
946    }
947
948    #[test]
949    fn test_command_line_short_circuit() {
950        let mut reg = CommandRegistry::with_builtins();
951        let counter = Arc::new(Mutex::new(0));
952        let c1 = Arc::clone(&counter);
953        reg.register("inc", move |_args, _ctx| {
954            *c1.lock().unwrap() += 1;
955            Ok(())
956        });
957        reg.register("fail", |_args, _ctx| Err("simulated failure".to_string()));
958
959        // inc && fail && inc
960        // Should stop after "fail"
961        let res = crate::handle_command_line("inc && fail && inc", &reg, wasibox_core::CancellationToken::new());
962        assert!(res.is_err());
963        assert_eq!(res.unwrap_err(), "simulated failure");
964        assert_eq!(*counter.lock().unwrap(), 1); // Only the first "inc" should execute
965    }
966
967    #[test]
968    fn test_handle_parallel_cancel() {
969        println!("STARTING TEST");
970        let registry = Arc::new(builtins());
971        let cancel_token = wasibox_core::CancellationToken::new();
972        let cancel_clone = cancel_token.clone();
973
974        let thread = std::thread::spawn(move || {
975            println!("THREAD SPAWNED");
976            super::handle_parallel(
977                vec!["yes".to_string()],
978                Box::new(std::io::empty()),
979                Box::new(std::io::sink()),
980                registry,
981                cancel_clone,
982            )
983        });
984
985        std::thread::sleep(std::time::Duration::from_millis(10));
986        println!("CANCELLING TOKEN");
987        cancel_token.cancel();
988
989        println!("JOINING THREAD");
990        let results = thread.join().unwrap();
991        println!("RESULTS: {:?}", results);
992        assert_eq!(results.len(), 1);
993        assert!(results[0].is_err());
994        assert_eq!(results[0].as_ref().unwrap_err(), "Interrupted");
995    }
996
997    #[test]
998    fn test_pipeline_cancel() {
999        let registry = builtins();
1000        let cancel_token = wasibox_core::CancellationToken::new();
1001        let cancel_clone = cancel_token.clone();
1002
1003        let thread = std::thread::spawn(move || {
1004            super::handle_pipeline(
1005                "yes | grep y",
1006                Box::new(std::io::empty()),
1007                Box::new(std::io::sink()),
1008                &registry,
1009                cancel_clone,
1010            )
1011        });
1012
1013        std::thread::sleep(std::time::Duration::from_millis(10));
1014        cancel_token.cancel();
1015
1016        let result = thread.join().unwrap();
1017        assert!(result.is_err());
1018        assert_eq!(result.unwrap_err(), "Interrupted");
1019    }
1020
1021    #[test]
1022    fn test_seq_redirection_parallel_cancel() {
1023        let dir = get_temp_dir();
1024        let file_path = dir.path().join("i.txt");
1025        let cmd = format!("seq > \"{}\"", file_path.display());
1026        
1027        let registry = Arc::new(builtins());
1028        let cancel_token = wasibox_core::CancellationToken::new();
1029        let cancel_clone = cancel_token.clone();
1030
1031        let thread = std::thread::spawn(move || {
1032            super::handle_parallel(
1033                vec![cmd],
1034                Box::new(std::io::empty()),
1035                Box::new(std::io::sink()),
1036                registry,
1037                cancel_clone,
1038            )
1039        });
1040
1041        // Give it some time to start writing
1042        std::thread::sleep(std::time::Duration::from_millis(50));
1043        cancel_token.cancel();
1044
1045        let results = thread.join().unwrap();
1046        assert_eq!(results.len(), 1);
1047        assert!(results[0].is_err());
1048        assert_eq!(results[0].as_ref().unwrap_err(), "Interrupted");
1049        
1050        // Verify that the file was created and contains some data
1051        assert!(file_path.exists());
1052        let content = std::fs::read_to_string(&file_path).unwrap();
1053        assert!(!content.is_empty(), "File should not be empty");
1054        
1055        // Ensure it stopped
1056        println!("Final sequence number written: {}", content.lines().last().unwrap_or("none"));
1057    }
1058}
1059