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