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