Skip to main content

ironflow_core/operations/
shell.rs

1//! Shell operation - run system commands with timeout and environment control.
2//!
3//! The [`Shell`] builder spawns a command via `sh -c`, captures stdout/stderr,
4//! and returns a [`ShellOutput`] on success. It implements [`IntoFuture`] so you
5//! can `await` it directly without calling `.run()`:
6//!
7//! ```no_run
8//! use ironflow_core::operations::shell::Shell;
9//!
10//! # async fn example() -> Result<(), ironflow_core::error::OperationError> {
11//! // These two are equivalent:
12//! let output = Shell::new("echo hello").await?;
13//! let output = Shell::new("echo hello").run().await?;
14//! # Ok(())
15//! # }
16//! ```
17//!
18//! For safe execution without shell interpretation, use [`Shell::exec`]:
19//!
20//! ```no_run
21//! use ironflow_core::operations::shell::Shell;
22//!
23//! # async fn example() -> Result<(), ironflow_core::error::OperationError> {
24//! let output = Shell::exec("echo", &["hello", "world"]).await?;
25//! # Ok(())
26//! # }
27//! ```
28
29use std::fmt;
30use std::future::{Future, IntoFuture};
31use std::pin::Pin;
32use std::process::Stdio;
33use std::time::{Duration, Instant};
34
35use tokio::process::Command;
36use tokio::time;
37use tracing::{debug, error, warn};
38
39use crate::error::OperationError;
40#[cfg(feature = "prometheus")]
41use crate::metric_names;
42use crate::utils::truncate_output;
43
44/// How the command is executed.
45enum ShellMode {
46    /// Pass the command string to `sh -c`.
47    Shell(String),
48    /// Execute the program directly with explicit arguments, bypassing shell
49    /// interpretation.
50    Exec { program: String, args: Vec<String> },
51}
52
53impl fmt::Display for ShellMode {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        match self {
56            Self::Shell(cmd) => f.write_str(cmd),
57            Self::Exec { program, args } => {
58                write!(f, "{program}")?;
59                for arg in args {
60                    write!(f, " {arg}")?;
61                }
62                Ok(())
63            }
64        }
65    }
66}
67
68/// Default timeout for shell commands (5 minutes).
69const DEFAULT_SHELL_TIMEOUT: Duration = Duration::from_secs(300);
70
71/// Builder for executing a shell command.
72///
73/// Supports optional timeout, working directory, environment variables, and
74/// clean-environment mode. Output is truncated to [`MAX_OUTPUT_SIZE`](crate::utils::MAX_OUTPUT_SIZE)
75/// to prevent OOM on large outputs.
76///
77/// # Security
78///
79/// Commands created with [`Shell::new`] are executed via `sh -c`, which means
80/// shell metacharacters (`;`, `|`, `$()`, `` ` ``, etc.) are interpreted.
81/// **Do not** incorporate untrusted input into command strings without proper
82/// validation. Use [`Shell::env`] to pass dynamic data safely through
83/// environment variables, or use [`Shell::exec`] to bypass shell interpretation
84/// entirely.
85///
86/// # Examples
87///
88/// ```no_run
89/// use std::time::Duration;
90/// use ironflow_core::operations::shell::Shell;
91///
92/// # async fn example() -> Result<(), ironflow_core::error::OperationError> {
93/// let output = Shell::new("cargo test")
94///     .dir("/path/to/project")
95///     .timeout(Duration::from_secs(120))
96///     .env("RUST_LOG", "debug")
97///     .await?;
98///
99/// println!("stdout: {}", output.stdout());
100/// # Ok(())
101/// # }
102/// ```
103#[must_use = "a Shell command does nothing until .run() or .await is called"]
104pub struct Shell {
105    mode: ShellMode,
106    timeout: Duration,
107    dir: Option<String>,
108    env_vars: Vec<(String, String)>,
109    inherit_env: bool,
110    dry_run: Option<bool>,
111}
112
113impl Shell {
114    /// Create a new shell builder for the given command string.
115    ///
116    /// The command is passed to `sh -c`, so pipes, redirects, and other shell
117    /// features work as expected.
118    ///
119    /// # Security
120    ///
121    /// **Never** interpolate untrusted input directly into the command string.
122    /// Doing so creates a **command injection** vulnerability:
123    ///
124    /// ```no_run
125    /// # use ironflow_core::operations::shell::Shell;
126    /// // DANGEROUS - attacker controls `user_input`
127    /// # let user_input = "safe";
128    /// let _ = Shell::new(&format!("cat {user_input}"));
129    ///
130    /// // SAFE - use arguments via a wrapper script or validate input first
131    /// let _ = Shell::new("cat -- ./known_safe_file.txt");
132    /// ```
133    ///
134    /// If you need to pass dynamic values, either validate them rigorously
135    /// or use [`Shell::env`] to pass data through environment variables
136    /// (which are not interpreted by the shell).
137    pub fn new(command: &str) -> Self {
138        Self {
139            mode: ShellMode::Shell(command.to_string()),
140            timeout: DEFAULT_SHELL_TIMEOUT,
141            dir: None,
142            env_vars: Vec::new(),
143            inherit_env: true,
144            dry_run: None,
145        }
146    }
147
148    /// Create a new builder that executes a program directly without shell
149    /// interpretation.
150    ///
151    /// Unlike [`Shell::new`], this does **not** pass the command through
152    /// `sh -c`. The `program` is invoked directly with the given `args`,
153    /// so shell metacharacters in arguments are treated as literal text.
154    /// This is the preferred way to run commands with untrusted arguments.
155    ///
156    /// # Examples
157    ///
158    /// ```no_run
159    /// use ironflow_core::operations::shell::Shell;
160    ///
161    /// # async fn example() -> Result<(), ironflow_core::error::OperationError> {
162    /// let output = Shell::exec("git", &["log", "--oneline", "-5"]).await?;
163    /// println!("{}", output.stdout());
164    /// # Ok(())
165    /// # }
166    /// ```
167    pub fn exec(program: &str, args: &[&str]) -> Self {
168        Self {
169            mode: ShellMode::Exec {
170                program: program.to_string(),
171                args: args.iter().map(|a| (*a).to_string()).collect(),
172            },
173            timeout: DEFAULT_SHELL_TIMEOUT,
174            dir: None,
175            env_vars: Vec::new(),
176            inherit_env: true,
177            dry_run: None,
178        }
179    }
180
181    /// Override the maximum duration for the command.
182    ///
183    /// If the command does not complete within this duration, it is killed and
184    /// an [`OperationError::Timeout`] is returned. Defaults to 5 minutes.
185    pub fn timeout(mut self, timeout: Duration) -> Self {
186        self.timeout = timeout;
187        self
188    }
189
190    /// Set the working directory for the spawned process.
191    pub fn dir(mut self, dir: &str) -> Self {
192        self.dir = Some(dir.to_string());
193        self
194    }
195
196    /// Add an environment variable to the spawned process.
197    ///
198    /// Can be called multiple times to set several variables.
199    pub fn env(mut self, key: &str, value: &str) -> Self {
200        self.env_vars.push((key.to_string(), value.to_string()));
201        self
202    }
203
204    /// Clear the inherited environment so the process starts with an empty
205    /// environment (plus any variables added via [`env`](Shell::env)).
206    pub fn clean_env(mut self) -> Self {
207        self.inherit_env = false;
208        self
209    }
210
211    /// Enable or disable dry-run mode for this specific operation.
212    ///
213    /// When dry-run is active, the command is logged but not executed.
214    /// A synthetic [`ShellOutput`] is returned with empty stdout/stderr,
215    /// exit code 0, and 0ms duration.
216    ///
217    /// If not set, falls back to the global dry-run setting
218    /// (see [`set_dry_run`](crate::dry_run::set_dry_run)).
219    pub fn dry_run(mut self, enabled: bool) -> Self {
220        self.dry_run = Some(enabled);
221        self
222    }
223
224    /// Execute the command and wait for it to complete.
225    ///
226    /// # Errors
227    ///
228    /// * [`OperationError::Shell`] - if the command exits with a non-zero code
229    ///   or cannot be spawned.
230    /// * [`OperationError::Timeout`] - if the command exceeds the configured
231    ///   [`timeout`](Shell::timeout).
232    #[tracing::instrument(name = "shell", skip_all, fields(command = %self.mode))]
233    pub async fn run(self) -> Result<ShellOutput, OperationError> {
234        let command_display = self.mode.to_string();
235
236        if crate::dry_run::effective_dry_run(self.dry_run) {
237            debug!(command = %command_display, "[dry-run] shell command skipped");
238            return Ok(ShellOutput {
239                stdout: String::new(),
240                stderr: String::new(),
241                exit_code: 0,
242                duration_ms: 0,
243            });
244        }
245
246        debug!(command = %command_display, "executing shell command");
247
248        let start = Instant::now();
249
250        let mut cmd = match &self.mode {
251            ShellMode::Shell(command) => {
252                let mut c = Command::new("sh");
253                c.arg("-c").arg(command);
254                c
255            }
256            ShellMode::Exec { program, args } => {
257                let mut c = Command::new(program);
258                c.args(args);
259                c
260            }
261        };
262
263        cmd.stdout(Stdio::piped())
264            .stderr(Stdio::piped())
265            .kill_on_drop(true);
266
267        if !self.inherit_env {
268            cmd.env_clear();
269        }
270
271        if let Some(ref dir) = self.dir {
272            cmd.current_dir(dir);
273        }
274
275        for (key, value) in &self.env_vars {
276            cmd.env(key, value);
277        }
278
279        let child = cmd.spawn().map_err(|e| OperationError::Shell {
280            exit_code: -1,
281            stderr: format!("failed to spawn shell: {e}"),
282        })?;
283
284        let output = match time::timeout(self.timeout, child.wait_with_output()).await {
285            Ok(result) => result.map_err(|e| OperationError::Shell {
286                exit_code: -1,
287                stderr: format!("failed to wait for shell: {e}"),
288            })?,
289            Err(_) => {
290                return Err(OperationError::Timeout {
291                    step: command_display,
292                    limit: self.timeout,
293                });
294            }
295        };
296
297        let duration_ms = start.elapsed().as_millis() as u64;
298        let stdout = truncate_output(&output.stdout, "shell stdout");
299        let stderr = truncate_output(&output.stderr, "shell stderr");
300
301        let exit_code = output.status.code().unwrap_or_else(|| {
302            #[cfg(unix)]
303            {
304                use std::os::unix::process::ExitStatusExt;
305                if let Some(signal) = output.status.signal() {
306                    warn!(signal, "process killed by signal");
307                    return -signal;
308                }
309            }
310            -1
311        });
312
313        #[cfg(feature = "prometheus")]
314        metrics::histogram!(metric_names::SHELL_DURATION_SECONDS)
315            .record(duration_ms as f64 / 1000.0);
316
317        if !output.status.success() {
318            error!(exit_code, stderr = %stderr, "shell command failed");
319            #[cfg(feature = "prometheus")]
320            metrics::counter!(metric_names::SHELL_TOTAL, "status" => metric_names::STATUS_ERROR)
321                .increment(1);
322            return Err(OperationError::Shell { exit_code, stderr });
323        }
324
325        debug!(
326            exit_code,
327            stdout_len = stdout.len(),
328            duration_ms,
329            "shell command completed"
330        );
331
332        #[cfg(feature = "prometheus")]
333        metrics::counter!(metric_names::SHELL_TOTAL, "status" => metric_names::STATUS_SUCCESS)
334            .increment(1);
335
336        Ok(ShellOutput {
337            stdout,
338            stderr,
339            exit_code,
340            duration_ms,
341        })
342    }
343}
344
345impl IntoFuture for Shell {
346    type Output = Result<ShellOutput, OperationError>;
347    type IntoFuture = Pin<Box<dyn Future<Output = Self::Output> + Send>>;
348
349    fn into_future(self) -> Self::IntoFuture {
350        Box::pin(self.run())
351    }
352}
353
354/// Output of a successful shell command execution.
355///
356/// Contains the captured stdout, stderr, exit code, and duration.
357#[derive(Debug)]
358pub struct ShellOutput {
359    stdout: String,
360    stderr: String,
361    exit_code: i32,
362    duration_ms: u64,
363}
364
365impl ShellOutput {
366    /// Return the captured standard output, trimmed and truncated to
367    /// [`MAX_OUTPUT_SIZE`](crate::utils::MAX_OUTPUT_SIZE).
368    pub fn stdout(&self) -> &str {
369        &self.stdout
370    }
371
372    /// Return the captured standard error, trimmed and truncated to
373    /// [`MAX_OUTPUT_SIZE`](crate::utils::MAX_OUTPUT_SIZE).
374    pub fn stderr(&self) -> &str {
375        &self.stderr
376    }
377
378    /// Return the process exit code (`0` on success).
379    pub fn exit_code(&self) -> i32 {
380        self.exit_code
381    }
382
383    /// Return the wall-clock duration of the command in milliseconds.
384    pub fn duration_ms(&self) -> u64 {
385        self.duration_ms
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::dry_run::{DryRunGuard, set_dry_run};
393    use serial_test::serial;
394    use std::time::Duration;
395
396    #[tokio::test]
397    async fn test_shell_new_creates_with_correct_command() {
398        let shell = Shell::new("echo hello");
399        assert_eq!(shell.timeout, DEFAULT_SHELL_TIMEOUT);
400        assert!(shell.inherit_env);
401        assert!(shell.dir.is_none());
402        assert!(shell.env_vars.is_empty());
403    }
404
405    #[tokio::test]
406    async fn test_shell_exec_creates_with_program_and_args() {
407        let shell = Shell::exec("echo", &["hello", "world"]);
408        assert_eq!(shell.timeout, DEFAULT_SHELL_TIMEOUT);
409        assert!(shell.inherit_env);
410        assert!(shell.dir.is_none());
411        assert!(shell.env_vars.is_empty());
412    }
413
414    #[tokio::test]
415    async fn test_timeout_builder_returns_self() {
416        let custom_timeout = Duration::from_secs(10);
417        let shell = Shell::new("echo hello").timeout(custom_timeout);
418        assert_eq!(shell.timeout, custom_timeout);
419    }
420
421    #[tokio::test]
422    async fn test_timeout_is_enforced() {
423        let short_timeout = Duration::from_millis(100);
424        let result = Shell::new("sleep 10")
425            .dry_run(false)
426            .timeout(short_timeout)
427            .await;
428
429        assert!(result.is_err());
430        match result {
431            Err(OperationError::Timeout { step, limit }) => {
432                assert_eq!(limit, short_timeout);
433                assert!(step.contains("sleep"));
434            }
435            _ => panic!("expected Timeout error"),
436        }
437    }
438
439    #[tokio::test]
440    async fn test_dir_builder_returns_self() {
441        let shell = Shell::new("pwd").dir("/tmp");
442        assert_eq!(shell.dir, Some("/tmp".to_string()));
443    }
444
445    #[tokio::test]
446    async fn test_dir_is_respected() {
447        let output = Shell::new("pwd")
448            .dry_run(false)
449            .dir("/tmp")
450            .await
451            .expect("failed to run pwd in /tmp");
452
453        // pwd should output /tmp (or a symlink to it)
454        let pwd_output = output.stdout().trim();
455        assert!(pwd_output.ends_with("/tmp") || pwd_output.ends_with("private/tmp"));
456    }
457
458    #[tokio::test]
459    async fn test_env_builder_returns_self() {
460        let shell = Shell::new("echo $TEST_VAR").env("TEST_VAR", "hello");
461        assert_eq!(shell.env_vars.len(), 1);
462        assert_eq!(
463            shell.env_vars[0],
464            ("TEST_VAR".to_string(), "hello".to_string())
465        );
466    }
467
468    #[tokio::test]
469    async fn test_env_is_visible_to_command() {
470        let output = Shell::new("echo $TEST_VAR")
471            .dry_run(false)
472            .env("TEST_VAR", "custom_value")
473            .await
474            .expect("failed to run echo with env var");
475
476        assert_eq!(output.stdout().trim(), "custom_value");
477    }
478
479    #[tokio::test]
480    async fn test_multiple_env_vars() {
481        let output = Shell::new("echo $VAR1:$VAR2")
482            .dry_run(false)
483            .env("VAR1", "foo")
484            .env("VAR2", "bar")
485            .await
486            .expect("failed to run echo with multiple env vars");
487
488        assert_eq!(output.stdout().trim(), "foo:bar");
489    }
490
491    #[tokio::test]
492    async fn test_clean_env_clears_inherited_environment() {
493        // With clean env, we need to use the full path to commands
494        let output = Shell::exec("/bin/echo", &["hello"])
495            .dry_run(false)
496            .clean_env()
497            .await
498            .expect("failed to run with clean env");
499
500        assert_eq!(output.stdout().trim(), "hello");
501    }
502
503    #[tokio::test]
504    async fn test_clean_env_with_custom_var_only() {
505        // With clean env, only our custom var should be visible
506        let output = Shell::exec("/bin/sh", &["-c", "echo $CUSTOM_VAR"])
507            .dry_run(false)
508            .clean_env()
509            .env("CUSTOM_VAR", "value")
510            .await
511            .expect("failed to run with clean env and custom var");
512
513        assert_eq!(output.stdout().trim(), "value");
514    }
515
516    #[tokio::test]
517    async fn test_dry_run_true_skips_execution() {
518        let output = Shell::new("echo test")
519            .dry_run(true)
520            .await
521            .expect("dry run should not fail");
522
523        assert_eq!(output.stdout(), "");
524        assert_eq!(output.stderr(), "");
525        assert_eq!(output.exit_code(), 0);
526        assert_eq!(output.duration_ms(), 0);
527    }
528
529    #[tokio::test]
530    async fn test_dry_run_false_executes_command() {
531        let output = Shell::new("echo hello")
532            .dry_run(false)
533            .await
534            .expect("dry run false should execute");
535
536        assert_eq!(output.stdout(), "hello");
537    }
538
539    #[tokio::test]
540    #[serial]
541    async fn test_global_dry_run_affects_operations() {
542        set_dry_run(false);
543        {
544            let _guard = DryRunGuard::new(true);
545            let output = Shell::new("echo test")
546                .await
547                .expect("dry run should not fail");
548
549            assert_eq!(output.stdout(), "");
550            assert_eq!(output.duration_ms(), 0);
551        }
552        set_dry_run(false);
553    }
554
555    #[tokio::test]
556    #[serial]
557    async fn test_per_operation_dry_run_overrides_global() {
558        set_dry_run(false);
559        {
560            let _guard = DryRunGuard::new(true);
561            let output = Shell::new("echo hello")
562                .dry_run(false)
563                .await
564                .expect("per-operation dry_run(false) should override global");
565
566            // Should execute despite global dry-run being true
567            assert_eq!(output.stdout(), "hello");
568        }
569        set_dry_run(false);
570    }
571
572    #[tokio::test]
573    async fn test_run_captures_stdout_stderr_exit_code() {
574        let output = Shell::new("echo stdout && echo stderr >&2; exit 0")
575            .dry_run(false)
576            .await
577            .expect("should not fail with exit 0");
578
579        assert_eq!(output.stdout().trim(), "stdout");
580        assert!(output.stderr().contains("stderr"));
581        assert_eq!(output.exit_code(), 0);
582    }
583
584    #[tokio::test]
585    async fn test_failed_command_returns_error() {
586        let result = Shell::new("exit 42").dry_run(false).await;
587
588        assert!(result.is_err());
589        match result {
590            Err(OperationError::Shell {
591                exit_code,
592                stderr: _,
593            }) => {
594                assert_eq!(exit_code, 42);
595            }
596            _ => panic!("expected Shell error"),
597        }
598    }
599
600    #[tokio::test]
601    async fn test_non_zero_exit_code_captured() {
602        let result = Shell::new("sh -c 'exit 7'").dry_run(false).await;
603
604        assert!(result.is_err());
605        if let Err(OperationError::Shell { exit_code, .. }) = result {
606            assert_eq!(exit_code, 7);
607        } else {
608            panic!("expected Shell error with exit_code 7");
609        }
610    }
611
612    #[tokio::test]
613    async fn test_shell_exec_without_shell_interpretation() {
614        // With exec, the pipe should be passed as a literal argument, not interpreted
615        let output = Shell::exec("echo", &["hello | world"])
616            .dry_run(false)
617            .await
618            .expect("exec should not interpret pipe");
619
620        assert_eq!(output.stdout().trim(), "hello | world");
621    }
622
623    #[tokio::test]
624    async fn test_shell_new_with_shell_interpretation() {
625        // With Shell::new, the pipe should be interpreted
626        let output = Shell::new("echo hello | wc -w")
627            .dry_run(false)
628            .await
629            .expect("should interpret pipe");
630
631        assert_eq!(output.stdout().trim(), "1");
632    }
633
634    #[tokio::test]
635    async fn test_empty_command_string() {
636        // Empty command via sh -c should succeed with empty output
637        let output = Shell::new("")
638            .dry_run(false)
639            .await
640            .expect("empty command should succeed");
641
642        assert_eq!(output.stdout(), "");
643        assert_eq!(output.exit_code(), 0);
644    }
645
646    #[tokio::test]
647    async fn test_unicode_in_stdout() {
648        let output = Shell::new("echo '你好世界'")
649            .dry_run(false)
650            .await
651            .expect("should handle unicode");
652
653        assert!(output.stdout().contains("你好"));
654    }
655
656    #[tokio::test]
657    async fn test_unicode_in_stderr() {
658        let result = Shell::new("echo '错误日志' >&2; exit 1")
659            .dry_run(false)
660            .await;
661
662        assert!(result.is_err());
663        if let Err(OperationError::Shell { stderr, .. }) = result {
664            assert!(stderr.contains("错误"));
665        }
666    }
667
668    #[tokio::test]
669    async fn test_large_output_is_truncated() {
670        // Create output larger than MAX_OUTPUT_SIZE
671        // Instead of actually creating 10MB, we'll test the truncation logic
672        // by checking that very large outputs don't cause OOM
673        let large_count = 1000; // Create 1000 lines, which is safe but still substantial
674        let cmd = format!(
675            "for i in $(seq 1 {}); do echo \"line $i\"; done",
676            large_count
677        );
678        let output = Shell::new(&cmd)
679            .dry_run(false)
680            .await
681            .expect("should handle large output");
682
683        // Output should be successful
684        assert_eq!(output.exit_code(), 0);
685        assert!(!output.stdout().is_empty());
686    }
687
688    #[tokio::test]
689    async fn test_duration_is_recorded() {
690        let output = Shell::new("sleep 0.1")
691            .dry_run(false)
692            .await
693            .expect("should complete");
694
695        assert!(output.duration_ms() >= 100);
696        assert!(output.duration_ms() < 2000); // generous headroom for slow CI
697    }
698
699    #[tokio::test]
700    async fn test_into_future_trait() {
701        // This tests that Shell implements IntoFuture
702        let output = Shell::new("echo into_future")
703            .dry_run(false)
704            .await
705            .expect("should work");
706        assert_eq!(output.stdout(), "into_future");
707    }
708
709    #[tokio::test]
710    async fn test_multiple_builder_calls_chain() {
711        let output = Shell::new("echo test")
712            .dry_run(false)
713            .timeout(Duration::from_secs(30))
714            .env("MY_VAR", "value")
715            .dir("/tmp")
716            .await
717            .expect("chained builders should work");
718
719        assert_eq!(output.stdout(), "test");
720    }
721
722    #[tokio::test]
723    async fn test_shell_output_accessors() {
724        let output = Shell::new("echo hello && echo world >&2")
725            .dry_run(false)
726            .await
727            .expect("should succeed");
728
729        let stdout = output.stdout();
730        let stderr = output.stderr();
731        let exit_code = output.exit_code();
732        assert_eq!(stdout, "hello");
733        assert!(stderr.contains("world"));
734        assert_eq!(exit_code, 0);
735    }
736
737    #[tokio::test]
738    async fn test_spawning_nonexistent_program_fails() {
739        let result = Shell::exec("/nonexistent/program/path", &[])
740            .dry_run(false)
741            .await;
742
743        assert!(result.is_err());
744        match result {
745            Err(OperationError::Shell {
746                exit_code,
747                stderr: _,
748            }) => {
749                assert_eq!(exit_code, -1);
750            }
751            _ => panic!("expected Shell error"),
752        }
753    }
754
755    #[tokio::test]
756    async fn test_output_is_trimmed() {
757        let output = Shell::new("echo 'hello\n\n'")
758            .dry_run(false)
759            .await
760            .expect("should succeed");
761
762        // truncate_output trims trailing whitespace
763        assert_eq!(output.stdout(), "hello");
764    }
765
766    #[tokio::test]
767    async fn test_complex_shell_features() {
768        let output = Shell::new("echo first && echo second | head -1")
769            .dry_run(false)
770            .await
771            .expect("complex shell should work");
772
773        assert!(output.stdout().contains("first"));
774        assert!(output.stdout().contains("second"));
775    }
776
777    #[tokio::test]
778    async fn test_stderr_on_success_is_captured() {
779        let output = Shell::new("echo success && echo warnings >&2")
780            .dry_run(false)
781            .await
782            .expect("should succeed despite stderr");
783
784        assert_eq!(output.stdout().trim(), "success");
785        assert!(output.stderr().contains("warnings"));
786        assert_eq!(output.exit_code(), 0);
787    }
788
789    #[tokio::test]
790    async fn test_must_use_attribute_on_shell() {
791        // This is a compile-time check, but we can at least construct and drop
792        let _shell = Shell::new("echo test");
793    }
794
795    #[tokio::test]
796    async fn test_shell_mode_display_for_new() {
797        let shell = Shell::new("echo test");
798        let mode_str = shell.mode.to_string();
799        assert_eq!(mode_str, "echo test");
800    }
801
802    #[tokio::test]
803    async fn test_shell_mode_display_for_exec() {
804        let shell = Shell::exec("echo", &["hello", "world"]);
805        let mode_str = shell.mode.to_string();
806        assert!(mode_str.contains("echo"));
807        assert!(mode_str.contains("hello"));
808        assert!(mode_str.contains("world"));
809    }
810}