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 events;
67#[doc(hidden)]
68pub mod macros;
69pub mod pipeline;
70pub mod quote;
71pub mod state;
72pub mod stream;
73pub mod terminal;
74pub mod trace;
75
76// Core modules
77pub mod commands;
78pub mod shell_parser;
79pub mod utils;
80
81use std::collections::HashMap;
82use std::path::PathBuf;
83use std::process::Stdio;
84use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
85use tokio::process::{Child, Command};
86use tokio::sync::mpsc;
87
88pub use commands::{CommandContext, StreamChunk};
89pub use shell_parser::{needs_real_shell, parse_shell_command, ParsedCommand};
90pub use utils::{CommandResult, VirtualUtils};
91
92// Re-export modular utilities at crate root for convenient access
93pub use ansi::{AnsiConfig, AnsiUtils};
94pub use events::{EventData, EventType, StreamEmitter};
95pub use pipeline::{Pipeline, PipelineBuilder, PipelineExt};
96pub use quote::quote;
97pub use state::{
98    get_shell_settings, global_state, reset_global_state, set_shell_option, unset_shell_option,
99    GlobalState, ShellSettings,
100};
101pub use stream::{AsyncIterator, IntoStream, OutputChunk, OutputStream, StreamingRunner};
102pub use trace::trace;
103
104/// Resolve a working directory that is safe to spawn a child process in.
105///
106/// When no explicit cwd is requested the child normally inherits the parent's
107/// working directory. But if that directory has been deleted or become
108/// inaccessible (the "getcwd() failed" scenario from issue #44), inheriting it
109/// makes the OS-level spawn fail. In that case fall back to a directory that is
110/// known to exist so the command still runs.
111///
112/// Normal behavior is preserved: when an explicit cwd is given, or when the
113/// inherited working directory is valid, this returns the requested value
114/// (`None` meaning "inherit").
115fn resolve_spawn_cwd(cwd: Option<&PathBuf>) -> Option<PathBuf> {
116    // An explicit directory is always honored as-is.
117    if let Some(c) = cwd {
118        return Some(c.clone());
119    }
120
121    // No explicit cwd: we would inherit the parent's working directory. Make
122    // sure that directory is actually usable before relying on inheritance.
123    match std::env::current_dir() {
124        Ok(_) => None,
125        Err(e) => {
126            let fallback = std::env::var_os("HOME")
127                .or_else(|| std::env::var_os("USERPROFILE"))
128                .map(PathBuf::from)
129                .unwrap_or_else(std::env::temp_dir);
130            trace(
131                "ProcessRunner",
132                &format!(
133                    "current_dir() failed ({}); spawning in fallback directory {}",
134                    e,
135                    fallback.display()
136                ),
137            );
138            if fallback.exists() {
139                Some(fallback)
140            } else {
141                Some(std::env::temp_dir())
142            }
143        }
144    }
145}
146
147/// Error type for command-stream operations
148#[derive(Debug, thiserror::Error)]
149pub enum Error {
150    #[error("IO error: {0}")]
151    Io(#[from] std::io::Error),
152
153    #[error("Command failed with exit code {code}: {message}")]
154    CommandFailed { code: i32, message: String },
155
156    #[error("Command not found: {0}")]
157    CommandNotFound(String),
158
159    #[error("Parse error: {0}")]
160    ParseError(String),
161
162    #[error("Cancelled")]
163    Cancelled,
164}
165
166/// Result type for command-stream operations
167pub type Result<T> = std::result::Result<T, Error>;
168
169/// Options for command execution
170#[derive(Debug, Clone)]
171pub struct RunOptions {
172    /// Mirror output to parent stdout/stderr
173    pub mirror: bool,
174    /// Capture output in result
175    pub capture: bool,
176    /// Standard input handling
177    pub stdin: StdinOption,
178    /// Working directory
179    pub cwd: Option<PathBuf>,
180    /// Environment variables
181    pub env: Option<HashMap<String, String>>,
182    /// Interactive mode (TTY forwarding)
183    pub interactive: bool,
184    /// Enable shell operator parsing
185    pub shell_operators: bool,
186    /// Enable tracing for this command
187    pub trace: bool,
188}
189
190impl Default for RunOptions {
191    fn default() -> Self {
192        RunOptions {
193            mirror: true,
194            capture: true,
195            stdin: StdinOption::Inherit,
196            cwd: None,
197            env: None,
198            interactive: false,
199            shell_operators: true,
200            trace: true,
201        }
202    }
203}
204
205/// Standard input options
206#[derive(Debug, Clone)]
207pub enum StdinOption {
208    /// Inherit from parent process
209    Inherit,
210    /// Pipe (allow writing to stdin)
211    Pipe,
212    /// Provide string content
213    Content(String),
214    /// Null device
215    Null,
216}
217
218/// A running or completed process
219pub struct ProcessRunner {
220    command: String,
221    options: RunOptions,
222    child: Option<Child>,
223    result: Option<CommandResult>,
224    started: bool,
225    finished: bool,
226    cancelled: bool,
227    output_tx: Option<mpsc::Sender<StreamChunk>>,
228    output_rx: Option<mpsc::Receiver<StreamChunk>>,
229}
230
231impl ProcessRunner {
232    /// Create a new process runner
233    pub fn new(command: impl Into<String>, options: RunOptions) -> Self {
234        let (tx, rx) = mpsc::channel(1024);
235        ProcessRunner {
236            command: command.into(),
237            options,
238            child: None,
239            result: None,
240            started: false,
241            finished: false,
242            cancelled: false,
243            output_tx: Some(tx),
244            output_rx: Some(rx),
245        }
246    }
247
248    /// Start the process
249    pub async fn start(&mut self) -> Result<()> {
250        if self.started {
251            return Ok(());
252        }
253        self.started = true;
254
255        utils::trace_lazy("ProcessRunner", || {
256            format!("Starting command: {}", self.command)
257        });
258
259        // Check if this is a virtual command
260        let first_word = self.command.split_whitespace().next().unwrap_or("");
261        if let Some(result) = self.try_virtual_command(first_word).await {
262            self.result = Some(result);
263            self.finished = true;
264            return Ok(());
265        }
266
267        // Parse command for shell operators (for future use with virtual command pipelines)
268        let _parsed = if self.options.shell_operators && !needs_real_shell(&self.command) {
269            parse_shell_command(&self.command)
270        } else {
271            None
272        };
273
274        // Execute via real shell if needed
275        let shell = find_available_shell();
276
277        let mut cmd = Command::new(&shell.cmd);
278        for arg in &shell.args {
279            cmd.arg(arg);
280        }
281        cmd.arg(&self.command);
282
283        // Configure stdin
284        match &self.options.stdin {
285            StdinOption::Inherit => {
286                cmd.stdin(Stdio::inherit());
287            }
288            StdinOption::Pipe => {
289                cmd.stdin(Stdio::piped());
290            }
291            StdinOption::Content(_) => {
292                cmd.stdin(Stdio::piped());
293            }
294            StdinOption::Null => {
295                cmd.stdin(Stdio::null());
296            }
297        }
298
299        // Configure stdout/stderr
300        if self.options.capture || self.options.mirror {
301            cmd.stdout(Stdio::piped());
302            cmd.stderr(Stdio::piped());
303        } else {
304            cmd.stdout(Stdio::inherit());
305            cmd.stderr(Stdio::inherit());
306        }
307
308        // Set working directory. Fall back to a valid directory when the
309        // inherited working directory has been deleted (issue #44).
310        if let Some(cwd) = resolve_spawn_cwd(self.options.cwd.as_ref()) {
311            cmd.current_dir(cwd);
312        }
313
314        // Set environment
315        if let Some(ref env_vars) = self.options.env {
316            for (key, value) in env_vars {
317                cmd.env(key, value);
318            }
319        }
320
321        // Spawn the process
322        let child = cmd.spawn()?;
323        self.child = Some(child);
324
325        Ok(())
326    }
327
328    /// Run the process to completion
329    pub async fn run(&mut self) -> Result<CommandResult> {
330        self.start().await?;
331
332        if let Some(result) = &self.result {
333            return Ok(result.clone());
334        }
335
336        let mut child = self.child.take().ok_or_else(|| {
337            Error::Io(std::io::Error::new(
338                std::io::ErrorKind::Other,
339                "Process not started",
340            ))
341        })?;
342
343        // Handle stdin content if provided
344        if let StdinOption::Content(ref content) = self.options.stdin {
345            if let Some(mut stdin) = child.stdin.take() {
346                let content = content.clone();
347                tokio::spawn(async move {
348                    let _ = stdin.write_all(content.as_bytes()).await;
349                    let _ = stdin.shutdown().await;
350                });
351            }
352        }
353
354        // Collect output
355        let mut stdout_content = String::new();
356        let mut stderr_content = String::new();
357
358        if let Some(stdout) = child.stdout.take() {
359            let mut reader = BufReader::new(stdout).lines();
360            while let Ok(Some(line)) = reader.next_line().await {
361                if self.options.mirror {
362                    println!("{}", line);
363                }
364                stdout_content.push_str(&line);
365                stdout_content.push('\n');
366            }
367        }
368
369        if let Some(stderr) = child.stderr.take() {
370            let mut reader = BufReader::new(stderr).lines();
371            while let Ok(Some(line)) = reader.next_line().await {
372                if self.options.mirror {
373                    eprintln!("{}", line);
374                }
375                stderr_content.push_str(&line);
376                stderr_content.push('\n');
377            }
378        }
379
380        let status = child.wait().await?;
381        let code = status.code().unwrap_or(-1);
382
383        let result = CommandResult {
384            stdout: stdout_content,
385            stderr: stderr_content,
386            code,
387        };
388
389        self.result = Some(result.clone());
390        self.finished = true;
391
392        Ok(result)
393    }
394
395    /// Try to execute as a virtual command
396    async fn try_virtual_command(&self, cmd_name: &str) -> Option<CommandResult> {
397        if !commands::are_virtual_commands_enabled() {
398            return None;
399        }
400
401        // Parse args from command string
402        let parts: Vec<&str> = self.command.split_whitespace().collect();
403        let args: Vec<String> = parts.iter().skip(1).map(|s| s.to_string()).collect();
404
405        let ctx = CommandContext {
406            args,
407            stdin: match &self.options.stdin {
408                StdinOption::Content(s) => Some(s.clone()),
409                _ => None,
410            },
411            cwd: self.options.cwd.clone(),
412            env: self.options.env.clone(),
413            output_tx: self.output_tx.clone(),
414            is_cancelled: None,
415        };
416
417        match cmd_name {
418            "echo" => Some(commands::echo(ctx).await),
419            "pwd" => Some(commands::pwd(ctx).await),
420            "cd" => Some(commands::cd(ctx).await),
421            "true" => Some(commands::r#true(ctx).await),
422            "false" => Some(commands::r#false(ctx).await),
423            "sleep" => Some(commands::sleep(ctx).await),
424            "cat" => Some(commands::cat(ctx).await),
425            "ls" => Some(commands::ls(ctx).await),
426            "mkdir" => Some(commands::mkdir(ctx).await),
427            "rm" => Some(commands::rm(ctx).await),
428            "touch" => Some(commands::touch(ctx).await),
429            "cp" => Some(commands::cp(ctx).await),
430            "mv" => Some(commands::mv(ctx).await),
431            "basename" => Some(commands::basename(ctx).await),
432            "dirname" => Some(commands::dirname(ctx).await),
433            "env" => Some(commands::env(ctx).await),
434            "exit" => Some(commands::exit(ctx).await),
435            "which" => Some(commands::which(ctx).await),
436            "yes" => Some(commands::yes(ctx).await),
437            "seq" => Some(commands::seq(ctx).await),
438            "test" => Some(commands::test(ctx).await),
439            _ => None,
440        }
441    }
442
443    /// Kill the process
444    pub fn kill(&mut self) -> Result<()> {
445        self.cancelled = true;
446        if let Some(ref mut child) = self.child {
447            child.start_kill()?;
448        }
449        Ok(())
450    }
451
452    /// Check if the process is finished
453    pub fn is_finished(&self) -> bool {
454        self.finished
455    }
456
457    /// Get the result if available
458    pub fn result(&self) -> Option<&CommandResult> {
459        self.result.as_ref()
460    }
461
462    /// Get the command string
463    pub fn command(&self) -> &str {
464        &self.command
465    }
466
467    /// Get the options
468    pub fn options(&self) -> &RunOptions {
469        &self.options
470    }
471}
472
473/// Shell configuration
474#[derive(Debug, Clone)]
475struct ShellConfig {
476    cmd: String,
477    args: Vec<String>,
478}
479
480/// Find an available shell
481fn find_available_shell() -> ShellConfig {
482    let is_windows = cfg!(windows);
483
484    if is_windows {
485        // Windows shells
486        let shells = [
487            ("cmd.exe", vec!["/c"]),
488            ("powershell.exe", vec!["-Command"]),
489        ];
490
491        for (cmd, args) in shells {
492            if which::which(cmd).is_ok() {
493                return ShellConfig {
494                    cmd: cmd.to_string(),
495                    args: args.into_iter().map(String::from).collect(),
496                };
497            }
498        }
499
500        ShellConfig {
501            cmd: "cmd.exe".to_string(),
502            args: vec!["/c".to_string()],
503        }
504    } else {
505        // Unix shells
506        let shells = [
507            ("/bin/sh", vec!["-c"]),
508            ("/usr/bin/sh", vec!["-c"]),
509            ("/bin/bash", vec!["-c"]),
510            ("sh", vec!["-c"]),
511        ];
512
513        for (cmd, args) in shells {
514            if std::path::Path::new(cmd).exists() || which::which(cmd).is_ok() {
515                return ShellConfig {
516                    cmd: cmd.to_string(),
517                    args: args.into_iter().map(String::from).collect(),
518                };
519            }
520        }
521
522        ShellConfig {
523            cmd: "/bin/sh".to_string(),
524            args: vec!["-c".to_string()],
525        }
526    }
527}
528
529/// Execute a command and return the result
530///
531/// This is the main entry point for simple command execution.
532/// Named `run` instead of `$` since `$` is not a valid Rust identifier.
533pub async fn run(command: impl Into<String>) -> Result<CommandResult> {
534    let mut runner = ProcessRunner::new(command, RunOptions::default());
535    runner.run().await
536}
537
538/// Alias for `run` function - for JavaScript-like API feel
539/// Since `$` is not valid in Rust, this provides a similar short name
540pub use run as execute;
541
542/// Execute a command with custom options
543pub async fn exec(command: impl Into<String>, options: RunOptions) -> Result<CommandResult> {
544    let mut runner = ProcessRunner::new(command, options);
545    runner.run().await
546}
547
548/// Create a new process runner without starting it
549pub fn create(command: impl Into<String>, options: RunOptions) -> ProcessRunner {
550    ProcessRunner::new(command, options)
551}
552
553/// Execute a command synchronously (blocking)
554pub fn run_sync(command: impl Into<String>) -> Result<CommandResult> {
555    let rt = tokio::runtime::Runtime::new()?;
556    rt.block_on(run(command))
557}
558
559// Tests are located in tests/ directory for better organization