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    pub fn add(mut self, command: impl Into<String>) -> Self {
81        self.commands.push(command.into());
82        self
83    }
84
85    /// Set the initial stdin content for the first command
86    pub fn stdin(mut self, content: impl Into<String>) -> Self {
87        self.stdin = Some(content.into());
88        self
89    }
90
91    /// Set the working directory for all commands
92    pub fn cwd(mut self, path: impl Into<PathBuf>) -> Self {
93        self.cwd = Some(path.into());
94        self
95    }
96
97    /// Set environment variables for all commands
98    pub fn env(mut self, env: HashMap<String, String>) -> Self {
99        self.env = Some(env);
100        self
101    }
102
103    /// Set whether to mirror output to stdout/stderr
104    pub fn mirror_output(mut self, mirror: bool) -> Self {
105        self.mirror = mirror;
106        self
107    }
108
109    /// Set whether to capture output
110    pub fn capture_output(mut self, capture: bool) -> Self {
111        self.capture = capture;
112        self
113    }
114
115    /// Execute the pipeline and return the result
116    pub async fn run(self) -> Result<CommandResult> {
117        if self.commands.is_empty() {
118            return Ok(CommandResult {
119                stdout: String::new(),
120                stderr: "No commands in pipeline".to_string(),
121                code: 1,
122            });
123        }
124
125        trace_lazy("Pipeline", || {
126            format!("Running pipeline with {} commands", self.commands.len())
127        });
128
129        let mut current_stdin = self.stdin.clone();
130        let mut effective_cwd = self.cwd.clone();
131        let mut effective_env = self.env.clone();
132        let mut last_result = CommandResult {
133            stdout: String::new(),
134            stderr: String::new(),
135            code: 0,
136        };
137        let mut accumulated_stderr = String::new();
138
139        for (i, cmd_str) in self.commands.iter().enumerate() {
140            let is_last = i == self.commands.len() - 1;
141
142            trace_lazy("Pipeline", || {
143                format!(
144                    "Executing command {}/{}: {}",
145                    i + 1,
146                    self.commands.len(),
147                    cmd_str
148                )
149            });
150
151            // Check if this is a virtual command
152            let first_word = cmd_str.split_whitespace().next().unwrap_or("");
153            if crate::commands::are_virtual_commands_enabled() {
154                if let Some(result) = self
155                    .try_virtual_command(
156                        first_word,
157                        cmd_str,
158                        &current_stdin,
159                        effective_cwd.as_ref(),
160                        effective_env.as_ref(),
161                    )
162                    .await
163                {
164                    let VirtualCommandResult { result, cd_context } = result;
165                    if result.code != 0 {
166                        return Ok(CommandResult {
167                            stdout: result.stdout,
168                            stderr: accumulated_stderr + &result.stderr,
169                            code: result.code,
170                        });
171                    }
172                    current_stdin = Some(result.stdout.clone());
173                    accumulated_stderr.push_str(&result.stderr);
174                    if let Some(context) = cd_context {
175                        let env = effective_env.get_or_insert_with(|| std::env::vars().collect());
176                        env.insert(
177                            "OLDPWD".to_string(),
178                            context.oldpwd.to_string_lossy().to_string(),
179                        );
180                        env.insert("PWD".to_string(), context.cwd.to_string_lossy().to_string());
181                        effective_cwd = Some(context.cwd);
182                    }
183                    last_result = result;
184                    continue;
185                }
186            }
187
188            // Execute via shell
189            let shell = find_available_shell();
190            let mut cmd = Command::new(&shell.cmd);
191            for arg in &shell.args {
192                cmd.arg(arg);
193            }
194            cmd.arg(crate::utils::with_exported_process_context(
195                cmd_str,
196                effective_env.as_ref(),
197            ));
198
199            // Configure stdio
200            cmd.stdin(Stdio::piped());
201            cmd.stdout(Stdio::piped());
202            cmd.stderr(Stdio::piped());
203
204            // Set working directory. Fall back to a valid directory when the
205            // inherited working directory has been deleted (issue #44).
206            if let Some(cwd) = crate::resolve_spawn_cwd(effective_cwd.as_ref()) {
207                cmd.current_dir(cwd);
208            }
209
210            // Set environment
211            if let Some(ref env_vars) = effective_env {
212                for (key, value) in env_vars {
213                    cmd.env(key, value);
214                }
215            }
216
217            // Spawn the process
218            let mut child = cmd.spawn()?;
219
220            // Write stdin if available
221            if let Some(ref stdin_content) = current_stdin {
222                if let Some(mut stdin) = child.stdin.take() {
223                    let content = stdin_content.clone();
224                    tokio::spawn(async move {
225                        let _ = stdin.write_all(content.as_bytes()).await;
226                        let _ = stdin.shutdown().await;
227                    });
228                }
229            }
230
231            // Read stdout
232            let mut stdout_content = String::new();
233            if let Some(mut stdout) = child.stdout.take() {
234                stdout.read_to_string(&mut stdout_content).await?;
235            }
236
237            // Read stderr
238            let mut stderr_content = String::new();
239            if let Some(mut stderr) = child.stderr.take() {
240                stderr.read_to_string(&mut stderr_content).await?;
241            }
242
243            // Mirror output if enabled and this is the last command
244            if is_last && self.mirror {
245                if !stdout_content.is_empty() {
246                    print!("{}", stdout_content);
247                }
248                if !stderr_content.is_empty() {
249                    eprint!("{}", stderr_content);
250                }
251            }
252
253            // Wait for the process
254            let status = child.wait().await?;
255            let code = status.code().unwrap_or(-1);
256
257            accumulated_stderr.push_str(&stderr_content);
258
259            if code != 0 {
260                return Ok(CommandResult {
261                    stdout: stdout_content,
262                    stderr: accumulated_stderr,
263                    code,
264                });
265            }
266
267            // Set up stdin for next command
268            current_stdin = Some(stdout_content.clone());
269            last_result = CommandResult {
270                stdout: stdout_content,
271                stderr: String::new(),
272                code,
273            };
274        }
275
276        Ok(CommandResult {
277            stdout: last_result.stdout,
278            stderr: accumulated_stderr,
279            code: last_result.code,
280        })
281    }
282
283    /// Try to execute a virtual command
284    async fn try_virtual_command(
285        &self,
286        cmd_name: &str,
287        full_cmd: &str,
288        stdin: &Option<String>,
289        cwd: Option<&PathBuf>,
290        env: Option<&HashMap<String, String>>,
291    ) -> Option<VirtualCommandResult> {
292        let parts: Vec<&str> = full_cmd.split_whitespace().collect();
293        let args: Vec<String> = parts.iter().skip(1).map(|s| s.to_string()).collect();
294
295        let ctx = crate::commands::CommandContext {
296            args,
297            stdin: stdin.clone(),
298            cwd: cwd.cloned(),
299            env: env.cloned(),
300            output_tx: None,
301            is_cancelled: None,
302        };
303
304        let (result, cd_context) = match cmd_name {
305            "echo" => (crate::commands::echo(ctx).await, None),
306            "pwd" => (crate::commands::pwd(ctx).await, None),
307            "cd" => crate::commands::cd::resolve_cd(ctx).await,
308            "true" => (crate::commands::r#true(ctx).await, None),
309            "false" => (crate::commands::r#false(ctx).await, None),
310            "sleep" => (crate::commands::sleep(ctx).await, None),
311            "cat" => (crate::commands::cat(ctx).await, None),
312            "ls" => (crate::commands::ls(ctx).await, None),
313            "mkdir" => (crate::commands::mkdir(ctx).await, None),
314            "rm" => (crate::commands::rm(ctx).await, None),
315            "touch" => (crate::commands::touch(ctx).await, None),
316            "cp" => (crate::commands::cp(ctx).await, None),
317            "mv" => (crate::commands::mv(ctx).await, None),
318            "basename" => (crate::commands::basename(ctx).await, None),
319            "dirname" => (crate::commands::dirname(ctx).await, None),
320            "env" => (crate::commands::env(ctx).await, None),
321            "exit" => (crate::commands::exit(ctx).await, None),
322            "which" => (crate::commands::which(ctx).await, None),
323            "yes" => (crate::commands::yes(ctx).await, None),
324            "seq" => (crate::commands::seq(ctx).await, None),
325            "test" => (crate::commands::test(ctx).await, None),
326            _ => return None,
327        };
328        Some(VirtualCommandResult { result, cd_context })
329    }
330}
331
332/// Shell configuration
333#[derive(Debug, Clone)]
334struct ShellConfig {
335    cmd: String,
336    args: Vec<String>,
337}
338
339/// Find an available shell
340fn find_available_shell() -> ShellConfig {
341    let is_windows = cfg!(windows);
342
343    if is_windows {
344        ShellConfig {
345            cmd: "cmd.exe".to_string(),
346            args: vec!["/c".to_string()],
347        }
348    } else {
349        let shells = [
350            ("/bin/sh", "-c"),
351            ("/usr/bin/sh", "-c"),
352            ("/bin/bash", "-c"),
353        ];
354
355        for (cmd, arg) in shells {
356            if std::path::Path::new(cmd).exists() {
357                return ShellConfig {
358                    cmd: cmd.to_string(),
359                    args: vec![arg.to_string()],
360                };
361            }
362        }
363
364        ShellConfig {
365            cmd: "/bin/sh".to_string(),
366            args: vec!["-c".to_string()],
367        }
368    }
369}
370
371/// Extension trait to add `.pipe()` method to ProcessRunner
372pub trait PipelineExt {
373    /// Pipe the output of this command to another command
374    fn pipe(self, command: impl Into<String>) -> PipelineBuilder;
375}
376
377impl PipelineExt for crate::ProcessRunner {
378    fn pipe(self, command: impl Into<String>) -> PipelineBuilder {
379        PipelineBuilder {
380            first: self,
381            additional: vec![command.into()],
382        }
383    }
384}
385
386/// Builder for piping commands together
387pub struct PipelineBuilder {
388    first: crate::ProcessRunner,
389    additional: Vec<String>,
390}
391
392impl PipelineBuilder {
393    /// Add another command to the pipeline
394    pub fn pipe(mut self, command: impl Into<String>) -> Self {
395        self.additional.push(command.into());
396        self
397    }
398
399    /// Execute the pipeline
400    pub async fn run(mut self) -> Result<CommandResult> {
401        // First, run the initial command
402        let first_result = self.first.run().await?;
403
404        if first_result.code != 0 {
405            return Ok(first_result);
406        }
407
408        // Then run the rest as a pipeline
409        let mut current_stdin = Some(first_result.stdout);
410        let mut accumulated_stderr = first_result.stderr;
411        let mut last_result = CommandResult {
412            stdout: String::new(),
413            stderr: String::new(),
414            code: 0,
415        };
416
417        for cmd_str in &self.additional {
418            let mut runner = crate::ProcessRunner::new(
419                cmd_str.clone(),
420                RunOptions {
421                    stdin: StdinOption::Content(current_stdin.take().unwrap_or_default()),
422                    mirror: false,
423                    capture: true,
424                    ..Default::default()
425                },
426            );
427
428            let result = runner.run().await?;
429            accumulated_stderr.push_str(&result.stderr);
430
431            if result.code != 0 {
432                return Ok(CommandResult {
433                    stdout: result.stdout,
434                    stderr: accumulated_stderr,
435                    code: result.code,
436                });
437            }
438
439            current_stdin = Some(result.stdout.clone());
440            last_result = result;
441        }
442
443        Ok(CommandResult {
444            stdout: last_result.stdout,
445            stderr: accumulated_stderr,
446            code: last_result.code,
447        })
448    }
449}