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::new("", "No commands in pipeline", 1));
136        }
137
138        trace_lazy("Pipeline", || {
139            format!("Running pipeline with {} commands", self.commands.len())
140        });
141
142        let mut current_stdin = self.stdin.clone();
143        let mut effective_cwd = self.cwd.clone();
144        let mut effective_env = self.env.clone();
145        let mut last_result = CommandResult::new("", "", 0);
146        let mut accumulated_stderr = String::new();
147        let mut exit_codes = Vec::with_capacity(self.commands.len());
148        let pipefail = crate::get_shell_settings().await.pipefail;
149
150        for (i, cmd_str) in self.commands.iter().enumerate() {
151            let is_last = i == self.commands.len() - 1;
152
153            trace_lazy("Pipeline", || {
154                format!(
155                    "Executing command {}/{}: {}",
156                    i + 1,
157                    self.commands.len(),
158                    cmd_str
159                )
160            });
161
162            // Check if this is a virtual command
163            let first_word = cmd_str.split_whitespace().next().unwrap_or("");
164            if crate::commands::are_virtual_commands_enabled() {
165                if let Some(result) = self
166                    .try_virtual_command(
167                        first_word,
168                        cmd_str,
169                        &current_stdin,
170                        effective_cwd.as_ref(),
171                        effective_env.as_ref(),
172                    )
173                    .await
174                {
175                    let VirtualCommandResult { result, cd_context } = result;
176                    exit_codes.push(result.code);
177                    current_stdin = Some(result.stdout.to_string());
178                    accumulated_stderr.push_str(&result.stderr);
179                    if result.code == 0 {
180                        if let Some(context) = cd_context {
181                            let env =
182                                effective_env.get_or_insert_with(|| std::env::vars().collect());
183                            env.insert(
184                                "OLDPWD".to_string(),
185                                context.oldpwd.to_string_lossy().to_string(),
186                            );
187                            env.insert(
188                                "PWD".to_string(),
189                                context.cwd.to_string_lossy().to_string(),
190                            );
191                            effective_cwd = Some(context.cwd);
192                        }
193                    }
194                    last_result = result;
195                    continue;
196                }
197            }
198
199            // Execute via shell
200            let mut cmd = crate::utils::shell_command(cmd_str, effective_env.as_ref());
201
202            // Configure stdio
203            cmd.stdin(Stdio::piped());
204            cmd.stdout(Stdio::piped());
205            cmd.stderr(Stdio::piped());
206
207            // Set working directory. Fall back to a valid directory when the
208            // inherited working directory has been deleted (issue #44).
209            if let Some(cwd) = crate::resolve_spawn_cwd(effective_cwd.as_ref()) {
210                cmd.current_dir(cwd);
211            }
212
213            // Set environment
214            if let Some(ref env_vars) = effective_env {
215                for (key, value) in env_vars {
216                    cmd.env(key, value);
217                }
218            }
219
220            // Spawn the process
221            let mut child = cmd.spawn()?;
222
223            // Write stdin if available
224            if let Some(ref stdin_content) = current_stdin {
225                if let Some(mut stdin) = child.stdin.take() {
226                    let content = stdin_content.clone();
227                    tokio::spawn(async move {
228                        let _ = stdin.write_all(content.as_bytes()).await;
229                        let _ = stdin.shutdown().await;
230                    });
231                }
232            }
233
234            // Read stdout
235            let mut stdout_content = String::new();
236            if let Some(mut stdout) = child.stdout.take() {
237                stdout.read_to_string(&mut stdout_content).await?;
238            }
239
240            // Read stderr
241            let mut stderr_content = String::new();
242            if let Some(mut stderr) = child.stderr.take() {
243                stderr.read_to_string(&mut stderr_content).await?;
244            }
245
246            // Mirror output if enabled and this is the last command
247            if is_last && self.mirror {
248                if !stdout_content.is_empty() {
249                    print!("{}", stdout_content);
250                }
251                if !stderr_content.is_empty() {
252                    eprint!("{}", stderr_content);
253                }
254            }
255
256            // Wait for the process
257            let status = child.wait().await?;
258            let code = status.code().unwrap_or(-1);
259
260            accumulated_stderr.push_str(&stderr_content);
261            exit_codes.push(code);
262
263            // Set up stdin for next command
264            current_stdin = Some(stdout_content.clone());
265            last_result = CommandResult::new(stdout_content, "", code);
266        }
267
268        Ok(CommandResult::new(
269            last_result.stdout,
270            accumulated_stderr,
271            pipeline_exit_code(&exit_codes, pipefail),
272        ))
273    }
274
275    /// Try to execute a virtual command
276    async fn try_virtual_command(
277        &self,
278        cmd_name: &str,
279        full_cmd: &str,
280        stdin: &Option<String>,
281        cwd: Option<&PathBuf>,
282        env: Option<&HashMap<String, String>>,
283    ) -> Option<VirtualCommandResult> {
284        // Respect quotes and perform POSIX quote removal (issue #48), matching
285        // the non-pipeline virtual-command path.
286        let words = crate::shell_parser::split_command_words(full_cmd);
287        let args: Vec<String> = words.into_iter().skip(1).collect();
288
289        let ctx = crate::commands::CommandContext {
290            args,
291            stdin: stdin.clone(),
292            cwd: cwd.cloned(),
293            env: env.cloned(),
294            output_tx: None,
295            is_cancelled: None,
296        };
297
298        let (result, cd_context) = match cmd_name {
299            "echo" => (crate::commands::echo(ctx).await, None),
300            "pwd" => (crate::commands::pwd(ctx).await, None),
301            "cd" => crate::commands::cd::resolve_cd(ctx).await,
302            "true" => (crate::commands::r#true(ctx).await, None),
303            "false" => (crate::commands::r#false(ctx).await, None),
304            "sleep" => (crate::commands::sleep(ctx).await, None),
305            "cat" => (crate::commands::cat(ctx).await, None),
306            "ls" => (crate::commands::ls(ctx).await, None),
307            "mkdir" => (crate::commands::mkdir(ctx).await, None),
308            "rm" => (crate::commands::rm(ctx).await, None),
309            "touch" => (crate::commands::touch(ctx).await, None),
310            "cp" => (crate::commands::cp(ctx).await, None),
311            "mv" => (crate::commands::mv(ctx).await, None),
312            "basename" => (crate::commands::basename(ctx).await, None),
313            "dirname" => (crate::commands::dirname(ctx).await, None),
314            "env" => (crate::commands::env(ctx).await, None),
315            "exit" => (crate::commands::exit(ctx).await, None),
316            "which" => (crate::commands::which(ctx).await, None),
317            "yes" => (crate::commands::yes(ctx).await, None),
318            "seq" => (crate::commands::seq(ctx).await, None),
319            "tee" => (crate::commands::tee(ctx).await, None),
320            "test" => (crate::commands::test(ctx).await, None),
321            _ => return None,
322        };
323        Some(VirtualCommandResult { result, cd_context })
324    }
325}
326
327/// Extension trait to add `.pipe()` method to ProcessRunner
328pub trait PipelineExt {
329    /// Pipe the output of this command to another command
330    fn pipe(self, command: impl Into<String>) -> PipelineBuilder;
331}
332
333impl PipelineExt for crate::ProcessRunner {
334    fn pipe(self, command: impl Into<String>) -> PipelineBuilder {
335        PipelineBuilder {
336            first: self,
337            additional: vec![command.into()],
338        }
339    }
340}
341
342/// Builder for piping commands together
343pub struct PipelineBuilder {
344    first: crate::ProcessRunner,
345    additional: Vec<String>,
346}
347
348impl PipelineBuilder {
349    /// Add another command to the pipeline
350    pub fn pipe(mut self, command: impl Into<String>) -> Self {
351        self.additional.push(command.into());
352        self
353    }
354
355    /// Execute the pipeline
356    pub async fn run(mut self) -> Result<CommandResult> {
357        // First, run the initial command
358        let first_result = self.first.run().await?;
359        let pipefail = crate::get_shell_settings().await.pipefail;
360        let mut exit_codes = vec![first_result.code];
361
362        // Then run the rest as a pipeline
363        let mut current_stdin = Some(first_result.stdout.to_string());
364        let mut accumulated_stderr = first_result.stderr.to_string();
365        let mut last_result = first_result;
366
367        for cmd_str in &self.additional {
368            let mut runner = crate::ProcessRunner::new(
369                cmd_str.clone(),
370                RunOptions {
371                    stdin: StdinOption::Content(current_stdin.take().unwrap_or_default()),
372                    mirror: false,
373                    capture: true,
374                    ..Default::default()
375                },
376            );
377
378            let result = runner.run().await?;
379            accumulated_stderr.push_str(&result.stderr);
380            exit_codes.push(result.code);
381
382            current_stdin = Some(result.stdout.to_string());
383            last_result = result;
384        }
385
386        Ok(CommandResult::new(
387            last_result.stdout,
388            accumulated_stderr,
389            pipeline_exit_code(&exit_codes, pipefail),
390        ))
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::pipeline_exit_code;
397
398    #[test]
399    fn pipeline_status_uses_last_stage_by_default() {
400        assert_eq!(pipeline_exit_code(&[3, 0], false), 0);
401    }
402
403    #[test]
404    fn pipefail_uses_rightmost_failing_stage() {
405        assert_eq!(pipeline_exit_code(&[2, 7, 0], true), 7);
406    }
407}