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