Skip to main content

wasi_shell/
lib.rs

1use colored::*;
2use std::collections::HashMap;
3use std::env;
4use std::fs::File;
5use std::io::{self, Read, Write};
6use std::sync::{Arc, Mutex};
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())
70                .map_err(|e| e.to_string())?;
71
72            let mut shell_builtins = vec!["cd", "help", "exit"];
73            #[cfg(feature = "clear")]
74            shell_builtins.push("clear");
75            shell_builtins.sort();
76            writeln!(
77                ctx.stdout,
78                "  Shell Built-ins: {}",
79                shell_builtins.join(", ")
80            )
81            .map_err(|e| e.to_string())?;
82
83            #[cfg(feature = "sl")]
84            writeln!(ctx.stdout, "  Animations: sl").map_err(|e| e.to_string())?;
85
86            let mut utils = Vec::new();
87            #[cfg(feature = "arch")]
88            utils.push("arch");
89            #[cfg(feature = "basename")]
90            utils.push("basename");
91            #[cfg(feature = "cat")]
92            utils.push("cat");
93            #[cfg(feature = "cp")]
94            utils.push("cp");
95            #[cfg(feature = "dir")]
96            utils.push("dir");
97            #[cfg(feature = "dirname")]
98            utils.push("dirname");
99            #[cfg(feature = "echo")]
100            utils.push("echo");
101            #[cfg(feature = "env")]
102            utils.push("env");
103            #[cfg(feature = "false")]
104            utils.push("false");
105            #[cfg(feature = "grep")]
106            utils.push("grep");
107            #[cfg(feature = "head")]
108            utils.push("head");
109            #[cfg(feature = "link")]
110            utils.push("link");
111            #[cfg(feature = "ln")]
112            utils.push("ln");
113            #[cfg(feature = "ls")]
114            utils.push("ls");
115            #[cfg(feature = "mkdir")]
116            utils.push("mkdir");
117            #[cfg(feature = "mv")]
118            utils.push("mv");
119            #[cfg(feature = "pwd")]
120            utils.push("pwd");
121            #[cfg(feature = "rm")]
122            utils.push("rm");
123            #[cfg(feature = "rmdir")]
124            utils.push("rmdir");
125            #[cfg(feature = "seq")]
126            utils.push("seq");
127            #[cfg(feature = "sleep")]
128            utils.push("sleep");
129            #[cfg(feature = "tail")]
130            utils.push("tail");
131            #[cfg(feature = "tee")]
132            utils.push("tee");
133            #[cfg(feature = "touch")]
134            utils.push("touch");
135            #[cfg(feature = "tree")]
136            utils.push("tree");
137            #[cfg(feature = "true")]
138            utils.push("true");
139            #[cfg(feature = "uname")]
140            utils.push("uname");
141            #[cfg(feature = "unlink")]
142            utils.push("unlink");
143            #[cfg(feature = "wc")]
144            utils.push("wc");
145            #[cfg(feature = "whoami")]
146            utils.push("whoami");
147            #[cfg(feature = "yes")]
148            utils.push("yes");
149
150            if !utils.is_empty() {
151                utils.sort();
152                writeln!(ctx.stdout, "  Core Utilities: {}", utils.join(", "))
153                    .map_err(|e| e.to_string())?;
154            }
155            Ok(())
156        });
157
158        reg.register("cd", |args, _ctx| {
159            let new_dir = args.get(1).map(|s| s.as_str()).unwrap_or(".");
160            env::set_current_dir(new_dir).map_err(|e| format!("cd: {}", e))
161        });
162
163        reg.register("exit", |_args, _ctx| Ok(()));
164
165        #[cfg(feature = "clear")]
166        reg.register("clear", |_args, ctx| {
167            write!(ctx.stdout, "\x1B[2J\x1B[1;1H").map_err(|e| e.to_string())?;
168            ctx.stdout.flush().map_err(|e| e.to_string())
169        });
170
171        #[cfg(feature = "sl")]
172        reg.register("sl", |args, ctx| {
173            let _ = sl::run_with_token(args.iter().cloned(), Some(ctx.cancel_token.clone()));
174            Ok(())
175        });
176
177        // Register coreutils from wasibox-core
178        #[cfg(feature = "arch")]
179        reg.register("arch", |args, ctx| {
180            wasibox_core::utils::arch::execute_with_context(args.iter().cloned(), ctx)
181        });
182        #[cfg(feature = "basename")]
183        reg.register("basename", |args, ctx| {
184            wasibox_core::utils::basename::execute_with_context(args.iter().cloned(), ctx)
185        });
186        #[cfg(feature = "cat")]
187        reg.register("cat", |args, ctx| {
188            wasibox_core::utils::cat::execute_with_context(args.iter().cloned(), ctx)
189        });
190        #[cfg(feature = "cp")]
191        reg.register("cp", |args, ctx| {
192            wasibox_core::utils::cp::execute_with_context(args.iter().cloned(), ctx)
193        });
194        #[cfg(feature = "dir")]
195        reg.register("dir", |args, ctx| {
196            wasibox_core::utils::dir::execute_with_context(args.iter().cloned(), ctx)
197        });
198        #[cfg(feature = "dirname")]
199        reg.register("dirname", |args, ctx| {
200            wasibox_core::utils::dirname::execute_with_context(args.iter().cloned(), ctx)
201        });
202        #[cfg(feature = "echo")]
203        reg.register("echo", |args, ctx| {
204            wasibox_core::utils::echo::execute_with_context(args.iter().cloned(), ctx)
205        });
206        #[cfg(feature = "env")]
207        reg.register("env", |args, ctx| {
208            wasibox_core::utils::env::execute_with_context(args.iter().cloned(), ctx)
209        });
210        #[cfg(feature = "false")]
211        reg.register("false", |args, ctx| {
212            wasibox_core::utils::r#false::execute_with_context(args.iter().cloned(), ctx)
213        });
214        #[cfg(feature = "grep")]
215        reg.register("grep", |args, ctx| {
216            wasibox_core::utils::grep::execute_with_context(args.iter().cloned(), ctx)
217        });
218        #[cfg(feature = "head")]
219        reg.register("head", |args, ctx| {
220            wasibox_core::utils::head::execute_with_context(args.iter().cloned(), ctx)
221        });
222        #[cfg(feature = "link")]
223        reg.register("link", |args, ctx| {
224            wasibox_core::utils::link::execute_with_context(args.iter().cloned(), ctx)
225        });
226        #[cfg(feature = "ln")]
227        reg.register("ln", |args, ctx| {
228            wasibox_core::utils::ln::execute_with_context(args.iter().cloned(), ctx)
229        });
230        #[cfg(feature = "ls")]
231        reg.register("ls", |args, ctx| {
232            wasibox_core::utils::ls::execute_with_context(args.iter().cloned(), ctx)
233        });
234        #[cfg(feature = "mkdir")]
235        reg.register("mkdir", |args, ctx| {
236            wasibox_core::utils::mkdir::execute_with_context(args.iter().cloned(), ctx)
237        });
238        #[cfg(feature = "mv")]
239        reg.register("mv", |args, ctx| {
240            wasibox_core::utils::mv::execute_with_context(args.iter().cloned(), ctx)
241        });
242        #[cfg(feature = "pwd")]
243        reg.register("pwd", |args, ctx| {
244            wasibox_core::utils::pwd::execute_with_context(args.iter().cloned(), ctx)
245        });
246        #[cfg(feature = "rm")]
247        reg.register("rm", |args, ctx| {
248            wasibox_core::utils::rm::execute_with_context(args.iter().cloned(), ctx)
249        });
250        #[cfg(feature = "rmdir")]
251        reg.register("rmdir", |args, ctx| {
252            wasibox_core::utils::rmdir::execute_with_context(args.iter().cloned(), ctx)
253        });
254        #[cfg(feature = "seq")]
255        reg.register("seq", |args, ctx| {
256            wasibox_core::utils::seq::execute_with_context(args.iter().cloned(), ctx)
257        });
258        #[cfg(feature = "sleep")]
259        reg.register("sleep", |args, ctx| {
260            wasibox_core::utils::sleep::execute_with_context(args.iter().cloned(), ctx)
261        });
262        #[cfg(feature = "tail")]
263        reg.register("tail", |args, ctx| {
264            wasibox_core::utils::tail::execute_with_context(args.iter().cloned(), ctx)
265        });
266        #[cfg(feature = "tee")]
267        reg.register("tee", |args, ctx| {
268            wasibox_core::utils::tee::execute_with_context(args.iter().cloned(), ctx)
269        });
270        #[cfg(feature = "touch")]
271        reg.register("touch", |args, ctx| {
272            wasibox_core::utils::touch::execute_with_context(args.iter().cloned(), ctx)
273        });
274        #[cfg(feature = "tree")]
275        reg.register("tree", |args, ctx| {
276            wasibox_core::utils::tree::execute_with_context(args.iter().cloned(), ctx)
277        });
278        #[cfg(feature = "true")]
279        reg.register("true", |args, ctx| {
280            wasibox_core::utils::r#true::execute_with_context(args.iter().cloned(), ctx)
281        });
282        #[cfg(feature = "uname")]
283        reg.register("uname", |args, ctx| {
284            wasibox_core::utils::uname::execute_with_context(args.iter().cloned(), ctx)
285        });
286        #[cfg(feature = "unlink")]
287        reg.register("unlink", |args, ctx| {
288            wasibox_core::utils::unlink::execute_with_context(args.iter().cloned(), ctx)
289        });
290        #[cfg(feature = "wc")]
291        reg.register("wc", |args, ctx| {
292            wasibox_core::utils::wc::execute_with_context(args.iter().cloned(), ctx)
293        });
294        #[cfg(feature = "whoami")]
295        reg.register("whoami", |args, ctx| {
296            wasibox_core::utils::whoami::execute_with_context(args.iter().cloned(), ctx)
297        });
298        #[cfg(feature = "yes")]
299        reg.register("yes", |args, ctx| {
300            wasibox_core::utils::yes::execute_with_context(args.iter().cloned(), ctx)
301        });
302
303        reg
304    }
305
306    /// Returns the names of all explicitly registered commands
307    /// (excluding the fallback).
308    pub fn command_names(&self) -> Vec<&str> {
309        self.commands.keys().map(|s| s.as_str()).collect()
310    }
311
312    /// Register (or replace) a command.
313    pub fn register<F>(&mut self, name: impl Into<String>, handler: F)
314    where
315        F: Fn(&[String], &mut IoContext) -> Result<(), String> + Send + Sync + 'static,
316    {
317        self.commands.insert(name.into(), Arc::new(handler));
318    }
319
320    /// Set a fallback handler invoked when no explicit command matches.
321    pub fn set_fallback<F>(&mut self, handler: F)
322    where
323        F: Fn(&[String], &mut IoContext) -> Result<(), String> + Send + Sync + 'static,
324    {
325        self.fallback = Some(Arc::new(handler));
326    }
327
328    /// Remove the fallback handler. Unknown commands will return an error.
329    pub fn remove_fallback(&mut self) {
330        self.fallback = None;
331    }
332
333    /// Look up and execute a command.
334    pub fn execute(&self, args: &[String], ctx: &mut IoContext) -> Result<(), String> {
335        if args.is_empty() {
336            return Ok(());
337        }
338        let cmd = &args[0];
339
340        if let Some(handler) = self.commands.get(cmd.as_str()) {
341            return handler(args, ctx);
342        }
343        if let Some(ref fallback) = self.fallback {
344            return fallback(args, ctx);
345        }
346        Err(format!("command not found: {}", cmd))
347    }
348}
349
350impl Default for CommandRegistry {
351    fn default() -> Self {
352        Self::new()
353    }
354}
355
356// ---------------------------------------------------------------------------
357// Pipeline execution
358// ---------------------------------------------------------------------------
359
360/// Check if an error string represents a BrokenPipe (normal pipeline termination).
361fn is_broken_pipe(err: &str) -> bool {
362    err.contains("Broken pipe") || err.contains("BrokenPipe") || err.contains("broken pipe")
363}
364
365/// Execute a shell pipeline (commands separated by `|`).
366///
367/// Each stage runs in its own thread; stages are connected by in-process
368/// pipes. The last stage supports `>` / `>>` redirection.
369pub fn handle_pipeline(
370    line: &str,
371    initial_stdin: Box<dyn Read + Send + 'static>,
372    final_stdout: Box<dyn Write + Send + 'static>,
373    registry: &CommandRegistry,
374    cancel_token: wasibox_core::CancellationToken,
375) -> Result<(), String> {
376    let stages_str: Vec<&str> = line.split('|').collect();
377
378    let mut final_stdout = Some(final_stdout);
379
380    if stages_str.len() == 1 {
381        let mut tokens = shlex::split(stages_str[0].trim())
382            .ok_or_else(|| "Error: Invalid input (quoting error)".to_string())?;
383        let mut stdout: Box<dyn Write + Send> = final_stdout.take().unwrap();
384        if let Some(pos) = tokens.iter().position(|t| t == ">" || t == ">>") {
385            let append = tokens[pos] == ">>";
386            let filename = tokens
387                .get(pos + 1)
388                .ok_or("Error: Missing file for redirection")?;
389            let file = if append {
390                std::fs::OpenOptions::new()
391                    .create(true)
392                    .append(true)
393                    .open(filename)
394            } else {
395                File::create(filename)
396            }
397            .map_err(|e| format!("Error opening file: {}", e))?;
398            stdout = Box::new(file);
399            tokens.truncate(pos);
400        }
401
402        if cancel_token.is_cancelled() {
403            return Err("Interrupted".to_string());
404        }
405        let wrapped_stdin = Box::new(CancelReader {
406            inner: initial_stdin,
407            token: cancel_token.clone(),
408        });
409        let wrapped_stdout = Box::new(CancelWriter {
410            inner: stdout,
411            token: cancel_token.clone(),
412        });
413        let wrapped_stderr = Box::new(CancelWriter {
414            inner: Box::new(io::stderr()),
415            token: cancel_token.clone(),
416        });
417
418        let mut ctx = IoContext::with_cancel(
419            wrapped_stdin,
420            wrapped_stdout,
421            wrapped_stderr,
422            cancel_token.clone(),
423        );
424        let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
425            registry.execute(&tokens, &mut ctx)
426        }))
427        .unwrap_or_else(|_| Err("Thread panicked".to_string()));
428        if cancel_token.is_cancelled() {
429            return Err("Interrupted".to_string());
430        }
431        return res;
432    }
433
434    std::thread::scope(|s| {
435        let mut prev_reader: Box<dyn Read + Send> = initial_stdin;
436        let mut threads = Vec::new();
437
438        for (i, stage_str) in stages_str.iter().enumerate() {
439            let is_last = i == stages_str.len() - 1;
440            let mut tokens = shlex::split(stage_str.trim())
441                .ok_or_else(|| "Error: Invalid input (quoting error)".to_string())?;
442
443            let stdin = std::mem::replace(&mut prev_reader, Box::new(io::empty()));
444            let (stdout, next_reader): (Box<dyn Write + Send>, Option<Box<dyn Read + Send>>) =
445                if is_last {
446                    let mut out: Box<dyn Write + Send> = final_stdout.take().unwrap();
447                    if let Some(pos) = tokens.iter().position(|t| t == ">" || t == ">>") {
448                        let append = tokens[pos] == ">>";
449                        let filename = tokens
450                            .get(pos + 1)
451                            .ok_or("Error: Missing file for redirection")?;
452                        let file = if append {
453                            std::fs::OpenOptions::new()
454                                .create(true)
455                                .append(true)
456                                .open(filename)
457                        } else {
458                            File::create(filename)
459                        }
460                        .map_err(|e| format!("Error opening file: {}", e))?;
461                        out = Box::new(file);
462                        tokens.truncate(pos);
463                    }
464                    (out, None)
465                } else {
466                    let (reader, writer) = create_pipe(cancel_token.clone());
467                    (Box::new(writer), Some(Box::new(reader)))
468                };
469
470            if let Some(r) = next_reader {
471                prev_reader = r;
472            }
473
474            let cancel_clone = cancel_token.clone();
475            let thread = s.spawn(move || {
476                if cancel_clone.is_cancelled() {
477                    return Err("Interrupted".to_string());
478                }
479                let wrapped_stdin = Box::new(CancelReader {
480                    inner: stdin,
481                    token: cancel_clone.clone(),
482                });
483                let wrapped_stdout = Box::new(CancelWriter {
484                    inner: stdout,
485                    token: cancel_clone.clone(),
486                });
487                let wrapped_stderr = Box::new(CancelWriter {
488                    inner: Box::new(io::stderr()),
489                    token: cancel_clone.clone(),
490                });
491
492                let mut ctx = IoContext::with_cancel(
493                    wrapped_stdin,
494                    wrapped_stdout,
495                    wrapped_stderr,
496                    cancel_clone.clone(),
497                );
498                let res = registry.execute(&tokens, &mut ctx);
499                if cancel_clone.is_cancelled() {
500                    return Err("Interrupted".to_string());
501                }
502                res
503            });
504            threads.push((i, thread));
505        }
506
507        // Collect results: BrokenPipe in non-last stages is normal
508        // (upstream terminated by downstream closing the pipe).
509        let last_idx = stages_str.len() - 1;
510        let mut final_res = Ok(());
511        for (idx, thread) in threads {
512            match thread.join() {
513                Ok(Ok(())) => {}
514                Ok(Err(e)) => {
515                    if idx != last_idx && is_broken_pipe(&e) {
516                        continue;
517                    }
518                    if final_res.is_ok() {
519                        final_res = Err(e);
520                    }
521                }
522                Err(_) => {
523                    if final_res.is_ok() {
524                        final_res = Err("Thread panicked".to_string());
525                    }
526                }
527            }
528        }
529        final_res
530    })
531}
532
533/// Execute multiple independent command lines in parallel.
534///
535/// The first line receives `initial_stdin` / `final_stdout`; all subsequent
536/// lines use `io::empty()` / `io::stdout()`.
537pub fn handle_parallel(
538    mut lines: Vec<String>,
539    initial_stdin: Box<dyn Read + Send + 'static>,
540    final_stdout: Box<dyn Write + Send + 'static>,
541    registry: Arc<CommandRegistry>,
542    cancel_token: wasibox_core::CancellationToken,
543) -> Vec<Result<(), String>> {
544    if lines.len() == 1 {
545        let line = lines.pop().unwrap();
546        let mut stdin_opt: Option<Box<dyn Read + Send>> = Some(initial_stdin);
547        let mut stdout_opt: Option<Box<dyn Write + Send>> = Some(final_stdout);
548        let result = (|| {
549            let commands: Vec<&str> = line.split("&&").collect();
550            for cmd in commands {
551                let cmd = cmd.trim();
552                if cmd.is_empty() {
553                    continue;
554                }
555                if cancel_token.is_cancelled() {
556                    return Err("Interrupted".to_string());
557                }
558                let stdin = stdin_opt.take().unwrap_or_else(|| Box::new(io::empty()));
559                let stdout = stdout_opt.take().unwrap_or_else(|| Box::new(io::stdout()));
560                std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
561                    handle_pipeline(cmd, stdin, stdout, &*registry, cancel_token.clone())
562                }))
563                .unwrap_or_else(|_| Err("Thread panicked".to_string()))?;
564            }
565            Ok(())
566        })();
567        return vec![result];
568    }
569
570    let mut handles = Vec::new();
571    let mut stdin_opt: Option<Box<dyn Read + Send>> = Some(initial_stdin);
572    let mut stdout_opt: Option<Box<dyn Write + Send>> = Some(final_stdout);
573
574    for line in lines {
575        let registry = Arc::clone(&registry);
576        let mut stdin_for_thread: Option<Box<dyn Read + Send>> =
577            Some(stdin_opt.take().unwrap_or_else(|| Box::new(io::empty())));
578        let mut stdout_for_thread: Option<Box<dyn Write + Send>> =
579            Some(stdout_opt.take().unwrap_or_else(|| Box::new(io::stdout())));
580
581        let cancel_clone = cancel_token.clone();
582        let handle = std::thread::spawn(move || {
583            let commands: Vec<&str> = line.split("&&").collect();
584            for cmd in commands {
585                let cmd = cmd.trim();
586                if cmd.is_empty() {
587                    continue;
588                }
589                if cancel_clone.is_cancelled() {
590                    return Err("Interrupted".to_string());
591                }
592                let stdin = stdin_for_thread
593                    .take()
594                    .unwrap_or_else(|| Box::new(io::empty()));
595                let stdout = stdout_for_thread
596                    .take()
597                    .unwrap_or_else(|| Box::new(io::stdout()));
598                handle_pipeline(cmd, stdin, stdout, &*registry, cancel_clone.clone())?;
599            }
600            Ok(())
601        });
602        handles.push(handle);
603    }
604
605    handles
606        .into_iter()
607        .map(|h| h.join().unwrap_or(Err("Thread panicked".to_string())))
608        .collect()
609}
610
611/// Execute a complete command line, splitting by `&&` and running sequentially.
612/// Stops execution if any command in the sequence fails.
613pub fn handle_command_line(
614    line: &str,
615    registry: &CommandRegistry,
616    cancel_token: wasibox_core::CancellationToken,
617) -> Result<(), String> {
618    let commands: Vec<&str> = line.split("&&").collect();
619    for cmd in commands {
620        let cmd = cmd.trim();
621        if cmd.is_empty() {
622            continue;
623        }
624        if cancel_token.is_cancelled() {
625            return Err("Interrupted".to_string());
626        }
627        handle_pipeline(
628            cmd,
629            Box::new(io::stdin()),
630            Box::new(io::stdout()),
631            registry,
632            cancel_token.clone(),
633        )?;
634    }
635    Ok(())
636}
637
638// ---------------------------------------------------------------------------
639// Pipe implementation
640// ---------------------------------------------------------------------------
641
642struct CancelReader<R: Read> {
643    inner: R,
644    token: wasibox_core::CancellationToken,
645}
646impl<R: Read> Read for CancelReader<R> {
647    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
648        if self.token.is_cancelled() {
649            return Err(io::Error::new(io::ErrorKind::BrokenPipe, "Interrupted"));
650        }
651        self.inner.read(buf)
652    }
653}
654
655struct CancelWriter<W: Write> {
656    inner: W,
657    token: wasibox_core::CancellationToken,
658}
659impl<W: Write> Write for CancelWriter<W> {
660    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
661        if self.token.is_cancelled() {
662            return Err(io::Error::new(io::ErrorKind::BrokenPipe, "Interrupted"));
663        }
664        self.inner.write(buf)
665    }
666    fn flush(&mut self) -> io::Result<()> {
667        if self.token.is_cancelled() {
668            return Err(io::Error::new(io::ErrorKind::BrokenPipe, "Interrupted"));
669        }
670        self.inner.flush()
671    }
672}
673
674struct PipeReader {
675    rx: std::sync::mpsc::Receiver<Vec<u8>>,
676    buffer: Vec<u8>,
677    pos: usize,
678    token: wasibox_core::CancellationToken,
679}
680impl Read for PipeReader {
681    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
682        if self.token.is_cancelled() {
683            return Err(io::Error::new(io::ErrorKind::BrokenPipe, "Interrupted"));
684        }
685        if self.pos >= self.buffer.len() {
686            loop {
687                if self.token.is_cancelled() {
688                    return Err(io::Error::new(io::ErrorKind::BrokenPipe, "Interrupted"));
689                }
690                match self.rx.recv_timeout(std::time::Duration::from_millis(100)) {
691                    Ok(data) => {
692                        self.buffer = data;
693                        self.pos = 0;
694                        break;
695                    }
696                    Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
697                    Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return Ok(0),
698                }
699            }
700        }
701        let available = self.buffer.len() - self.pos;
702        let to_copy = std::cmp::min(available, buf.len());
703        buf[..to_copy].copy_from_slice(&self.buffer[self.pos..self.pos + to_copy]);
704        self.pos += to_copy;
705        Ok(to_copy)
706    }
707}
708
709struct PipeWriter {
710    tx: std::sync::mpsc::SyncSender<Vec<u8>>,
711    token: wasibox_core::CancellationToken,
712}
713impl Write for PipeWriter {
714    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
715        if self.token.is_cancelled() {
716            return Err(io::Error::new(io::ErrorKind::BrokenPipe, "Interrupted"));
717        }
718        self.tx
719            .send(buf.to_vec())
720            .map_err(|e| io::Error::new(io::ErrorKind::BrokenPipe, e))?;
721        Ok(buf.len())
722    }
723    fn flush(&mut self) -> io::Result<()> {
724        Ok(())
725    }
726}
727
728fn create_pipe(token: wasibox_core::CancellationToken) -> (PipeReader, PipeWriter) {
729    let (tx, rx) = std::sync::mpsc::sync_channel(1024);
730    (
731        PipeReader {
732            rx,
733            buffer: Vec::new(),
734            pos: 0,
735            token: token.clone(),
736        },
737        PipeWriter { tx, token },
738    )
739}
740
741// ---------------------------------------------------------------------------
742// Stdin Multiplexer (for signals on platforms without them)
743// ---------------------------------------------------------------------------
744
745use std::sync::mpsc::{self, Receiver, Sender};
746
747/// A shared stdin reader that can be monitored by a background thread
748/// to intercept control characters (like Ctrl-C) on platforms without signals.
749pub struct StdinMultiplexer {
750    tx_subscribers: Mutex<Vec<Sender<Vec<u8>>>>,
751    cancel_token: wasibox_core::CancellationToken,
752}
753
754impl StdinMultiplexer {
755    pub fn new(cancel_token: wasibox_core::CancellationToken) -> Arc<Self> {
756        let mux = Arc::new(Self {
757            tx_subscribers: Mutex::new(Vec::new()),
758            cancel_token,
759        });
760
761        let mux_clone = Arc::clone(&mux);
762        std::thread::spawn(move || {
763            let mut buf = [0u8; 1024];
764            let mut stdin = io::stdin();
765            loop {
766                match stdin.read(&mut buf) {
767                    Ok(0) => break, // EOF
768                    Ok(n) => {
769                        let data = &buf[..n];
770                        // Check for Ctrl-C (byte 3)
771                        for &b in data {
772                            if b == 3 {
773                                mux_clone.cancel_token.cancel();
774                            }
775                        }
776                        // Broadcast to all active subscribers
777                        let mut subs = mux_clone.tx_subscribers.lock().unwrap();
778                        subs.retain(|tx| tx.send(data.to_vec()).is_ok());
779                    }
780                    Err(_) => {
781                        // On some platforms, read error might be temporary,
782                        // but for stdin it usually means we should stop.
783                        std::thread::sleep(std::time::Duration::from_millis(100));
784                    }
785                }
786            }
787        });
788
789        mux
790    }
791
792    pub fn subscribe(&self) -> MultiplexedReader {
793        let (tx, rx) = mpsc::channel();
794        self.tx_subscribers.lock().unwrap().push(tx);
795        MultiplexedReader {
796            rx,
797            buffer: Vec::new(),
798            pos: 0,
799        }
800    }
801}
802
803pub struct MultiplexedReader {
804    rx: Receiver<Vec<u8>>,
805    buffer: Vec<u8>,
806    pos: usize,
807}
808
809impl Read for MultiplexedReader {
810    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
811        if self.pos >= self.buffer.len() {
812            match self.rx.recv() {
813                Ok(data) => {
814                    self.buffer = data;
815                    self.pos = 0;
816                }
817                Err(_) => return Ok(0),
818            }
819        }
820        let available = self.buffer.len() - self.pos;
821        let to_copy = std::cmp::min(available, buf.len());
822        buf[..to_copy].copy_from_slice(&self.buffer[self.pos..self.pos + to_copy]);
823        self.pos += to_copy;
824        Ok(to_copy)
825    }
826}
827
828/// A thread-safe writer backed by a shared `Vec<u8>`. Useful for tests.
829pub struct ArcVecWriter {
830    pub inner: Arc<Mutex<Vec<u8>>>,
831}
832impl Write for ArcVecWriter {
833    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
834        let mut inner = self
835            .inner
836            .lock()
837            .map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
838        inner.extend_from_slice(buf);
839        Ok(buf.len())
840    }
841    fn flush(&mut self) -> io::Result<()> {
842        Ok(())
843    }
844}
845
846// ---------------------------------------------------------------------------
847// Tests
848// ---------------------------------------------------------------------------
849
850#[cfg(test)]
851mod tests {
852    use super::*;
853    use std::io::{BufRead, BufReader, Cursor};
854
855    fn get_temp_dir() -> tempfile::TempDir {
856        #[cfg(target_os = "wasi")]
857        {
858            tempfile::Builder::new()
859                .prefix("test_")
860                .tempdir_in(".")
861                .expect("Failed to create temp dir in current directory")
862        }
863        #[cfg(not(target_os = "wasi"))]
864        {
865            tempfile::tempdir().expect("Failed to create system temp dir")
866        }
867    }
868
869    /// Shorthand: registry with all builtins (shell + wasibox-core)
870    fn builtins() -> CommandRegistry {
871        CommandRegistry::with_builtins()
872    }
873
874    // ── basic pipeline tests ────────────────────────────────────────────
875
876    #[test]
877    fn test_simple_echo() {
878        let out = Arc::new(Mutex::new(Vec::new()));
879        handle_pipeline(
880            "echo hello",
881            Box::new(Cursor::new("")),
882            Box::new(ArcVecWriter {
883                inner: Arc::clone(&out),
884            }),
885            &builtins(),
886            wasibox_core::CancellationToken::new(),
887        )
888        .unwrap();
889        let buf = out.lock().unwrap();
890        assert_eq!(String::from_utf8_lossy(&buf).trim(), "hello");
891    }
892
893    #[test]
894    fn test_single_stage_pipeline_runs_inline() {
895        let caller_thread = format!("{:?}", std::thread::current().id());
896        let observed_thread = Arc::new(Mutex::new(None));
897        let observed_thread_clone = Arc::clone(&observed_thread);
898
899        let mut reg = CommandRegistry::new();
900        reg.remove_fallback();
901        reg.register("record-thread", move |_args, _ctx| {
902            *observed_thread_clone.lock().unwrap() =
903                Some(format!("{:?}", std::thread::current().id()));
904            Ok(())
905        });
906
907        handle_pipeline(
908            "record-thread",
909            Box::new(Cursor::new("")),
910            Box::new(io::sink()),
911            &reg,
912            wasibox_core::CancellationToken::new(),
913        )
914        .unwrap();
915
916        assert_eq!(
917            observed_thread.lock().unwrap().as_deref(),
918            Some(caller_thread.as_str())
919        );
920    }
921
922    #[test]
923    fn test_single_stage_pipeline_catches_panics() {
924        let mut reg = CommandRegistry::new();
925        reg.remove_fallback();
926        reg.register("panic", |_args, _ctx| {
927            panic!("intentional test panic");
928        });
929
930        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
931            handle_pipeline(
932                "panic",
933                Box::new(Cursor::new("")),
934                Box::new(io::sink()),
935                &reg,
936                wasibox_core::CancellationToken::new(),
937            )
938        }));
939
940        assert!(
941            result.is_ok(),
942            "single-stage panic should be converted to Err"
943        );
944        assert_eq!(result.unwrap(), Err("Thread panicked".to_string()));
945    }
946
947    #[test]
948    fn test_pipe_echo_cat() {
949        let out = Arc::new(Mutex::new(Vec::new()));
950        handle_pipeline(
951            "echo hello | cat",
952            Box::new(Cursor::new("")),
953            Box::new(ArcVecWriter {
954                inner: Arc::clone(&out),
955            }),
956            &builtins(),
957            wasibox_core::CancellationToken::new(),
958        )
959        .unwrap();
960        let buf = out.lock().unwrap();
961        assert_eq!(String::from_utf8_lossy(&buf).trim(), "hello");
962    }
963
964    #[test]
965    fn test_pipe_grep() {
966        let out = Arc::new(Mutex::new(Vec::new()));
967        handle_pipeline(
968            "echo world | grep world",
969            Box::new(Cursor::new("")),
970            Box::new(ArcVecWriter {
971                inner: Arc::clone(&out),
972            }),
973            &builtins(),
974            wasibox_core::CancellationToken::new(),
975        )
976        .unwrap();
977        let buf = out.lock().unwrap();
978        assert_eq!(String::from_utf8_lossy(&buf).trim(), "world");
979    }
980
981    #[test]
982    fn test_redirection_create() {
983        let dir = get_temp_dir();
984        let file_path = dir.path().join("test_create.txt");
985        let cmd = format!("echo hello > \"{}\"", file_path.display());
986        handle_pipeline(
987            &cmd,
988            Box::new(Cursor::new("")),
989            Box::new(io::sink()),
990            &builtins(),
991            wasibox_core::CancellationToken::new(),
992        )
993        .unwrap();
994        let content = std::fs::read_to_string(file_path).unwrap();
995        assert_eq!(content.trim(), "hello");
996    }
997
998    #[test]
999    fn test_redirection_append() {
1000        let dir = get_temp_dir();
1001        let file_path = dir.path().join("test_append.txt");
1002        let cmd1 = format!("echo hello > \"{}\"", file_path.display());
1003        handle_pipeline(
1004            &cmd1,
1005            Box::new(Cursor::new("")),
1006            Box::new(io::sink()),
1007            &builtins(),
1008            wasibox_core::CancellationToken::new(),
1009        )
1010        .unwrap();
1011        let cmd2 = format!("echo world >> \"{}\"", file_path.display());
1012        handle_pipeline(
1013            &cmd2,
1014            Box::new(Cursor::new("")),
1015            Box::new(io::sink()),
1016            &builtins(),
1017            wasibox_core::CancellationToken::new(),
1018        )
1019        .unwrap();
1020        let content = std::fs::read_to_string(file_path).unwrap();
1021        assert!(content.contains("hello"));
1022        assert!(content.contains("world"));
1023    }
1024
1025    #[test]
1026    fn test_complex_pipeline() {
1027        let out = Arc::new(Mutex::new(Vec::new()));
1028        handle_pipeline(
1029            "echo hello | grep h | wc -c",
1030            Box::new(Cursor::new("")),
1031            Box::new(ArcVecWriter {
1032                inner: Arc::clone(&out),
1033            }),
1034            &builtins(),
1035            wasibox_core::CancellationToken::new(),
1036        )
1037        .unwrap();
1038        let buf = out.lock().unwrap();
1039        assert_eq!(String::from_utf8_lossy(&buf).trim(), "6");
1040    }
1041
1042    // ── custom command tests ────────────────────────────────────────────
1043
1044    #[test]
1045    fn test_custom_command() {
1046        let mut reg = CommandRegistry::with_builtins();
1047        reg.register("magic", |_args, ctx| {
1048            write!(ctx.stdout, "magic happen").map_err(|e| e.to_string())
1049        });
1050
1051        let out = Arc::new(Mutex::new(Vec::new()));
1052        handle_pipeline(
1053            "magic",
1054            Box::new(io::empty()),
1055            Box::new(ArcVecWriter {
1056                inner: Arc::clone(&out),
1057            }),
1058            &reg,
1059            wasibox_core::CancellationToken::new(),
1060        )
1061        .unwrap();
1062        let buf = out.lock().unwrap();
1063        assert_eq!(String::from_utf8_lossy(&buf), "magic happen");
1064    }
1065
1066    #[test]
1067    fn test_custom_command_in_pipeline() {
1068        // Custom "double" command that duplicates each input line
1069        let mut reg = CommandRegistry::with_builtins();
1070        reg.register("double", |_args, ctx| {
1071            for line in BufReader::new(&mut ctx.stdin).lines() {
1072                let line = match line {
1073                    Ok(l) => l,
1074                    Err(_) => break,
1075                };
1076                if writeln!(ctx.stdout, "{}", line).is_err() {
1077                    break;
1078                }
1079                if writeln!(ctx.stdout, "{}", line).is_err() {
1080                    break;
1081                }
1082            }
1083            Ok(())
1084        });
1085
1086        let out = Arc::new(Mutex::new(Vec::new()));
1087        handle_pipeline(
1088            "echo hello | double",
1089            Box::new(io::empty()),
1090            Box::new(ArcVecWriter {
1091                inner: Arc::clone(&out),
1092            }),
1093            &reg,
1094            wasibox_core::CancellationToken::new(),
1095        )
1096        .unwrap();
1097        let buf = out.lock().unwrap();
1098        assert_eq!(String::from_utf8_lossy(&buf).trim(), "hello\nhello");
1099    }
1100
1101    #[test]
1102    fn test_override_builtin() {
1103        // Override "echo" with a custom version
1104        let mut reg = CommandRegistry::with_builtins();
1105        reg.register("echo", |args, ctx| {
1106            let msg = args[1..].join(" ").to_uppercase();
1107            writeln!(ctx.stdout, "{}", msg).map_err(|e| e.to_string())
1108        });
1109
1110        let out = Arc::new(Mutex::new(Vec::new()));
1111        handle_pipeline(
1112            "echo hello",
1113            Box::new(io::empty()),
1114            Box::new(ArcVecWriter {
1115                inner: Arc::clone(&out),
1116            }),
1117            &reg,
1118            wasibox_core::CancellationToken::new(),
1119        )
1120        .unwrap();
1121        let buf = out.lock().unwrap();
1122        assert_eq!(String::from_utf8_lossy(&buf).trim(), "HELLO");
1123    }
1124
1125    // ── fully external pipeline (no builtins at all) ────────────────────
1126
1127    #[test]
1128    fn test_all_external_pipeline() {
1129        let mut reg = CommandRegistry::new();
1130        reg.remove_fallback(); // no wasibox-core fallback
1131
1132        // "count" — infinite counter
1133        reg.register("count", |_args, ctx| {
1134            for i in 1u64.. {
1135                if writeln!(ctx.stdout, "{}", i).is_err() {
1136                    break;
1137                }
1138            }
1139            Ok(())
1140        });
1141
1142        // "filter2" — keep lines containing '2'
1143        reg.register("filter2", |_args, ctx| {
1144            for line in BufReader::new(&mut ctx.stdin).lines() {
1145                let line = match line {
1146                    Ok(l) => l,
1147                    Err(_) => break,
1148                };
1149                if line.contains('2') {
1150                    if writeln!(ctx.stdout, "{}", line).is_err() {
1151                        break;
1152                    }
1153                }
1154            }
1155            Ok(())
1156        });
1157
1158        // "take N" — first N lines
1159        reg.register("take", |args, ctx| {
1160            let n: usize = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(5);
1161            for (i, line) in BufReader::new(&mut ctx.stdin).lines().enumerate() {
1162                if i >= n {
1163                    break;
1164                }
1165                let line = match line {
1166                    Ok(l) => l,
1167                    Err(_) => break,
1168                };
1169                if writeln!(ctx.stdout, "{}", line).is_err() {
1170                    break;
1171                }
1172            }
1173            Ok(())
1174        });
1175
1176        let out = Arc::new(Mutex::new(Vec::new()));
1177        handle_pipeline(
1178            "count | filter2 | take 3",
1179            Box::new(io::empty()),
1180            Box::new(ArcVecWriter {
1181                inner: Arc::clone(&out),
1182            }),
1183            &reg,
1184            wasibox_core::CancellationToken::new(),
1185        )
1186        .unwrap();
1187
1188        let buf = out.lock().unwrap();
1189        let result = String::from_utf8_lossy(&buf);
1190        let lines: Vec<&str> = result.trim().lines().collect();
1191        assert_eq!(lines.len(), 3);
1192        for line in &lines {
1193            assert!(line.contains('2'), "expected '2' in line, got: {}", line);
1194        }
1195        assert_eq!(lines[0], "2");
1196        assert_eq!(lines[1], "12");
1197        assert_eq!(lines[2], "20");
1198    }
1199
1200    // ── parallel tests ──────────────────────────────────────────────────
1201
1202    #[test]
1203    fn test_parallel_execution() {
1204        let mut reg = CommandRegistry::with_builtins();
1205        reg.register("slow", |_args, _ctx| {
1206            std::thread::sleep(std::time::Duration::from_millis(100));
1207            Ok(())
1208        });
1209        let registry = Arc::new(reg);
1210
1211        let lines = vec!["slow".to_string(), "slow".to_string(), "slow".to_string()];
1212        let start = std::time::Instant::now();
1213        let results = handle_parallel(
1214            lines,
1215            Box::new(io::empty()),
1216            Box::new(io::sink()),
1217            registry,
1218            wasibox_core::CancellationToken::new(),
1219        );
1220        let duration = start.elapsed();
1221
1222        assert_eq!(results.len(), 3);
1223        for res in results {
1224            assert!(res.is_ok());
1225        }
1226        assert!(duration < std::time::Duration::from_millis(250));
1227    }
1228
1229    #[test]
1230    fn test_single_parallel_command_runs_inline() {
1231        let caller_thread = std::thread::current().id();
1232        let observed_thread = Arc::new(Mutex::new(None));
1233        let observed_thread_for_cmd = Arc::clone(&observed_thread);
1234
1235        let mut reg = CommandRegistry::new();
1236        reg.remove_fallback();
1237        reg.register("record-thread", move |_args, _ctx| {
1238            *observed_thread_for_cmd.lock().unwrap() = Some(std::thread::current().id());
1239            Ok(())
1240        });
1241
1242        let results = handle_parallel(
1243            vec!["record-thread".to_string()],
1244            Box::new(io::empty()),
1245            Box::new(io::sink()),
1246            Arc::new(reg),
1247            wasibox_core::CancellationToken::new(),
1248        );
1249
1250        assert_eq!(results, vec![Ok(())]);
1251        assert_eq!(*observed_thread.lock().unwrap(), Some(caller_thread));
1252    }
1253
1254    #[test]
1255    fn test_single_parallel_command_catches_panics() {
1256        let mut reg = CommandRegistry::new();
1257        reg.remove_fallback();
1258        reg.register("panic", |_args, _ctx| {
1259            panic!("intentional test panic");
1260        });
1261
1262        let results = handle_parallel(
1263            vec!["panic".to_string()],
1264            Box::new(io::empty()),
1265            Box::new(io::sink()),
1266            Arc::new(reg),
1267            wasibox_core::CancellationToken::new(),
1268        );
1269
1270        assert_eq!(results, vec![Err("Thread panicked".to_string())]);
1271    }
1272
1273    // ── streaming / infinite pipeline tests ─────────────────────────────
1274
1275    #[test]
1276    fn test_streaming_pipeline() {
1277        let out = Arc::new(Mutex::new(Vec::new()));
1278        handle_pipeline(
1279            "yes | head -n 2",
1280            Box::new(io::empty()),
1281            Box::new(ArcVecWriter {
1282                inner: Arc::clone(&out),
1283            }),
1284            &builtins(),
1285            wasibox_core::CancellationToken::new(),
1286        )
1287        .unwrap();
1288        let buf = out.lock().unwrap();
1289        assert_eq!(String::from_utf8_lossy(&buf).trim(), "y\ny");
1290    }
1291
1292    #[test]
1293    fn test_seq_pipeline_head() {
1294        let out = Arc::new(Mutex::new(Vec::new()));
1295        handle_pipeline(
1296            "seq | head -n 5",
1297            Box::new(io::empty()),
1298            Box::new(ArcVecWriter {
1299                inner: Arc::clone(&out),
1300            }),
1301            &builtins(),
1302            wasibox_core::CancellationToken::new(),
1303        )
1304        .unwrap();
1305        let buf = out.lock().unwrap();
1306        assert_eq!(String::from_utf8_lossy(&buf).trim(), "1\n2\n3\n4\n5");
1307    }
1308
1309    #[test]
1310    fn test_seq_pipeline_grep_head() {
1311        let out = Arc::new(Mutex::new(Vec::new()));
1312        handle_pipeline(
1313            "seq | grep 2 | head -n 3",
1314            Box::new(io::empty()),
1315            Box::new(ArcVecWriter {
1316                inner: Arc::clone(&out),
1317            }),
1318            &builtins(),
1319            wasibox_core::CancellationToken::new(),
1320        )
1321        .unwrap();
1322        let buf = out.lock().unwrap();
1323        let result = String::from_utf8_lossy(&buf);
1324        let lines: Vec<&str> = result.trim().lines().collect();
1325        assert_eq!(lines.len(), 3);
1326        for line in &lines {
1327            assert!(line.contains('2'));
1328        }
1329        assert_eq!(lines[0], "2");
1330        assert_eq!(lines[1], "12");
1331        assert_eq!(lines[2], "20");
1332    }
1333
1334    #[test]
1335    fn test_seq_pipeline_grep_head_5() {
1336        let out = Arc::new(Mutex::new(Vec::new()));
1337        handle_pipeline(
1338            "seq | grep 2 | head -n 5",
1339            Box::new(io::empty()),
1340            Box::new(ArcVecWriter {
1341                inner: Arc::clone(&out),
1342            }),
1343            &builtins(),
1344            wasibox_core::CancellationToken::new(),
1345        )
1346        .unwrap();
1347        let buf = out.lock().unwrap();
1348        let result = String::from_utf8_lossy(&buf);
1349        let lines: Vec<&str> = result.trim().lines().collect();
1350        assert_eq!(lines.len(), 5);
1351        assert_eq!(lines[0], "2");
1352        assert_eq!(lines[1], "12");
1353        assert_eq!(lines[2], "20");
1354        assert_eq!(lines[3], "21");
1355        assert_eq!(lines[4], "22");
1356    }
1357
1358    #[test]
1359    fn test_seq_finite() {
1360        let out = Arc::new(Mutex::new(Vec::new()));
1361        handle_pipeline(
1362            "seq 3",
1363            Box::new(io::empty()),
1364            Box::new(ArcVecWriter {
1365                inner: Arc::clone(&out),
1366            }),
1367            &builtins(),
1368            wasibox_core::CancellationToken::new(),
1369        )
1370        .unwrap();
1371        let buf = out.lock().unwrap();
1372        assert_eq!(String::from_utf8_lossy(&buf).trim(), "1\n2\n3");
1373    }
1374
1375    #[test]
1376    fn test_seq_range() {
1377        let out = Arc::new(Mutex::new(Vec::new()));
1378        handle_pipeline(
1379            "seq 5 8",
1380            Box::new(io::empty()),
1381            Box::new(ArcVecWriter {
1382                inner: Arc::clone(&out),
1383            }),
1384            &builtins(),
1385            wasibox_core::CancellationToken::new(),
1386        )
1387        .unwrap();
1388        let buf = out.lock().unwrap();
1389        assert_eq!(String::from_utf8_lossy(&buf).trim(), "5\n6\n7\n8");
1390    }
1391
1392    #[test]
1393    fn test_seq_step() {
1394        let out = Arc::new(Mutex::new(Vec::new()));
1395        handle_pipeline(
1396            "seq 1 2 10",
1397            Box::new(io::empty()),
1398            Box::new(ArcVecWriter {
1399                inner: Arc::clone(&out),
1400            }),
1401            &builtins(),
1402            wasibox_core::CancellationToken::new(),
1403        )
1404        .unwrap();
1405        let buf = out.lock().unwrap();
1406        assert_eq!(String::from_utf8_lossy(&buf).trim(), "1\n3\n5\n7\n9");
1407    }
1408    #[test]
1409    fn test_cd_and_ls() {
1410        let dir = get_temp_dir();
1411        // Create files inside the temp dir
1412        std::fs::write(dir.path().join("aaa.txt"), "hello").unwrap();
1413        std::fs::write(dir.path().join("bbb.txt"), "world").unwrap();
1414
1415        let original_cwd = env::current_dir().unwrap();
1416        let reg = builtins();
1417
1418        // cd into the temp directory
1419        let cd_cmd = format!("cd \"{}\"", dir.path().display());
1420        handle_pipeline(
1421            &cd_cmd,
1422            Box::new(io::empty()),
1423            Box::new(io::sink()),
1424            &reg,
1425            wasibox_core::CancellationToken::new(),
1426        )
1427        .unwrap();
1428
1429        // ls the current directory (should now be the temp dir)
1430        let out = Arc::new(Mutex::new(Vec::new()));
1431        handle_pipeline(
1432            "ls",
1433            Box::new(io::empty()),
1434            Box::new(ArcVecWriter {
1435                inner: Arc::clone(&out),
1436            }),
1437            &reg,
1438            wasibox_core::CancellationToken::new(),
1439        )
1440        .unwrap();
1441
1442        let buf = out.lock().unwrap();
1443        let output = String::from_utf8_lossy(&buf);
1444        assert!(
1445            output.contains("aaa.txt"),
1446            "expected aaa.txt in ls output, got: {}",
1447            output
1448        );
1449        assert!(
1450            output.contains("bbb.txt"),
1451            "expected bbb.txt in ls output, got: {}",
1452            output
1453        );
1454
1455        // Restore original cwd
1456        env::set_current_dir(original_cwd).unwrap();
1457    }
1458
1459    // ── sequence (&&) tests ─────────────────────────────────────────────
1460
1461    #[test]
1462    fn test_command_line_sequence() {
1463        let mut reg = CommandRegistry::with_builtins();
1464        let counter = Arc::new(Mutex::new(0));
1465        let c1 = Arc::clone(&counter);
1466        reg.register("inc", move |_args, _ctx| {
1467            *c1.lock().unwrap() += 1;
1468            Ok(())
1469        });
1470
1471        // inc && inc && inc
1472        crate::handle_command_line(
1473            "inc && inc && inc",
1474            &reg,
1475            wasibox_core::CancellationToken::new(),
1476        )
1477        .unwrap();
1478        assert_eq!(*counter.lock().unwrap(), 3);
1479    }
1480
1481    #[test]
1482    fn test_command_line_short_circuit() {
1483        let mut reg = CommandRegistry::with_builtins();
1484        let counter = Arc::new(Mutex::new(0));
1485        let c1 = Arc::clone(&counter);
1486        reg.register("inc", move |_args, _ctx| {
1487            *c1.lock().unwrap() += 1;
1488            Ok(())
1489        });
1490        reg.register("fail", |_args, _ctx| Err("simulated failure".to_string()));
1491
1492        // inc && fail && inc
1493        // Should stop after "fail"
1494        let res = crate::handle_command_line(
1495            "inc && fail && inc",
1496            &reg,
1497            wasibox_core::CancellationToken::new(),
1498        );
1499        assert!(res.is_err());
1500        assert_eq!(res.unwrap_err(), "simulated failure");
1501        assert_eq!(*counter.lock().unwrap(), 1); // Only the first "inc" should execute
1502    }
1503
1504    #[test]
1505    fn test_handle_parallel_cancel() {
1506        println!("STARTING TEST");
1507        let registry = Arc::new(builtins());
1508        let cancel_token = wasibox_core::CancellationToken::new();
1509        let cancel_clone = cancel_token.clone();
1510
1511        let thread = std::thread::spawn(move || {
1512            println!("THREAD SPAWNED");
1513            super::handle_parallel(
1514                vec!["yes".to_string()],
1515                Box::new(std::io::empty()),
1516                Box::new(std::io::sink()),
1517                registry,
1518                cancel_clone,
1519            )
1520        });
1521
1522        std::thread::sleep(std::time::Duration::from_millis(10));
1523        println!("CANCELLING TOKEN");
1524        cancel_token.cancel();
1525
1526        println!("JOINING THREAD");
1527        let results = thread.join().unwrap();
1528        println!("RESULTS: {:?}", results);
1529        assert_eq!(results.len(), 1);
1530        assert!(results[0].is_err());
1531        assert_eq!(results[0].as_ref().unwrap_err(), "Interrupted");
1532    }
1533
1534    #[test]
1535    fn test_pipeline_cancel() {
1536        let registry = builtins();
1537        let cancel_token = wasibox_core::CancellationToken::new();
1538        let cancel_clone = cancel_token.clone();
1539
1540        let thread = std::thread::spawn(move || {
1541            super::handle_pipeline(
1542                "yes | grep y",
1543                Box::new(std::io::empty()),
1544                Box::new(std::io::sink()),
1545                &registry,
1546                cancel_clone,
1547            )
1548        });
1549
1550        std::thread::sleep(std::time::Duration::from_millis(10));
1551        cancel_token.cancel();
1552
1553        let result = thread.join().unwrap();
1554        assert!(result.is_err());
1555        assert_eq!(result.unwrap_err(), "Interrupted");
1556    }
1557
1558    #[test]
1559    fn test_seq_redirection_parallel_cancel() {
1560        let dir = get_temp_dir();
1561        let file_path = dir.path().join("i.txt");
1562        let cmd = format!("seq > \"{}\"", file_path.display());
1563
1564        let registry = Arc::new(builtins());
1565        let cancel_token = wasibox_core::CancellationToken::new();
1566        let cancel_clone = cancel_token.clone();
1567
1568        let thread = std::thread::spawn(move || {
1569            super::handle_parallel(
1570                vec![cmd],
1571                Box::new(std::io::empty()),
1572                Box::new(std::io::sink()),
1573                registry,
1574                cancel_clone,
1575            )
1576        });
1577
1578        // Give it some time to start writing
1579        std::thread::sleep(std::time::Duration::from_millis(50));
1580        cancel_token.cancel();
1581
1582        let results = thread.join().unwrap();
1583        assert_eq!(results.len(), 1);
1584        assert!(results[0].is_err());
1585        assert_eq!(results[0].as_ref().unwrap_err(), "Interrupted");
1586
1587        // Verify that the file was created and contains some data
1588        assert!(file_path.exists());
1589        let content = std::fs::read_to_string(&file_path).unwrap();
1590        assert!(!content.is_empty(), "File should not be empty");
1591
1592        // Ensure it stopped
1593        println!(
1594            "Final sequence number written: {}",
1595            content.lines().last().unwrap_or("none")
1596        );
1597    }
1598}