Skip to main content

command_stream/
lib.rs

1//! # command-stream
2//!
3//! Modern shell command execution library with streaming, async iteration, and event support.
4//!
5//! This library provides a Rust equivalent to the JavaScript command-stream library,
6//! offering powerful shell command execution with streaming capabilities.
7//!
8//! ## Features
9//!
10//! - Async command execution with tokio
11//! - Streaming output via async iterators
12//! - Event-based output handling (on, once, emit)
13//! - Virtual commands for common operations (cat, ls, mkdir, etc.)
14//! - Shell operator support (&&, ||, ;, |)
15//! - Pipeline support with `.pipe()` method and `Pipeline` builder
16//! - Global state management for shell settings
17//! - `cmd!` macro for ergonomic command creation (similar to JS `$` tagged template literals)
18//! - Cross-platform support
19//!
20//! ## Module Organization
21//!
22//! The codebase follows a modular architecture similar to the JavaScript implementation:
23//!
24//! - `ansi` - ANSI escape code handling utilities
25//! - `commands` - Virtual command implementations
26//! - `events` - Event emitter for stream events
27//! - `macros` - The `cmd!` macro for ergonomic command creation
28//! - `pipeline` - Pipeline execution support
29//! - `quote` - Shell quoting utilities
30//! - `shell_parser` - Shell command parsing
31//! - `state` - Global state management
32//! - `stream` - Async streaming and iteration support
33//! - `trace` - Logging and tracing utilities
34//! - `utils` - Command results and virtual command helpers
35//!
36//! ## Quick Start
37//!
38//! ```rust,no_run
39//! use command_stream::{run, cmd};
40//!
41//! #[tokio::main]
42//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
43//!     // Execute a simple command
44//!     let result = run("echo hello world").await?;
45//!     println!("{}", result.stdout);
46//!
47//!     // Using the cmd! macro (similar to JS $ tagged template)
48//!     let name = "world";
49//!     let result = cmd!("echo hello {}", name).await?;
50//!     println!("{}", result.stdout);
51//!
52//!     // Using pipelines
53//!     use command_stream::Pipeline;
54//!     let result = Pipeline::new()
55//!         .add("echo hello world")
56//!         .add("grep world")
57//!         .run()
58//!         .await?;
59//!
60//!     Ok(())
61//! }
62//! ```
63
64// Modular utility modules (following JavaScript modular pattern)
65pub mod ansi;
66pub mod bun_shell;
67pub mod command_builder;
68pub mod command_packages;
69pub mod development;
70pub use command_packages::{CommandEnvironment, CommandPackage, PackageHandler};
71pub use development::{
72    dev, start_repl, DevController, DevEvent, DevOptions, ReplOptions, RustRepl,
73};
74pub mod events;
75pub mod execa;
76pub mod local_bin;
77#[doc(hidden)]
78pub mod macros;
79pub mod pipeline;
80pub mod quote;
81pub mod result_streams;
82mod run_options;
83pub mod signal;
84pub mod state;
85pub mod stream;
86pub mod stream_processing;
87pub mod terminal;
88pub mod trace;
89
90// Core modules
91pub mod commands;
92pub mod shell_parser;
93pub mod shelljs;
94pub mod utils;
95
96// zx-compatible API (google/zx 8.x): `$`-style Shell, ProcessOutput, goods.
97pub mod zx;
98
99use std::path::PathBuf;
100use std::process::Stdio;
101use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
102use tokio::process::Child;
103use tokio::sync::mpsc;
104
105pub use command_builder::{fy, CommandBuilder, CommandStdio};
106pub use commands::{CommandContext, StreamChunk};
107pub use execa::{execa, execa_compat, execa_node, execa_sync};
108pub use run_options::{Error, Result, RunOptions, StdinOption};
109pub use shell_parser::{needs_real_shell, parse_shell_command, ParsedCommand};
110pub use utils::{CommandResult, VirtualUtils};
111
112// Re-export modular utilities at crate root for convenient access
113pub use ansi::{AnsiConfig, AnsiUtils};
114pub use events::{EventData, EventType, StreamEmitter};
115pub use local_bin::PreferLocal;
116pub use pipeline::{Pipeline, PipelineBuilder, PipelineExt};
117pub use quote::{
118    escape_for_double_quotes, escape_for_single_quotes, has_shell_escapes,
119    is_pre_quoted_passthrough_enabled, is_quote_context_enabled, quote, quote_for_context,
120    scan_quote_context, QuoteContext,
121};
122pub use signal::{signal_exit_code, signal_number, DEFAULT_KILL_GRACE_MS, DEFAULT_KILL_SIGNAL};
123pub use state::{
124    get_shell_settings, global_state, reset_global_state, set_shell_option, unset_shell_option,
125    GlobalState, ShellSettings,
126};
127pub use stream::{AsyncIterator, IntoStream, OutputChunk, OutputStream, StreamingRunner};
128pub use stream_processing::{DataStream, StreamStats};
129pub use trace::trace;
130
131#[derive(Clone, Copy)]
132enum ChildOutput {
133    Stdout,
134    Stderr,
135}
136
137/// Read child output as byte chunks so capture does not invent a trailing newline.
138///
139/// stdout and stderr use separate futures in `ProcessRunner::run`, preventing
140/// either pipe from filling while the other is being drained. Mirroring keeps
141/// the original bytes too, including output that does not end in a newline.
142async fn collect_child_output<R>(
143    reader: Option<R>,
144    mirror: bool,
145    target: ChildOutput,
146    collected: &mut Vec<u8>,
147) -> std::io::Result<()>
148where
149    R: AsyncRead + Unpin,
150{
151    let Some(mut reader) = reader else {
152        return Ok(());
153    };
154    let mut buffer = [0_u8; 8192];
155
156    loop {
157        let count = reader.read(&mut buffer).await?;
158        if count == 0 {
159            break;
160        }
161
162        let chunk = &buffer[..count];
163        collected.extend_from_slice(chunk);
164        if mirror {
165            match target {
166                ChildOutput::Stdout => {
167                    let mut output = std::io::stdout().lock();
168                    let _ = std::io::Write::write_all(&mut output, chunk);
169                    let _ = std::io::Write::flush(&mut output);
170                }
171                ChildOutput::Stderr => {
172                    let mut output = std::io::stderr().lock();
173                    let _ = std::io::Write::write_all(&mut output, chunk);
174                    let _ = std::io::Write::flush(&mut output);
175                }
176            }
177        }
178    }
179
180    Ok(())
181}
182
183fn fallback_cwd() -> PathBuf {
184    std::env::var_os("HOME")
185        .or_else(|| std::env::var_os("USERPROFILE"))
186        .map(PathBuf::from)
187        .filter(|path| path.is_dir())
188        .unwrap_or_else(std::env::temp_dir)
189}
190
191/// Resolve a working directory that is safe to spawn a child process in.
192///
193/// When no explicit cwd is requested the child normally inherits the parent's
194/// working directory. But if that directory has been deleted or become
195/// inaccessible (the "getcwd() failed" scenario from issue #44), inheriting it
196/// makes the OS-level spawn fail. In that case fall back to a directory that is
197/// known to exist so the command still runs.
198///
199/// Normal behavior is preserved: when an explicit cwd is given, or when the
200/// inherited working directory is valid, this returns the requested value
201/// (`None` meaning "inherit").
202fn resolve_spawn_cwd(cwd: Option<&PathBuf>) -> Option<PathBuf> {
203    // An explicit directory is always honored as-is.
204    if let Some(c) = cwd {
205        return Some(c.clone());
206    }
207
208    // No explicit cwd: we would inherit the parent's working directory. Make
209    // sure that directory is actually usable before relying on inheritance.
210    match std::env::current_dir() {
211        Ok(_) => None,
212        Err(e) => {
213            let fallback = fallback_cwd();
214            trace(
215                "ProcessRunner",
216                &format!(
217                    "current_dir() failed ({}); spawning in fallback directory {}",
218                    e,
219                    fallback.display()
220                ),
221            );
222            Some(fallback)
223        }
224    }
225}
226
227/// A running or completed process
228pub struct ProcessRunner {
229    command: String,
230    options: RunOptions,
231    child: Option<Child>,
232    stdin_bytes: Vec<u8>,
233    /// Process id of the spawned child, recorded at spawn time. `run()` takes
234    /// the child in order to await it, so reading the id from it only works
235    /// between `start()` and `run()`; this copy is what makes `pid()` answer
236    /// after the command has finished too (issue #18).
237    pid: Option<u32>,
238    result: Option<CommandResult>,
239    started: bool,
240    finished: bool,
241    cancelled: bool,
242    /// Whether the child was spawned into a process group of its own, and so
243    /// can be signalled as a group. Recorded at spawn time because it cannot be
244    /// discovered later: by the time the group is signalled the leader is
245    /// usually a zombie, which macOS refuses to answer `getpgid` for.
246    #[cfg(unix)]
247    own_process_group: bool,
248    output_tx: Option<mpsc::Sender<StreamChunk>>,
249    // Held, never read: dropping the receiver would close the channel, and
250    // streaming virtual commands treat a closed channel as "stop now" (see
251    // `commands::yes`, which loops until `output_tx.send` fails). Keeping it
252    // alive is what gives those commands their run-until-cancelled behaviour.
253    #[allow(dead_code)]
254    output_rx: Option<mpsc::Receiver<StreamChunk>>,
255}
256
257/// Borrowed access to the operating-system child owned by a [`ProcessRunner`].
258///
259/// The wrapper keeps process termination on the runner's signal-aware path:
260/// [`kill`](Self::kill) and [`kill_with`](Self::kill_with) signal the child and
261/// its process group, honor the configured grace period, and then escalate if
262/// necessary. Use [`native`](Self::native) or [`native_mut`](Self::native_mut)
263/// when direct access to Tokio's child object is required.
264pub struct ProcessChild<'a> {
265    runner: &'a mut ProcessRunner,
266}
267
268impl ProcessChild<'_> {
269    /// Process id of the active child.
270    pub fn pid(&self) -> Option<u32> {
271        self.native().id()
272    }
273
274    /// Borrow Tokio's native child process object.
275    pub fn native(&self) -> &Child {
276        self.runner
277            .child
278            .as_ref()
279            .expect("ProcessChild exists only while its native child is present")
280    }
281
282    /// Mutably borrow Tokio's native child process object.
283    pub fn native_mut(&mut self) -> &mut Child {
284        self.runner
285            .child
286            .as_mut()
287            .expect("ProcessChild exists only while its native child is present")
288    }
289
290    /// Stop the child using the runner's configured signal and grace period.
291    pub fn kill(&mut self) -> Result<()> {
292        self.runner.kill()
293    }
294
295    /// Stop the child using an explicit signal and the configured grace period.
296    pub fn kill_with(&mut self, signal: &str) -> Result<()> {
297        self.runner.kill_with(signal)
298    }
299}
300
301impl ProcessRunner {
302    /// Create a new process runner
303    pub fn new(command: impl Into<String>, options: RunOptions) -> Self {
304        let (tx, rx) = mpsc::channel(1024);
305        ProcessRunner {
306            command: command.into(),
307            options,
308            child: None,
309            stdin_bytes: Vec::new(),
310            pid: None,
311            result: None,
312            started: false,
313            finished: false,
314            cancelled: false,
315            #[cfg(unix)]
316            own_process_group: false,
317            output_tx: Some(tx),
318            output_rx: Some(rx),
319        }
320    }
321
322    /// Whether the child will read from the caller's terminal.
323    ///
324    /// Only an *inherited* stdin that is actually a tty counts: a pipe, a null
325    /// stdin, or inherited stdin that has been redirected to a file carries no
326    /// terminal, and neither does output-only inheritance. This is the one case
327    /// where the child must stay in the caller's process group.
328    #[cfg(unix)]
329    fn shares_the_terminal(&self) -> bool {
330        use std::io::IsTerminal;
331
332        self.options.interactive
333            || (matches!(self.options.stdin, StdinOption::Inherit)
334                && std::io::stdin().is_terminal())
335    }
336
337    /// Start the process
338    pub async fn start(&mut self) -> Result<()> {
339        if self.started {
340            return Ok(());
341        }
342        self.started = true;
343
344        utils::trace_lazy("ProcessRunner", || {
345            format!("Starting command: {}", self.command)
346        });
347
348        // Check if this is a virtual command. Backslash escapes are removed by a
349        // real shell but not by the whitespace splitting used for virtual
350        // command args, so such commands always go to the system shell (#49).
351        // The same applies to redirection and expansions: whitespace splitting
352        // would hand `>`, `out.txt` to the virtual command as two ordinary
353        // arguments, so `echo hello > out.txt` printed the redirection instead
354        // of writing the file, and `git push ... 2>&1` reported success while
355        // nothing was pushed (#46).
356        let first_word = if matches!(self.options.stdin, StdinOption::Pipe)
357            || has_shell_escapes(&self.command)
358            || needs_real_shell(&self.command)
359        {
360            ""
361        } else {
362            self.command.split_whitespace().next().unwrap_or("")
363        };
364        if let Some(mut result) = self.try_virtual_command(first_word).await {
365            if let StdinOption::Content(ref content) = self.options.stdin {
366                result.stdin =
367                    crate::result_streams::CapturedInput::new(content.as_bytes().to_vec());
368            }
369            self.result = Some(result);
370            self.finished = true;
371            return Ok(());
372        }
373        // Parse command for shell operators (for future use with virtual command pipelines)
374        let _parsed = if self.options.shell_operators && !needs_real_shell(&self.command) {
375            parse_shell_command(&self.command)
376        } else {
377            None
378        };
379
380        // Execute via real shell if needed
381        let mut cmd = utils::shell_command(&self.command, self.options.env.as_ref());
382
383        // Configure stdin
384        match &self.options.stdin {
385            StdinOption::Inherit => {
386                cmd.stdin(Stdio::inherit());
387            }
388            StdinOption::Pipe => {
389                cmd.stdin(Stdio::piped());
390            }
391            StdinOption::Content(_) => {
392                cmd.stdin(Stdio::piped());
393            }
394            StdinOption::Null => {
395                cmd.stdin(Stdio::null());
396            }
397        }
398
399        // Configure stdout/stderr
400        if self.options.capture || self.options.mirror {
401            cmd.stdout(Stdio::piped());
402            cmd.stderr(Stdio::piped());
403        } else {
404            cmd.stdout(Stdio::inherit());
405            cmd.stderr(Stdio::inherit());
406        }
407
408        // Set working directory. Fall back to a valid directory when the
409        // inherited working directory has been deleted (issue #44).
410        if let Some(cwd) = resolve_spawn_cwd(self.options.cwd.as_ref()) {
411            cmd.current_dir(cwd);
412        }
413
414        // Set environment
415        if let Some(ref env_vars) = self.options.env {
416            for (key, value) in env_vars {
417                cmd.env(key, value);
418            }
419        }
420        let local_cwd = self
421            .options
422            .cwd
423            .clone()
424            .or_else(|| std::env::current_dir().ok())
425            .unwrap_or_else(|| PathBuf::from("."));
426        local_bin::apply_prefer_local(
427            &mut cmd,
428            self.options.env.as_ref(),
429            &local_cwd,
430            &self.options.prefer_local,
431        );
432
433        // Run the child in its own process group so that killing it can signal
434        // the whole group (parent + grandchildren), matching `StreamingRunner`
435        // and the JavaScript implementation's `detached` spawn.
436        //
437        // A child that shares the terminal is deliberately left in the caller's
438        // group. The tty delivers CTRL+C to its foreground group only, so
439        // moving such a child out would both hide CTRL+C from it and stop it
440        // with SIGTTIN the moment it read from the terminal. JavaScript draws
441        // the same line, spawning interactive commands without `detached`.
442        #[cfg(unix)]
443        {
444            self.own_process_group = !self.shares_the_terminal();
445            if self.own_process_group {
446                cmd.process_group(0);
447            }
448        }
449
450        // Spawn the process
451        let child = cmd.spawn()?;
452        // Record the id while the child is still held. `run()` takes the child
453        // in order to await it, so this copy is what keeps `pid()` readable
454        // afterwards.
455        self.pid = child.id();
456        self.child = Some(child);
457
458        Ok(())
459    }
460
461    /// Borrow the active operating-system child.
462    ///
463    /// Call [`start`](Self::start) first. The result is `None` before startup,
464    /// for built-in commands (which run in-process), and after [`run`](Self::run)
465    /// consumes and reaps the child. Killing through the returned handle keeps
466    /// the runner's process-group and graceful-escalation behavior.
467    ///
468    /// ```no_run
469    /// use command_stream::{ProcessRunner, RunOptions};
470    ///
471    /// # #[tokio::main]
472    /// # async fn main() -> command_stream::Result<()> {
473    /// let mut runner = ProcessRunner::new("sleep 30", RunOptions::default());
474    /// runner.start().await?;
475    /// if let Some(mut child) = runner.child() {
476    ///     println!("child pid: {:?}", child.pid());
477    ///     child.kill_with("SIGTERM")?;
478    /// }
479    /// let _ = runner.run().await?;
480    /// # Ok(())
481    /// # }
482    /// ```
483    pub fn child(&mut self) -> Option<ProcessChild<'_>> {
484        self.child.as_ref()?;
485        Some(ProcessChild { runner: self })
486    }
487
488    /// Write bytes to the stdin pipe of a running command.
489    ///
490    /// Configure the runner with [`StdinOption::Pipe`], call [`start`](Self::start),
491    /// write as many chunks as needed, and finish with [`close_stdin`](Self::close_stdin).
492    pub async fn write_stdin(&mut self, data: impl AsRef<[u8]>) -> Result<()> {
493        self.start().await?;
494        let stdin = self
495            .child
496            .as_mut()
497            .and_then(|child| child.stdin.as_mut())
498            .ok_or_else(|| {
499                Error::Io(std::io::Error::new(
500                    std::io::ErrorKind::BrokenPipe,
501                    "command stdin is not available; use StdinOption::Pipe",
502                ))
503            })?;
504        stdin.write_all(data.as_ref()).await?;
505        self.stdin_bytes.extend_from_slice(data.as_ref());
506        Ok(())
507    }
508
509    /// Close a running command's stdin pipe so it can observe end-of-input.
510    pub async fn close_stdin(&mut self) -> Result<()> {
511        self.start().await?;
512        if let Some(mut stdin) = self.child.as_mut().and_then(|child| child.stdin.take()) {
513            stdin.shutdown().await?;
514        }
515        Ok(())
516    }
517
518    /// Run the process to completion
519    pub async fn run(&mut self) -> Result<CommandResult> {
520        self.start().await?;
521
522        if let Some(result) = &self.result {
523            return Ok(result.clone());
524        }
525
526        let mut child = self
527            .child
528            .take()
529            .ok_or_else(|| Error::Io(std::io::Error::other("Process not started")))?;
530
531        // Handle stdin content if provided
532        if let StdinOption::Content(ref content) = self.options.stdin {
533            if let Some(mut stdin) = child.stdin.take() {
534                let content = content.clone();
535                tokio::spawn(async move {
536                    let _ = stdin.write_all(content.as_bytes()).await;
537                    let _ = stdin.shutdown().await;
538                });
539            }
540        }
541
542        // Drain both pipes concurrently and preserve their newline framing. The
543        // previous line reader appended `\n` to every final line, changing
544        // output from commands such as `printf` that omit a newline (issue #37).
545        let stdout_reader = child.stdout.take();
546        let stderr_reader = child.stderr.take();
547        let mut stdout = Vec::new();
548        let mut stderr = Vec::new();
549        utils::trace_lazy("ProcessRunner", || {
550            format!("Draining child output | pid={:?}", self.pid)
551        });
552        let drain = async {
553            tokio::try_join!(
554                collect_child_output(
555                    stdout_reader,
556                    self.options.mirror,
557                    ChildOutput::Stdout,
558                    &mut stdout,
559                ),
560                collect_child_output(
561                    stderr_reader,
562                    self.options.mirror,
563                    ChildOutput::Stderr,
564                    &mut stderr,
565                ),
566            )
567            .map(|_| ())
568        };
569        let (collected, status) = if self.cancelled {
570            // Keep draining while a graceful signal handler runs. After the
571            // child exits, a descendant can still retain either pipe. Like
572            // the streaming runner, bound that remaining drain; the outside
573            // buffers survive cancellation of the reader.
574            tokio::pin!(drain);
575            let mut status = None;
576            let collected = tokio::select! {
577                output = &mut drain => output,
578                child_status = child.wait() => {
579                    status = Some(child_status?);
580                    match tokio::time::timeout(
581                        std::time::Duration::from_millis(stream::DEFAULT_EXIT_PUMP_GRACE_MS),
582                        &mut drain,
583                    ).await {
584                        Ok(output) => output,
585                        Err(_) => {
586                            utils::trace_lazy("ProcessRunner", || {
587                                "Cancelled output drain timed out; retaining buffered bytes".to_string()
588                            });
589                            Ok(())
590                        }
591                    }
592                }
593            };
594            (collected, status)
595        } else {
596            (drain.await, None)
597        };
598        if let Err(error) = collected {
599            // `try_join!` drops the other pipe reader after an error. Stop
600            // and reap the child so it cannot remain blocked on that pipe.
601            let _ = child.start_kill();
602            let _ = child.wait().await;
603            return Err(error.into());
604        }
605
606        utils::trace_lazy("ProcessRunner", || {
607            format!("Child output drained | pid={:?}", self.pid)
608        });
609        let status = match status {
610            Some(status) => status,
611            None => child.wait().await?,
612        };
613        utils::trace_lazy("ProcessRunner", || {
614            format!("Child exited | pid={:?} status={status}", self.pid)
615        });
616        let code = status.code().unwrap_or(-1);
617
618        let mut result = CommandResult::new(
619            String::from_utf8_lossy(&stdout).into_owned(),
620            String::from_utf8_lossy(&stderr).into_owned(),
621            code,
622        );
623        if let StdinOption::Content(ref content) = self.options.stdin {
624            result.stdin = crate::result_streams::CapturedInput::new(content.as_bytes().to_vec());
625        } else if !self.stdin_bytes.is_empty() {
626            result.stdin = crate::result_streams::CapturedInput::new(self.stdin_bytes.clone());
627        }
628
629        self.result = Some(result.clone());
630        self.finished = true;
631
632        Ok(result)
633    }
634
635    /// Try to execute as a virtual command
636    async fn try_virtual_command(&self, cmd_name: &str) -> Option<CommandResult> {
637        if !commands::are_virtual_commands_enabled() {
638            return None;
639        }
640
641        // An empty command name means the caller already decided this command
642        // must go to a real shell (redirection, expansions, escapes). Bail out
643        // before tokenizing so we neither waste work nor parse shell syntax we
644        // deliberately delegate.
645        if cmd_name.is_empty() {
646            return None;
647        }
648
649        // Parse args from command string, respecting quotes and performing
650        // POSIX quote removal so `echo label:'help wanted'` reaches the built-in
651        // as the single argument `label:help wanted` (issue #48).
652        let words = shell_parser::split_command_words(&self.command);
653        let args: Vec<String> = words.into_iter().skip(1).collect();
654
655        let ctx = CommandContext {
656            args,
657            stdin: match &self.options.stdin {
658                StdinOption::Content(s) => Some(s.clone()),
659                _ => None,
660            },
661            cwd: self.options.cwd.clone(),
662            env: self.options.env.clone(),
663            output_tx: self.output_tx.clone(),
664            is_cancelled: None,
665        };
666
667        match cmd_name {
668            "echo" => Some(commands::echo(ctx).await),
669            "pwd" => Some(commands::pwd(ctx).await),
670            "cd" => Some(commands::cd::resolve_cd(ctx).await.0),
671            "true" => Some(commands::r#true(ctx).await),
672            "false" => Some(commands::r#false(ctx).await),
673            "sleep" => Some(commands::sleep(ctx).await),
674            "cat" => Some(commands::cat(ctx).await),
675            "ls" => Some(commands::ls(ctx).await),
676            "mkdir" => Some(commands::mkdir(ctx).await),
677            "rm" => Some(commands::rm(ctx).await),
678            "touch" => Some(commands::touch(ctx).await),
679            "cp" => Some(commands::cp(ctx).await),
680            "mv" => Some(commands::mv(ctx).await),
681            "basename" => Some(commands::basename(ctx).await),
682            "dirname" => Some(commands::dirname(ctx).await),
683            "env" => Some(commands::env(ctx).await),
684            "exit" => Some(commands::exit(ctx).await),
685            "which" => Some(commands::which(ctx).await),
686            "yes" => Some(commands::yes(ctx).await),
687            "seq" => Some(commands::seq(ctx).await),
688            "tee" => Some(commands::tee(ctx).await),
689            "test" => Some(commands::test(ctx).await),
690            "head" => Some(commands::head(ctx).await),
691            "tail" => Some(commands::tail(ctx).await),
692            "sort" => Some(commands::sort(ctx).await),
693            "uniq" => Some(commands::uniq(ctx).await),
694
695            _ => None,
696        }
697    }
698
699    /// Stop the process using the configured kill signal
700    /// ([`RunOptions::kill_signal`], default `SIGTERM`).
701    ///
702    /// Mirrors the JavaScript `kill()` with no argument.
703    pub fn kill(&mut self) -> Result<()> {
704        let signal = self.options.kill_signal.clone();
705        self.kill_with(&signal)
706    }
707
708    /// Stop the process using an explicit signal, overriding
709    /// [`RunOptions::kill_signal`] for this call.
710    ///
711    /// Mirrors the JavaScript `kill(signal)`. The signal is delivered to the
712    /// child and its process group, so grandchildren behind a `sh -c` wrapper
713    /// are stopped too - except for a child sharing the caller's terminal,
714    /// which stays in the caller's group so CTRL+C keeps reaching it. The child
715    /// then has [`RunOptions::kill_grace_ms`] to run its own handler before
716    /// `SIGKILL` follows, so a process that ignores the signal still
717    /// terminates.
718    ///
719    /// ```no_run
720    /// use command_stream::{ProcessRunner, RunOptions};
721    ///
722    /// # #[tokio::main]
723    /// # async fn main() -> command_stream::Result<()> {
724    /// let mut runner = ProcessRunner::new("sleep 30", RunOptions::default());
725    /// runner.start().await?;
726    /// runner.kill_with("SIGINT")?; // the CTRL+C signal
727    /// # Ok(())
728    /// # }
729    /// ```
730    pub fn kill_with(&mut self, signal: &str) -> Result<()> {
731        self.cancelled = true;
732        utils::trace_lazy("ProcessRunner", || format!("kill | signal={signal}"));
733
734        let Some(child) = self.child.as_mut() else {
735            return Ok(());
736        };
737
738        // Windows stops the tree before the parent exits, while its descendants
739        // can still be found. Direct-child termination is the fallback.
740        // The `#[cfg(unix)]` block below is stripped on Windows, which leaves
741        // this one as the function's tail expression - hence no `return`.
742        #[cfg(not(unix))]
743        {
744            if let Some(pid) = child.id() {
745                signal::send_signal_to_process(pid, signal, signal::Delivery::ProcessAndGroup);
746            }
747            child.start_kill()?;
748            utils::trace_lazy("ProcessRunner", || {
749                "Direct-child termination requested".to_string()
750            });
751            Ok(())
752        }
753
754        // Without a pid the process never spawned (or was already reaped);
755        // fall back to the forceful stop so `kill()` still terminates it.
756        #[cfg(unix)]
757        {
758            let Some(pid) = child.id() else {
759                child.start_kill()?;
760                return Ok(());
761            };
762
763            // `SIGKILL` cannot be handled, so there is nothing to wait for.
764            //
765            // A zero grace period means the child is given no opportunity to
766            // handle the signal either, so the requested signal is not
767            // delivered at all. Anything done between it and `SIGKILL` - even a
768            // single syscall - is a window the child can be scheduled in, which
769            // made "no grace" a race the child occasionally won rather than a
770            // guarantee. The reported exit code still comes from the signal
771            // that was requested.
772            let grace = self.options.kill_grace_ms;
773            let delivery = if self.own_process_group {
774                signal::Delivery::ProcessAndGroup
775            } else {
776                signal::Delivery::ProcessOnly
777            };
778            if grace == 0 || signal == "SIGKILL" {
779                signal::send_signal_to_process(pid, "SIGKILL", delivery);
780                let _ = child.start_kill();
781                return Ok(());
782            }
783
784            signal::send_signal_to_process(pid, signal, delivery);
785
786            // Otherwise escalate in the background so the child keeps its grace
787            // period without blocking the caller, which may not be inside an
788            // await point.
789            tokio::spawn(async move {
790                tokio::time::sleep(std::time::Duration::from_millis(grace)).await;
791                // Best effort: if the child already exited on the first signal
792                // this delivery simply fails, and the pid has not been reused
793                // because the `Child` handle above has not reaped it yet. That
794                // unreaped leader is also what keeps the group id alive, so the
795                // group delivery still reaches a grandchild that outlived it.
796                signal::send_signal_to_process(pid, "SIGKILL", delivery);
797            });
798
799            Ok(())
800        }
801    }
802
803    /// Check if the process is finished
804    pub fn is_finished(&self) -> bool {
805        self.finished
806    }
807
808    /// Get the result if available
809    pub fn result(&self) -> Option<&CommandResult> {
810        self.result.as_ref()
811    }
812
813    /// Process id of the command, or `None` when there is no operating system
814    /// process to identify.
815    ///
816    /// It is `None` before the command starts, and stays `None` for built-in
817    /// (virtual) commands such as `echo` or `sleep`, which run inside this
818    /// process and never spawn a child. Once a real command has been spawned
819    /// the value is stable: it remains readable after the command finishes,
820    /// unlike the child handle, which [`run`](Self::run) consumes.
821    ///
822    /// Mirrors the JavaScript `runner.pid` property.
823    ///
824    /// ```no_run
825    /// use command_stream::{ProcessRunner, RunOptions};
826    ///
827    /// # #[tokio::main]
828    /// # async fn main() -> command_stream::Result<()> {
829    /// let mut runner = ProcessRunner::new("/bin/sleep 1", RunOptions::default());
830    /// runner.start().await?;
831    /// println!("running as pid {:?}", runner.pid());
832    /// runner.run().await?;
833    /// println!("still readable: {:?}", runner.pid());
834    /// # Ok(())
835    /// # }
836    /// ```
837    pub fn pid(&self) -> Option<u32> {
838        self.pid
839    }
840
841    /// Get the command string
842    pub fn command(&self) -> &str {
843        &self.command
844    }
845
846    /// Get the options
847    pub fn options(&self) -> &RunOptions {
848        &self.options
849    }
850}
851
852/// Execute a command and return the result
853///
854/// This is the main entry point for simple command execution.
855/// Named `run` instead of `$` since `$` is not a valid Rust identifier.
856pub async fn run(command: impl Into<String>) -> Result<CommandResult> {
857    let mut runner = ProcessRunner::new(command, RunOptions::default());
858    runner.run().await
859}
860
861/// Alias for `run` function - for JavaScript-like API feel
862/// Since `$` is not valid in Rust, this provides a similar short name
863pub use run as execute;
864
865/// Execute a command with custom options
866pub async fn exec(command: impl Into<String>, options: RunOptions) -> Result<CommandResult> {
867    let mut runner = ProcessRunner::new(command, options);
868    runner.run().await
869}
870
871/// Create a new process runner without starting it
872pub fn create(command: impl Into<String>, options: RunOptions) -> ProcessRunner {
873    ProcessRunner::new(command, options)
874}
875
876/// Execute a command synchronously (blocking)
877pub fn run_sync(command: impl Into<String>) -> Result<CommandResult> {
878    let rt = tokio::runtime::Runtime::new()?;
879    rt.block_on(run(command))
880}
881
882// Tests are located in tests/ directory for better organization