Skip to main content

command_stream/
pipeline.rs

1//! Pipeline execution support
2//!
3//! This module provides pipeline functionality similar to the JavaScript
4//! `$.process-runner-pipeline.mjs` module. It allows chaining commands
5//! together with the output of one command becoming the input of the next.
6//!
7//! ## Usage
8//!
9//! ```rust,no_run
10//! use command_stream::{Pipeline, run};
11//!
12//! #[tokio::main]
13//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
14//!     // Create a pipeline
15//!     let result = Pipeline::new()
16//!         .add("echo hello world")
17//!         .add("grep world")
18//!         .add("wc -l")
19//!         .run()
20//!         .await?;
21//!
22//!     println!("Output: {}", result.stdout);
23//!     Ok(())
24//! }
25//! ```
26
27use std::collections::HashMap;
28use std::path::PathBuf;
29use std::process::Stdio;
30use tokio::io::{AsyncReadExt, AsyncWriteExt};
31use tokio::process::Command;
32
33use crate::trace::trace_lazy;
34use crate::{CommandResult, Result, RunOptions, StdinOption};
35
36struct VirtualCommandResult {
37    result: CommandResult,
38    cd_context: Option<crate::commands::cd::CdContext>,
39}
40
41/// A pipeline of commands to be executed sequentially
42///
43/// Each command's stdout is piped to the next command's stdin.
44#[derive(Debug, Clone)]
45pub struct Pipeline {
46    /// Commands in the pipeline
47    commands: Vec<String>,
48    /// Initial stdin content (optional)
49    stdin: Option<String>,
50    /// Working directory
51    cwd: Option<PathBuf>,
52    /// Environment variables
53    env: Option<HashMap<String, String>>,
54    /// Whether to mirror output to parent stdout/stderr
55    mirror: bool,
56    /// Whether to capture output
57    capture: bool,
58}
59
60impl Default for Pipeline {
61    fn default() -> Self {
62        Self::new()
63    }
64}
65
66impl Pipeline {
67    /// Create a new empty pipeline
68    pub fn new() -> Self {
69        Pipeline {
70            commands: Vec::new(),
71            stdin: None,
72            cwd: None,
73            env: None,
74            mirror: true,
75            capture: true,
76        }
77    }
78
79    /// Add a command to the pipeline
80    // `std::ops::Add` is not a fit for a consuming builder step, and renaming
81    // this method would break the published 0.x API and diverge from the
82    // JavaScript `.add()` it mirrors.
83    #[allow(clippy::should_implement_trait)]
84    pub fn add(mut self, command: impl Into<String>) -> Self {
85        self.commands.push(command.into());
86        self
87    }
88
89    /// Set the initial stdin content for the first command
90    pub fn stdin(mut self, content: impl Into<String>) -> Self {
91        self.stdin = Some(content.into());
92        self
93    }
94
95    /// Set the working directory for all commands
96    pub fn cwd(mut self, path: impl Into<PathBuf>) -> Self {
97        self.cwd = Some(path.into());
98        self
99    }
100
101    /// Set environment variables for all commands
102    pub fn env(mut self, env: HashMap<String, String>) -> Self {
103        self.env = Some(env);
104        self
105    }
106
107    /// Set whether to mirror output to stdout/stderr
108    pub fn mirror_output(mut self, mirror: bool) -> Self {
109        self.mirror = mirror;
110        self
111    }
112
113    /// Set whether to capture output
114    pub fn capture_output(mut self, capture: bool) -> Self {
115        self.capture = capture;
116        self
117    }
118
119    /// Execute the pipeline and return the result
120    pub async fn run(self) -> Result<CommandResult> {
121        if self.commands.is_empty() {
122            return Ok(CommandResult {
123                stdout: String::new(),
124                stderr: "No commands in pipeline".to_string(),
125                code: 1,
126            });
127        }
128
129        trace_lazy("Pipeline", || {
130            format!("Running pipeline with {} commands", self.commands.len())
131        });
132
133        let mut current_stdin = self.stdin.clone();
134        let mut effective_cwd = self.cwd.clone();
135        let mut effective_env = self.env.clone();
136        let mut last_result = CommandResult {
137            stdout: String::new(),
138            stderr: String::new(),
139            code: 0,
140        };
141        let mut accumulated_stderr = String::new();
142
143        for (i, cmd_str) in self.commands.iter().enumerate() {
144            let is_last = i == self.commands.len() - 1;
145
146            trace_lazy("Pipeline", || {
147                format!(
148                    "Executing command {}/{}: {}",
149                    i + 1,
150                    self.commands.len(),
151                    cmd_str
152                )
153            });
154
155            // Check if this is a virtual command
156            let first_word = cmd_str.split_whitespace().next().unwrap_or("");
157            if crate::commands::are_virtual_commands_enabled() {
158                if let Some(result) = self
159                    .try_virtual_command(
160                        first_word,
161                        cmd_str,
162                        &current_stdin,
163                        effective_cwd.as_ref(),
164                        effective_env.as_ref(),
165                    )
166                    .await
167                {
168                    let VirtualCommandResult { result, cd_context } = result;
169                    if result.code != 0 {
170                        return Ok(CommandResult {
171                            stdout: result.stdout,
172                            stderr: accumulated_stderr + &result.stderr,
173                            code: result.code,
174                        });
175                    }
176                    current_stdin = Some(result.stdout.clone());
177                    accumulated_stderr.push_str(&result.stderr);
178                    if let Some(context) = cd_context {
179                        let env = effective_env.get_or_insert_with(|| std::env::vars().collect());
180                        env.insert(
181                            "OLDPWD".to_string(),
182                            context.oldpwd.to_string_lossy().to_string(),
183                        );
184                        env.insert("PWD".to_string(), context.cwd.to_string_lossy().to_string());
185                        effective_cwd = Some(context.cwd);
186                    }
187                    last_result = result;
188                    continue;
189                }
190            }
191
192            // Execute via shell
193            let shell = find_available_shell();
194            let mut cmd = Command::new(&shell.cmd);
195            for arg in &shell.args {
196                cmd.arg(arg);
197            }
198            crate::utils::append_shell_command(&mut cmd, cmd_str, effective_env.as_ref());
199
200            // Configure stdio
201            cmd.stdin(Stdio::piped());
202            cmd.stdout(Stdio::piped());
203            cmd.stderr(Stdio::piped());
204
205            // Set working directory. Fall back to a valid directory when the
206            // inherited working directory has been deleted (issue #44).
207            if let Some(cwd) = crate::resolve_spawn_cwd(effective_cwd.as_ref()) {
208                cmd.current_dir(cwd);
209            }
210
211            // Set environment
212            if let Some(ref env_vars) = effective_env {
213                for (key, value) in env_vars {
214                    cmd.env(key, value);
215                }
216            }
217
218            // Spawn the process
219            let mut child = cmd.spawn()?;
220
221            // Write stdin if available
222            if let Some(ref stdin_content) = current_stdin {
223                if let Some(mut stdin) = child.stdin.take() {
224                    let content = stdin_content.clone();
225                    tokio::spawn(async move {
226                        let _ = stdin.write_all(content.as_bytes()).await;
227                        let _ = stdin.shutdown().await;
228                    });
229                }
230            }
231
232            // Read stdout
233            let mut stdout_content = String::new();
234            if let Some(mut stdout) = child.stdout.take() {
235                stdout.read_to_string(&mut stdout_content).await?;
236            }
237
238            // Read stderr
239            let mut stderr_content = String::new();
240            if let Some(mut stderr) = child.stderr.take() {
241                stderr.read_to_string(&mut stderr_content).await?;
242            }
243
244            // Mirror output if enabled and this is the last command
245            if is_last && self.mirror {
246                if !stdout_content.is_empty() {
247                    print!("{}", stdout_content);
248                }
249                if !stderr_content.is_empty() {
250                    eprint!("{}", stderr_content);
251                }
252            }
253
254            // Wait for the process
255            let status = child.wait().await?;
256            let code = status.code().unwrap_or(-1);
257
258            accumulated_stderr.push_str(&stderr_content);
259
260            if code != 0 {
261                return Ok(CommandResult {
262                    stdout: stdout_content,
263                    stderr: accumulated_stderr,
264                    code,
265                });
266            }
267
268            // Set up stdin for next command
269            current_stdin = Some(stdout_content.clone());
270            last_result = CommandResult {
271                stdout: stdout_content,
272                stderr: String::new(),
273                code,
274            };
275        }
276
277        Ok(CommandResult {
278            stdout: last_result.stdout,
279            stderr: accumulated_stderr,
280            code: last_result.code,
281        })
282    }
283
284    /// Try to execute a virtual command
285    async fn try_virtual_command(
286        &self,
287        cmd_name: &str,
288        full_cmd: &str,
289        stdin: &Option<String>,
290        cwd: Option<&PathBuf>,
291        env: Option<&HashMap<String, String>>,
292    ) -> Option<VirtualCommandResult> {
293        // Respect quotes and perform POSIX quote removal (issue #48), matching
294        // the non-pipeline virtual-command path.
295        let words = crate::shell_parser::split_command_words(full_cmd);
296        let args: Vec<String> = words.into_iter().skip(1).collect();
297
298        let ctx = crate::commands::CommandContext {
299            args,
300            stdin: stdin.clone(),
301            cwd: cwd.cloned(),
302            env: env.cloned(),
303            output_tx: None,
304            is_cancelled: None,
305        };
306
307        let (result, cd_context) = match cmd_name {
308            "echo" => (crate::commands::echo(ctx).await, None),
309            "pwd" => (crate::commands::pwd(ctx).await, None),
310            "cd" => crate::commands::cd::resolve_cd(ctx).await,
311            "true" => (crate::commands::r#true(ctx).await, None),
312            "false" => (crate::commands::r#false(ctx).await, None),
313            "sleep" => (crate::commands::sleep(ctx).await, None),
314            "cat" => (crate::commands::cat(ctx).await, None),
315            "ls" => (crate::commands::ls(ctx).await, None),
316            "mkdir" => (crate::commands::mkdir(ctx).await, None),
317            "rm" => (crate::commands::rm(ctx).await, None),
318            "touch" => (crate::commands::touch(ctx).await, None),
319            "cp" => (crate::commands::cp(ctx).await, None),
320            "mv" => (crate::commands::mv(ctx).await, None),
321            "basename" => (crate::commands::basename(ctx).await, None),
322            "dirname" => (crate::commands::dirname(ctx).await, None),
323            "env" => (crate::commands::env(ctx).await, None),
324            "exit" => (crate::commands::exit(ctx).await, None),
325            "which" => (crate::commands::which(ctx).await, None),
326            "yes" => (crate::commands::yes(ctx).await, None),
327            "seq" => (crate::commands::seq(ctx).await, None),
328            "test" => (crate::commands::test(ctx).await, None),
329            _ => return None,
330        };
331        Some(VirtualCommandResult { result, cd_context })
332    }
333}
334
335/// Shell configuration
336#[derive(Debug, Clone)]
337struct ShellConfig {
338    cmd: String,
339    args: Vec<String>,
340}
341
342/// Find an available shell
343fn find_available_shell() -> ShellConfig {
344    let is_windows = cfg!(windows);
345
346    if is_windows {
347        ShellConfig {
348            cmd: "cmd.exe".to_string(),
349            args: vec!["/c".to_string()],
350        }
351    } else {
352        let shells = [
353            ("/bin/sh", "-c"),
354            ("/usr/bin/sh", "-c"),
355            ("/bin/bash", "-c"),
356        ];
357
358        for (cmd, arg) in shells {
359            if std::path::Path::new(cmd).exists() {
360                return ShellConfig {
361                    cmd: cmd.to_string(),
362                    args: vec![arg.to_string()],
363                };
364            }
365        }
366
367        ShellConfig {
368            cmd: "/bin/sh".to_string(),
369            args: vec!["-c".to_string()],
370        }
371    }
372}
373
374/// Extension trait to add `.pipe()` method to ProcessRunner
375pub trait PipelineExt {
376    /// Pipe the output of this command to another command
377    fn pipe(self, command: impl Into<String>) -> PipelineBuilder;
378}
379
380impl PipelineExt for crate::ProcessRunner {
381    fn pipe(self, command: impl Into<String>) -> PipelineBuilder {
382        PipelineBuilder {
383            first: self,
384            additional: vec![command.into()],
385        }
386    }
387}
388
389/// Builder for piping commands together
390pub struct PipelineBuilder {
391    first: crate::ProcessRunner,
392    additional: Vec<String>,
393}
394
395impl PipelineBuilder {
396    /// Add another command to the pipeline
397    pub fn pipe(mut self, command: impl Into<String>) -> Self {
398        self.additional.push(command.into());
399        self
400    }
401
402    /// Execute the pipeline
403    pub async fn run(mut self) -> Result<CommandResult> {
404        // First, run the initial command
405        let first_result = self.first.run().await?;
406
407        if first_result.code != 0 {
408            return Ok(first_result);
409        }
410
411        // Then run the rest as a pipeline
412        let mut current_stdin = Some(first_result.stdout);
413        let mut accumulated_stderr = first_result.stderr;
414        let mut last_result = CommandResult {
415            stdout: String::new(),
416            stderr: String::new(),
417            code: 0,
418        };
419
420        for cmd_str in &self.additional {
421            let mut runner = crate::ProcessRunner::new(
422                cmd_str.clone(),
423                RunOptions {
424                    stdin: StdinOption::Content(current_stdin.take().unwrap_or_default()),
425                    mirror: false,
426                    capture: true,
427                    ..Default::default()
428                },
429            );
430
431            let result = runner.run().await?;
432            accumulated_stderr.push_str(&result.stderr);
433
434            if result.code != 0 {
435                return Ok(CommandResult {
436                    stdout: result.stdout,
437                    stderr: accumulated_stderr,
438                    code: result.code,
439                });
440            }
441
442            current_stdin = Some(result.stdout.clone());
443            last_result = result;
444        }
445
446        Ok(CommandResult {
447            stdout: last_result.stdout,
448            stderr: accumulated_stderr,
449            code: last_result.code,
450        })
451    }
452}