Skip to main content

wasi_shell/
lib.rs

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