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::{LineReader, LineHandler, LoopAction};
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(args.iter().cloned());
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    /// Register (or replace) a command.
206    pub fn register<F>(&mut self, name: impl Into<String>, handler: F)
207    where
208        F: Fn(&[String], &mut IoContext) -> Result<(), String> + Send + Sync + 'static,
209    {
210        self.commands.insert(name.into(), Arc::new(handler));
211    }
212
213    /// Set a fallback handler invoked when no explicit command matches.
214    pub fn set_fallback<F>(&mut self, handler: F)
215    where
216        F: Fn(&[String], &mut IoContext) -> Result<(), String> + Send + Sync + 'static,
217    {
218        self.fallback = Some(Arc::new(handler));
219    }
220
221    /// Remove the fallback handler. Unknown commands will return an error.
222    pub fn remove_fallback(&mut self) {
223        self.fallback = None;
224    }
225
226    /// Look up and execute a command.
227    pub fn execute(&self, args: &[String], ctx: &mut IoContext) -> Result<(), String> {
228        if args.is_empty() {
229            return Ok(());
230        }
231        let cmd = &args[0];
232
233        if let Some(handler) = self.commands.get(cmd.as_str()) {
234            return handler(args, ctx);
235        }
236        if let Some(ref fallback) = self.fallback {
237            return fallback(args, ctx);
238        }
239        Err(format!("command not found: {}", cmd))
240    }
241}
242
243impl Default for CommandRegistry {
244    fn default() -> Self {
245        Self::new()
246    }
247}
248
249// ---------------------------------------------------------------------------
250// Pipeline execution
251// ---------------------------------------------------------------------------
252
253/// Check if an error string represents a BrokenPipe (normal pipeline termination).
254fn is_broken_pipe(err: &str) -> bool {
255    err.contains("Broken pipe")
256        || err.contains("BrokenPipe")
257        || err.contains("broken pipe")
258}
259
260/// Execute a shell pipeline (commands separated by `|`).
261///
262/// Each stage runs in its own thread; stages are connected by in-process
263/// pipes. The last stage supports `>` / `>>` redirection.
264pub fn handle_pipeline(
265    line: &str,
266    initial_stdin: Box<dyn Read + Send + 'static>,
267    final_stdout: Box<dyn Write + Send + 'static>,
268    registry: &CommandRegistry,
269    cancel_token: wasibox_core::CancellationToken,
270) -> Result<(), String> {
271    let stages_str: Vec<&str> = line.split('|').collect();
272
273    let mut final_stdout = Some(final_stdout);
274
275    std::thread::scope(|s| {
276        let mut prev_reader: Box<dyn Read + Send> = initial_stdin;
277        let mut threads = Vec::new();
278
279        for (i, stage_str) in stages_str.iter().enumerate() {
280            let is_last = i == stages_str.len() - 1;
281            let mut tokens = shlex::split(stage_str.trim())
282                .ok_or_else(|| "Error: Invalid input (quoting error)".to_string())?;
283
284            let stdin = std::mem::replace(&mut prev_reader, Box::new(io::empty()));
285            let (stdout, next_reader): (Box<dyn Write + Send>, Option<Box<dyn Read + Send>>) = if is_last {
286                let mut out: Box<dyn Write + Send> = final_stdout.take().unwrap();
287                if let Some(pos) = tokens.iter().position(|t| t == ">" || t == ">>") {
288                    let append = tokens[pos] == ">>";
289                    let filename = tokens.get(pos + 1).ok_or("Error: Missing file for redirection")?;
290                    let file = if append {
291                        std::fs::OpenOptions::new().create(true).append(true).open(filename)
292                    } else {
293                        File::create(filename)
294                    }.map_err(|e| format!("Error opening file: {}", e))?;
295                    out = Box::new(file);
296                    tokens.truncate(pos);
297                }
298                (out, None)
299            } else {
300                let (reader, writer) = create_pipe(cancel_token.clone());
301                (Box::new(writer), Some(Box::new(reader)))
302            };
303
304            if let Some(r) = next_reader {
305                prev_reader = r;
306            }
307
308            let cancel_clone = cancel_token.clone();
309            let thread = s.spawn(move || {
310                if cancel_clone.is_cancelled() {
311                    return Err("Interrupted".to_string());
312                }
313                let wrapped_stdin = Box::new(CancelReader { inner: stdin, token: cancel_clone.clone() });
314                let wrapped_stdout = Box::new(CancelWriter { inner: stdout, token: cancel_clone.clone() });
315                let wrapped_stderr = Box::new(CancelWriter { inner: Box::new(io::stderr()), token: cancel_clone.clone() });
316                
317                let mut ctx = IoContext::with_cancel(wrapped_stdin, wrapped_stdout, wrapped_stderr, cancel_clone.clone());
318                let res = registry.execute(&tokens, &mut ctx);
319                if cancel_clone.is_cancelled() {
320                    return Err("Interrupted".to_string());
321                }
322                res
323            });
324            threads.push((i, thread));
325        }
326
327        // Collect results: BrokenPipe in non-last stages is normal
328        // (upstream terminated by downstream closing the pipe).
329        let last_idx = stages_str.len() - 1;
330        let mut final_res = Ok(());
331        for (idx, thread) in threads {
332            match thread.join() {
333                Ok(Ok(())) => {}
334                Ok(Err(e)) => {
335                    if idx != last_idx && is_broken_pipe(&e) {
336                        continue;
337                    }
338                    if final_res.is_ok() {
339                        final_res = Err(e);
340                    }
341                }
342                Err(_) => {
343                    if final_res.is_ok() {
344                        final_res = Err("Thread panicked".to_string());
345                    }
346                }
347            }
348        }
349        final_res
350    })
351}
352
353/// Execute multiple independent command lines in parallel.
354///
355/// The first line receives `initial_stdin` / `final_stdout`; all subsequent
356/// lines use `io::empty()` / `io::stdout()`.
357pub fn handle_parallel(
358    lines: Vec<String>,
359    initial_stdin: Box<dyn Read + Send + 'static>,
360    final_stdout: Box<dyn Write + Send + 'static>,
361    registry: Arc<CommandRegistry>,
362    cancel_token: wasibox_core::CancellationToken,
363) -> Vec<Result<(), String>> {
364    let mut handles = Vec::new();
365    let mut stdin_opt: Option<Box<dyn Read + Send>> = Some(initial_stdin);
366    let mut stdout_opt: Option<Box<dyn Write + Send>> = Some(final_stdout);
367
368    for line in lines {
369        let registry = Arc::clone(&registry);
370        let mut stdin_for_thread: Option<Box<dyn Read + Send>> = Some(stdin_opt.take().unwrap_or_else(|| Box::new(io::empty())));
371        let mut stdout_for_thread: Option<Box<dyn Write + Send>> = Some(stdout_opt.take().unwrap_or_else(|| Box::new(io::stdout())));
372        
373        let cancel_clone = cancel_token.clone();
374        let handle = std::thread::spawn(move || {
375            let commands: Vec<&str> = line.split("&&").collect();
376            for cmd in commands {
377                let cmd = cmd.trim();
378                if cmd.is_empty() { continue; }
379                if cancel_clone.is_cancelled() { return Err("Interrupted".to_string()); }
380                let stdin = stdin_for_thread.take().unwrap_or_else(|| Box::new(io::empty()));
381                let stdout = stdout_for_thread.take().unwrap_or_else(|| Box::new(io::stdout()));
382                handle_pipeline(cmd, stdin, stdout, &*registry, cancel_clone.clone())?;
383            }
384            Ok(())
385        });
386        handles.push(handle);
387    }
388
389    handles.into_iter()
390        .map(|h| h.join().unwrap_or(Err("Thread panicked".to_string())))
391        .collect()
392}
393
394/// Execute a complete command line, splitting by `&&` and running sequentially.
395/// Stops execution if any command in the sequence fails.
396pub fn handle_command_line(
397    line: &str,
398    registry: &CommandRegistry,
399    cancel_token: wasibox_core::CancellationToken,
400) -> Result<(), String> {
401    let commands: Vec<&str> = line.split("&&").collect();
402    for cmd in commands {
403        let cmd = cmd.trim();
404        if cmd.is_empty() {
405            continue;
406        }
407        if cancel_token.is_cancelled() { return Err("Interrupted".to_string()); }
408        handle_pipeline(cmd, Box::new(io::stdin()), Box::new(io::stdout()), registry, cancel_token.clone())?;
409    }
410    Ok(())
411}
412
413// ---------------------------------------------------------------------------
414// Pipe implementation
415// ---------------------------------------------------------------------------
416
417struct CancelReader<R: Read> {
418    inner: R,
419    token: wasibox_core::CancellationToken,
420}
421impl<R: Read> Read for CancelReader<R> {
422    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
423        if self.token.is_cancelled() {
424            return Err(io::Error::new(io::ErrorKind::BrokenPipe, "Interrupted"));
425        }
426        self.inner.read(buf)
427    }
428}
429
430struct CancelWriter<W: Write> {
431    inner: W,
432    token: wasibox_core::CancellationToken,
433}
434impl<W: Write> Write for CancelWriter<W> {
435    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
436        if self.token.is_cancelled() {
437            return Err(io::Error::new(io::ErrorKind::BrokenPipe, "Interrupted"));
438        }
439        self.inner.write(buf)
440    }
441    fn flush(&mut self) -> io::Result<()> {
442        if self.token.is_cancelled() {
443            return Err(io::Error::new(io::ErrorKind::BrokenPipe, "Interrupted"));
444        }
445        self.inner.flush()
446    }
447}
448
449struct PipeReader {
450    rx: std::sync::mpsc::Receiver<Vec<u8>>,
451    buffer: Vec<u8>,
452    pos: usize,
453    token: wasibox_core::CancellationToken,
454}
455impl Read for PipeReader {
456    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
457        if self.token.is_cancelled() {
458            return Err(io::Error::new(io::ErrorKind::BrokenPipe, "Interrupted"));
459        }
460        if self.pos >= self.buffer.len() {
461            loop {
462                if self.token.is_cancelled() {
463                    return Err(io::Error::new(io::ErrorKind::BrokenPipe, "Interrupted"));
464                }
465                match self.rx.recv_timeout(std::time::Duration::from_millis(100)) {
466                    Ok(data) => {
467                        self.buffer = data;
468                        self.pos = 0;
469                        break;
470                    }
471                    Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
472                    Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return Ok(0),
473                }
474            }
475        }
476        let available = self.buffer.len() - self.pos;
477        let to_copy = std::cmp::min(available, buf.len());
478        buf[..to_copy].copy_from_slice(&self.buffer[self.pos..self.pos + to_copy]);
479        self.pos += to_copy;
480        Ok(to_copy)
481    }
482}
483
484struct PipeWriter {
485    tx: std::sync::mpsc::SyncSender<Vec<u8>>,
486    token: wasibox_core::CancellationToken,
487}
488impl Write for PipeWriter {
489    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
490        if self.token.is_cancelled() {
491            return Err(io::Error::new(io::ErrorKind::BrokenPipe, "Interrupted"));
492        }
493        self.tx.send(buf.to_vec()).map_err(|e| io::Error::new(io::ErrorKind::BrokenPipe, e))?;
494        Ok(buf.len())
495    }
496    fn flush(&mut self) -> io::Result<()> { Ok(()) }
497}
498
499fn create_pipe(token: wasibox_core::CancellationToken) -> (PipeReader, PipeWriter) {
500    let (tx, rx) = std::sync::mpsc::sync_channel(1024);
501    (PipeReader { rx, buffer: Vec::new(), pos: 0, token: token.clone() }, PipeWriter { tx, token })
502}
503
504// ---------------------------------------------------------------------------
505// Utility types
506// ---------------------------------------------------------------------------
507
508/// A thread-safe writer backed by a shared `Vec<u8>`. Useful for tests.
509pub struct ArcVecWriter {
510    pub inner: Arc<Mutex<Vec<u8>>>,
511}
512impl Write for ArcVecWriter {
513    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
514        let mut inner = self.inner.lock().map_err(|e| io::Error::new(io::ErrorKind::Other, e.to_string()))?;
515        inner.extend_from_slice(buf);
516        Ok(buf.len())
517    }
518    fn flush(&mut self) -> io::Result<()> { Ok(()) }
519}
520
521// ---------------------------------------------------------------------------
522// Tests
523// ---------------------------------------------------------------------------
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use std::io::{BufRead, BufReader, Cursor};
529
530    fn get_temp_dir() -> tempfile::TempDir {
531        #[cfg(target_os = "wasi")]
532        {
533            tempfile::Builder::new()
534                .prefix("test_")
535                .tempdir_in(".")
536                .expect("Failed to create temp dir in current directory")
537        }
538        #[cfg(not(target_os = "wasi"))]
539        {
540            tempfile::tempdir().expect("Failed to create system temp dir")
541        }
542    }
543
544    /// Shorthand: registry with all builtins (shell + wasibox-core)
545    fn builtins() -> CommandRegistry {
546        CommandRegistry::with_builtins()
547    }
548
549    // ── basic pipeline tests ────────────────────────────────────────────
550
551    #[test]
552    fn test_simple_echo() {
553        let out = Arc::new(Mutex::new(Vec::new()));
554        handle_pipeline("echo hello", Box::new(Cursor::new("")), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
555        let buf = out.lock().unwrap();
556        assert_eq!(String::from_utf8_lossy(&buf).trim(), "hello");
557    }
558
559    #[test]
560    fn test_pipe_echo_cat() {
561        let out = Arc::new(Mutex::new(Vec::new()));
562        handle_pipeline("echo hello | cat", Box::new(Cursor::new("")), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
563        let buf = out.lock().unwrap();
564        assert_eq!(String::from_utf8_lossy(&buf).trim(), "hello");
565    }
566
567    #[test]
568    fn test_pipe_grep() {
569        let out = Arc::new(Mutex::new(Vec::new()));
570        handle_pipeline("echo world | grep world", Box::new(Cursor::new("")), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
571        let buf = out.lock().unwrap();
572        assert_eq!(String::from_utf8_lossy(&buf).trim(), "world");
573    }
574
575    #[test]
576    fn test_redirection_create() {
577        let dir = get_temp_dir();
578        let file_path = dir.path().join("test_create.txt");
579        let cmd = format!("echo hello > \"{}\"", file_path.display());
580        handle_pipeline(&cmd, Box::new(Cursor::new("")), Box::new(io::sink()), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
581        let content = std::fs::read_to_string(file_path).unwrap();
582        assert_eq!(content.trim(), "hello");
583    }
584
585    #[test]
586    fn test_redirection_append() {
587        let dir = get_temp_dir();
588        let file_path = dir.path().join("test_append.txt");
589        let cmd1 = format!("echo hello > \"{}\"", file_path.display());
590        handle_pipeline(&cmd1, Box::new(Cursor::new("")), Box::new(io::sink()), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
591        let cmd2 = format!("echo world >> \"{}\"", file_path.display());
592        handle_pipeline(&cmd2, Box::new(Cursor::new("")), Box::new(io::sink()), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
593        let content = std::fs::read_to_string(file_path).unwrap();
594        assert!(content.contains("hello"));
595        assert!(content.contains("world"));
596    }
597
598    #[test]
599    fn test_complex_pipeline() {
600        let out = Arc::new(Mutex::new(Vec::new()));
601        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();
602        let buf = out.lock().unwrap();
603        assert_eq!(String::from_utf8_lossy(&buf).trim(), "6");
604    }
605
606    // ── custom command tests ────────────────────────────────────────────
607
608    #[test]
609    fn test_custom_command() {
610        let mut reg = CommandRegistry::with_builtins();
611        reg.register("magic", |_args, ctx| {
612            write!(ctx.stdout, "magic happen").map_err(|e| e.to_string())
613        });
614
615        let out = Arc::new(Mutex::new(Vec::new()));
616        handle_pipeline("magic", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &reg, wasibox_core::CancellationToken::new()).unwrap();
617        let buf = out.lock().unwrap();
618        assert_eq!(String::from_utf8_lossy(&buf), "magic happen");
619    }
620
621    #[test]
622    fn test_custom_command_in_pipeline() {
623        // Custom "double" command that duplicates each input line
624        let mut reg = CommandRegistry::with_builtins();
625        reg.register("double", |_args, ctx| {
626            for line in BufReader::new(&mut ctx.stdin).lines() {
627                let line = match line {
628                    Ok(l) => l,
629                    Err(_) => break,
630                };
631                if writeln!(ctx.stdout, "{}", line).is_err() { break; }
632                if writeln!(ctx.stdout, "{}", line).is_err() { break; }
633            }
634            Ok(())
635        });
636
637        let out = Arc::new(Mutex::new(Vec::new()));
638        handle_pipeline("echo hello | double", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &reg, wasibox_core::CancellationToken::new()).unwrap();
639        let buf = out.lock().unwrap();
640        assert_eq!(String::from_utf8_lossy(&buf).trim(), "hello\nhello");
641    }
642
643    #[test]
644    fn test_override_builtin() {
645        // Override "echo" with a custom version
646        let mut reg = CommandRegistry::with_builtins();
647        reg.register("echo", |args, ctx| {
648            let msg = args[1..].join(" ").to_uppercase();
649            writeln!(ctx.stdout, "{}", msg).map_err(|e| e.to_string())
650        });
651
652        let out = Arc::new(Mutex::new(Vec::new()));
653        handle_pipeline("echo hello", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &reg, wasibox_core::CancellationToken::new()).unwrap();
654        let buf = out.lock().unwrap();
655        assert_eq!(String::from_utf8_lossy(&buf).trim(), "HELLO");
656    }
657
658    // ── fully external pipeline (no builtins at all) ────────────────────
659
660    #[test]
661    fn test_all_external_pipeline() {
662        let mut reg = CommandRegistry::new();
663        reg.remove_fallback(); // no wasibox-core fallback
664
665        // "count" — infinite counter
666        reg.register("count", |_args, ctx| {
667            for i in 1u64.. {
668                if writeln!(ctx.stdout, "{}", i).is_err() { break; }
669            }
670            Ok(())
671        });
672
673        // "filter2" — keep lines containing '2'
674        reg.register("filter2", |_args, ctx| {
675            for line in BufReader::new(&mut ctx.stdin).lines() {
676                let line = match line {
677                    Ok(l) => l,
678                    Err(_) => break,
679                };
680                if line.contains('2') {
681                    if writeln!(ctx.stdout, "{}", line).is_err() { break; }
682                }
683            }
684            Ok(())
685        });
686
687        // "take N" — first N lines
688        reg.register("take", |args, ctx| {
689            let n: usize = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(5);
690            for (i, line) in BufReader::new(&mut ctx.stdin).lines().enumerate() {
691                if i >= n { break; }
692                let line = match line {
693                    Ok(l) => l,
694                    Err(_) => break,
695                };
696                if writeln!(ctx.stdout, "{}", line).is_err() { break; }
697            }
698            Ok(())
699        });
700
701        let out = Arc::new(Mutex::new(Vec::new()));
702        handle_pipeline(
703            "count | filter2 | take 3",
704            Box::new(io::empty()),
705            Box::new(ArcVecWriter { inner: Arc::clone(&out) }),
706            &reg,
707            wasibox_core::CancellationToken::new(),
708        ).unwrap();
709
710        let buf = out.lock().unwrap();
711        let result = String::from_utf8_lossy(&buf);
712        let lines: Vec<&str> = result.trim().lines().collect();
713        assert_eq!(lines.len(), 3);
714        for line in &lines {
715            assert!(line.contains('2'), "expected '2' in line, got: {}", line);
716        }
717        assert_eq!(lines[0], "2");
718        assert_eq!(lines[1], "12");
719        assert_eq!(lines[2], "20");
720    }
721
722    // ── parallel tests ──────────────────────────────────────────────────
723
724    #[test]
725    fn test_parallel_execution() {
726        let mut reg = CommandRegistry::with_builtins();
727        reg.register("slow", |_args, _ctx| {
728            std::thread::sleep(std::time::Duration::from_millis(100));
729            Ok(())
730        });
731        let registry = Arc::new(reg);
732
733        let lines = vec!["slow".to_string(), "slow".to_string(), "slow".to_string()];
734        let start = std::time::Instant::now();
735        let results = handle_parallel(lines, Box::new(io::empty()), Box::new(io::sink()), registry, wasibox_core::CancellationToken::new());
736        let duration = start.elapsed();
737
738        assert_eq!(results.len(), 3);
739        for res in results {
740            assert!(res.is_ok());
741        }
742        assert!(duration < std::time::Duration::from_millis(250));
743    }
744
745    // ── streaming / infinite pipeline tests ─────────────────────────────
746
747    #[test]
748    fn test_streaming_pipeline() {
749        let out = Arc::new(Mutex::new(Vec::new()));
750        handle_pipeline("yes | head -n 2", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
751        let buf = out.lock().unwrap();
752        assert_eq!(String::from_utf8_lossy(&buf).trim(), "y\ny");
753    }
754
755    #[test]
756    fn test_seq_pipeline_head() {
757        let out = Arc::new(Mutex::new(Vec::new()));
758        handle_pipeline("seq | head -n 5", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
759        let buf = out.lock().unwrap();
760        assert_eq!(String::from_utf8_lossy(&buf).trim(), "1\n2\n3\n4\n5");
761    }
762
763    #[test]
764    fn test_seq_pipeline_grep_head() {
765        let out = Arc::new(Mutex::new(Vec::new()));
766        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();
767        let buf = out.lock().unwrap();
768        let result = String::from_utf8_lossy(&buf);
769        let lines: Vec<&str> = result.trim().lines().collect();
770        assert_eq!(lines.len(), 3);
771        for line in &lines {
772            assert!(line.contains('2'));
773        }
774        assert_eq!(lines[0], "2");
775        assert_eq!(lines[1], "12");
776        assert_eq!(lines[2], "20");
777    }
778
779    #[test]
780    fn test_seq_pipeline_grep_head_5() {
781        let out = Arc::new(Mutex::new(Vec::new()));
782        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();
783        let buf = out.lock().unwrap();
784        let result = String::from_utf8_lossy(&buf);
785        let lines: Vec<&str> = result.trim().lines().collect();
786        assert_eq!(lines.len(), 5);
787        assert_eq!(lines[0], "2");
788        assert_eq!(lines[1], "12");
789        assert_eq!(lines[2], "20");
790        assert_eq!(lines[3], "21");
791        assert_eq!(lines[4], "22");
792    }
793
794    #[test]
795    fn test_seq_finite() {
796        let out = Arc::new(Mutex::new(Vec::new()));
797        handle_pipeline("seq 3", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
798        let buf = out.lock().unwrap();
799        assert_eq!(String::from_utf8_lossy(&buf).trim(), "1\n2\n3");
800    }
801
802    #[test]
803    fn test_seq_range() {
804        let out = Arc::new(Mutex::new(Vec::new()));
805        handle_pipeline("seq 5 8", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
806        let buf = out.lock().unwrap();
807        assert_eq!(String::from_utf8_lossy(&buf).trim(), "5\n6\n7\n8");
808    }
809
810    #[test]
811    fn test_seq_step() {
812        let out = Arc::new(Mutex::new(Vec::new()));
813        handle_pipeline("seq 1 2 10", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &builtins(), wasibox_core::CancellationToken::new()).unwrap();
814        let buf = out.lock().unwrap();
815        assert_eq!(String::from_utf8_lossy(&buf).trim(), "1\n3\n5\n7\n9");
816    }
817    #[test]
818    fn test_cd_and_ls() {
819        let dir = get_temp_dir();
820        // Create files inside the temp dir
821        std::fs::write(dir.path().join("aaa.txt"), "hello").unwrap();
822        std::fs::write(dir.path().join("bbb.txt"), "world").unwrap();
823
824        let original_cwd = env::current_dir().unwrap();
825        let reg = builtins();
826
827        // cd into the temp directory
828        let cd_cmd = format!("cd \"{}\"", dir.path().display());
829        handle_pipeline(&cd_cmd, Box::new(io::empty()), Box::new(io::sink()), &reg, wasibox_core::CancellationToken::new()).unwrap();
830
831        // ls the current directory (should now be the temp dir)
832        let out = Arc::new(Mutex::new(Vec::new()));
833        handle_pipeline("ls", Box::new(io::empty()), Box::new(ArcVecWriter { inner: Arc::clone(&out) }), &reg, wasibox_core::CancellationToken::new()).unwrap();
834
835        let buf = out.lock().unwrap();
836        let output = String::from_utf8_lossy(&buf);
837        assert!(output.contains("aaa.txt"), "expected aaa.txt in ls output, got: {}", output);
838        assert!(output.contains("bbb.txt"), "expected bbb.txt in ls output, got: {}", output);
839
840        // Restore original cwd
841        env::set_current_dir(original_cwd).unwrap();
842    }
843
844    // ── sequence (&&) tests ─────────────────────────────────────────────
845
846    #[test]
847    fn test_command_line_sequence() {
848        let mut reg = CommandRegistry::with_builtins();
849        let counter = Arc::new(Mutex::new(0));
850        let c1 = Arc::clone(&counter);
851        reg.register("inc", move |_args, _ctx| {
852            *c1.lock().unwrap() += 1;
853            Ok(())
854        });
855
856        // inc && inc && inc
857        crate::handle_command_line("inc && inc && inc", &reg, wasibox_core::CancellationToken::new()).unwrap();
858        assert_eq!(*counter.lock().unwrap(), 3);
859    }
860
861    #[test]
862    fn test_command_line_short_circuit() {
863        let mut reg = CommandRegistry::with_builtins();
864        let counter = Arc::new(Mutex::new(0));
865        let c1 = Arc::clone(&counter);
866        reg.register("inc", move |_args, _ctx| {
867            *c1.lock().unwrap() += 1;
868            Ok(())
869        });
870        reg.register("fail", |_args, _ctx| Err("simulated failure".to_string()));
871
872        // inc && fail && inc
873        // Should stop after "fail"
874        let res = crate::handle_command_line("inc && fail && inc", &reg, wasibox_core::CancellationToken::new());
875        assert!(res.is_err());
876        assert_eq!(res.unwrap_err(), "simulated failure");
877        assert_eq!(*counter.lock().unwrap(), 1); // Only the first "inc" should execute
878    }
879
880    #[test]
881    fn test_handle_parallel_cancel() {
882        println!("STARTING TEST");
883        let registry = Arc::new(builtins());
884        let cancel_token = wasibox_core::CancellationToken::new();
885        let cancel_clone = cancel_token.clone();
886        
887        let thread = std::thread::spawn(move || {
888            println!("THREAD SPAWNED");
889            super::handle_parallel(
890                vec!["yes".to_string()],
891                Box::new(std::io::empty()),
892                Box::new(std::io::sink()),
893                registry,
894                cancel_clone,
895            )
896        });
897        
898        std::thread::sleep(std::time::Duration::from_millis(10));
899        println!("CANCELLING TOKEN");
900        cancel_token.cancel();
901        
902        println!("JOINING THREAD");
903        let results = thread.join().unwrap();
904        println!("RESULTS: {:?}", results);
905        assert_eq!(results.len(), 1);
906        assert!(results[0].is_err());
907        assert_eq!(results[0].as_ref().unwrap_err(), "Interrupted");
908    }
909
910    #[test]
911    fn test_pipeline_cancel() {
912        let registry = builtins();
913        let cancel_token = wasibox_core::CancellationToken::new();
914        let cancel_clone = cancel_token.clone();
915        
916        let thread = std::thread::spawn(move || {
917            super::handle_pipeline(
918                "yes | grep y",
919                Box::new(std::io::empty()),
920                Box::new(std::io::sink()),
921                &registry,
922                cancel_clone,
923            )
924        });
925        
926        std::thread::sleep(std::time::Duration::from_millis(10));
927        cancel_token.cancel();
928        
929        let result = thread.join().unwrap();
930        assert!(result.is_err());
931        assert_eq!(result.unwrap_err(), "Interrupted");
932    }
933}
934