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            cmd.arg(crate::utils::with_exported_process_context(
199                cmd_str,
200                effective_env.as_ref(),
201            ));
202
203            // Configure stdio
204            cmd.stdin(Stdio::piped());
205            cmd.stdout(Stdio::piped());
206            cmd.stderr(Stdio::piped());
207
208            // Set working directory. Fall back to a valid directory when the
209            // inherited working directory has been deleted (issue #44).
210            if let Some(cwd) = crate::resolve_spawn_cwd(effective_cwd.as_ref()) {
211                cmd.current_dir(cwd);
212            }
213
214            // Set environment
215            if let Some(ref env_vars) = effective_env {
216                for (key, value) in env_vars {
217                    cmd.env(key, value);
218                }
219            }
220
221            // Spawn the process
222            let mut child = cmd.spawn()?;
223
224            // Write stdin if available
225            if let Some(ref stdin_content) = current_stdin {
226                if let Some(mut stdin) = child.stdin.take() {
227                    let content = stdin_content.clone();
228                    tokio::spawn(async move {
229                        let _ = stdin.write_all(content.as_bytes()).await;
230                        let _ = stdin.shutdown().await;
231                    });
232                }
233            }
234
235            // Read stdout
236            let mut stdout_content = String::new();
237            if let Some(mut stdout) = child.stdout.take() {
238                stdout.read_to_string(&mut stdout_content).await?;
239            }
240
241            // Read stderr
242            let mut stderr_content = String::new();
243            if let Some(mut stderr) = child.stderr.take() {
244                stderr.read_to_string(&mut stderr_content).await?;
245            }
246
247            // Mirror output if enabled and this is the last command
248            if is_last && self.mirror {
249                if !stdout_content.is_empty() {
250                    print!("{}", stdout_content);
251                }
252                if !stderr_content.is_empty() {
253                    eprint!("{}", stderr_content);
254                }
255            }
256
257            // Wait for the process
258            let status = child.wait().await?;
259            let code = status.code().unwrap_or(-1);
260
261            accumulated_stderr.push_str(&stderr_content);
262
263            if code != 0 {
264                return Ok(CommandResult {
265                    stdout: stdout_content,
266                    stderr: accumulated_stderr,
267                    code,
268                });
269            }
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: last_result.code,
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            "test" => (crate::commands::test(ctx).await, None),
332            _ => return None,
333        };
334        Some(VirtualCommandResult { result, cd_context })
335    }
336}
337
338/// Shell configuration
339#[derive(Debug, Clone)]
340struct ShellConfig {
341    cmd: String,
342    args: Vec<String>,
343}
344
345/// Find an available shell
346fn find_available_shell() -> ShellConfig {
347    let is_windows = cfg!(windows);
348
349    if is_windows {
350        ShellConfig {
351            cmd: "cmd.exe".to_string(),
352            args: vec!["/c".to_string()],
353        }
354    } else {
355        let shells = [
356            ("/bin/sh", "-c"),
357            ("/usr/bin/sh", "-c"),
358            ("/bin/bash", "-c"),
359        ];
360
361        for (cmd, arg) in shells {
362            if std::path::Path::new(cmd).exists() {
363                return ShellConfig {
364                    cmd: cmd.to_string(),
365                    args: vec![arg.to_string()],
366                };
367            }
368        }
369
370        ShellConfig {
371            cmd: "/bin/sh".to_string(),
372            args: vec!["-c".to_string()],
373        }
374    }
375}
376
377/// Extension trait to add `.pipe()` method to ProcessRunner
378pub trait PipelineExt {
379    /// Pipe the output of this command to another command
380    fn pipe(self, command: impl Into<String>) -> PipelineBuilder;
381}
382
383impl PipelineExt for crate::ProcessRunner {
384    fn pipe(self, command: impl Into<String>) -> PipelineBuilder {
385        PipelineBuilder {
386            first: self,
387            additional: vec![command.into()],
388        }
389    }
390}
391
392/// Builder for piping commands together
393pub struct PipelineBuilder {
394    first: crate::ProcessRunner,
395    additional: Vec<String>,
396}
397
398impl PipelineBuilder {
399    /// Add another command to the pipeline
400    pub fn pipe(mut self, command: impl Into<String>) -> Self {
401        self.additional.push(command.into());
402        self
403    }
404
405    /// Execute the pipeline
406    pub async fn run(mut self) -> Result<CommandResult> {
407        // First, run the initial command
408        let first_result = self.first.run().await?;
409
410        if first_result.code != 0 {
411            return Ok(first_result);
412        }
413
414        // Then run the rest as a pipeline
415        let mut current_stdin = Some(first_result.stdout);
416        let mut accumulated_stderr = first_result.stderr;
417        let mut last_result = CommandResult {
418            stdout: String::new(),
419            stderr: String::new(),
420            code: 0,
421        };
422
423        for cmd_str in &self.additional {
424            let mut runner = crate::ProcessRunner::new(
425                cmd_str.clone(),
426                RunOptions {
427                    stdin: StdinOption::Content(current_stdin.take().unwrap_or_default()),
428                    mirror: false,
429                    capture: true,
430                    ..Default::default()
431                },
432            );
433
434            let result = runner.run().await?;
435            accumulated_stderr.push_str(&result.stderr);
436
437            if result.code != 0 {
438                return Ok(CommandResult {
439                    stdout: result.stdout,
440                    stderr: accumulated_stderr,
441                    code: result.code,
442                });
443            }
444
445            current_stdin = Some(result.stdout.clone());
446            last_result = result;
447        }
448
449        Ok(CommandResult {
450            stdout: last_result.stdout,
451            stderr: accumulated_stderr,
452            code: last_result.code,
453        })
454    }
455}