Skip to main content

flowey_core/
shell.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! Shell abstraction for flowey command execution.
5//!
6//! Provides [`FloweyShell`] and [`FloweyCmd`] as thin wrappers around
7//! [`xshell::Shell`] and [`xshell::Cmd`] that enable command
8//! wrapping (e.g., running commands inside `nix-shell --pure --run`).
9
10use std::ffi::OsStr;
11use std::ffi::OsString;
12use std::ops::Deref;
13use std::process::ExitStatus;
14use std::process::Output;
15
16use serde::Deserialize;
17use serde::Serialize;
18
19/// A wrapper around [`xshell::Shell`] that supports transparent command
20/// wrapping via an optional [`CommandWrapperKind`].
21///
22/// Implements [`Deref<Target = xshell::Shell>`] so that existing usages like
23/// `rt.sh.change_dir()` and `rt.sh.set_var()` continue to work unchanged.
24pub struct FloweyShell {
25    inner: xshell::Shell,
26    wrapper: Option<CommandWrapperKind>,
27}
28
29impl FloweyShell {
30    /// Create a new `FloweyShell` with no command wrapper.
31    #[expect(clippy::disallowed_methods)]
32    pub fn new() -> anyhow::Result<Self> {
33        Ok(Self {
34            inner: xshell::Shell::new()?,
35            wrapper: None,
36        })
37    }
38
39    /// Set (or clear) the command wrapper used for all commands created
40    /// through this shell.
41    pub fn set_wrapper(&mut self, wrapper: Option<CommandWrapperKind>) {
42        self.wrapper = wrapper;
43    }
44
45    /// Access the underlying [`xshell::Shell`].
46    ///
47    /// This is primarily used by the [`shell_cmd!`](crate::shell_cmd)
48    /// macro to pass the shell reference into [`xshell::cmd!`].
49    pub fn xshell(&self) -> &xshell::Shell {
50        &self.inner
51    }
52
53    /// Wrap an [`xshell::Cmd`] into a [`FloweyCmd`] that will apply
54    /// this shell's [`CommandWrapperKind`] (if any) at execution time.
55    pub fn wrap<'a>(&'a self, cmd: xshell::Cmd<'a>) -> FloweyCmd<'a> {
56        FloweyCmd {
57            inner: cmd,
58            env_changes: Vec::new(),
59            stdin_contents: None,
60            ignore_status: false,
61            quiet: false,
62            secret: false,
63            ignore_stdout: false,
64            ignore_stderr: false,
65            wrapper: self.wrapper.clone(),
66            sh: &self.inner,
67        }
68    }
69}
70
71impl Deref for FloweyShell {
72    type Target = xshell::Shell;
73
74    fn deref(&self) -> &xshell::Shell {
75        &self.inner
76    }
77}
78
79/// Environment variable changes tracked by [`FloweyCmd`].
80enum EnvChange {
81    Set(OsString, OsString),
82    Remove(OsString),
83    Clear,
84}
85
86/// A wrapper around [`xshell::Cmd`] that applies a [`CommandWrapperKind`]
87/// at execution time.
88///
89/// Builder methods (`.arg()`, `.env()`, etc.) are accumulated on the
90/// inner [`xshell::Cmd`] (for args) or in shadow fields (for env, stdin,
91/// and flags). Execution methods (`.run()`, `.read()`, etc.) consume
92/// `self`, apply the wrapper transformation, re-apply the shadowed state,
93/// and then execute.
94pub struct FloweyCmd<'a> {
95    /// The inner command accumulates program + arguments only.
96    inner: xshell::Cmd<'a>,
97    // Shadow fields for state that must survive wrapping.
98    env_changes: Vec<EnvChange>,
99    stdin_contents: Option<Vec<u8>>,
100    ignore_status: bool,
101    quiet: bool,
102    secret: bool,
103    ignore_stdout: bool,
104    ignore_stderr: bool,
105    wrapper: Option<CommandWrapperKind>,
106    sh: &'a xshell::Shell,
107}
108
109// Mirrors xshell::Cmd's builder methods, but xshell doesn't export a common trait to implement
110impl<'a> FloweyCmd<'a> {
111    /// Adds an argument to this command.
112    pub fn arg<P: AsRef<OsStr>>(mut self, arg: P) -> Self {
113        self.inner = self.inner.arg(arg);
114        self
115    }
116
117    /// Adds all of the arguments to this command.
118    pub fn args<I>(mut self, args: I) -> Self
119    where
120        I: IntoIterator,
121        I::Item: AsRef<OsStr>,
122    {
123        self.inner = self.inner.args(args);
124        self
125    }
126
127    /// Overrides the value of an environmental variable for this command.
128    pub fn env<K: AsRef<OsStr>, V: AsRef<OsStr>>(mut self, key: K, val: V) -> Self {
129        self.env_changes.push(EnvChange::Set(
130            key.as_ref().to_owned(),
131            val.as_ref().to_owned(),
132        ));
133        self
134    }
135
136    /// Overrides the values of specified environmental variables for this
137    /// command.
138    pub fn envs<I, K, V>(mut self, vars: I) -> Self
139    where
140        I: IntoIterator<Item = (K, V)>,
141        K: AsRef<OsStr>,
142        V: AsRef<OsStr>,
143    {
144        for (k, v) in vars {
145            self.env_changes
146                .push(EnvChange::Set(k.as_ref().to_owned(), v.as_ref().to_owned()));
147        }
148        self
149    }
150
151    /// Removes an environment variable from this command.
152    pub fn env_remove<K: AsRef<OsStr>>(mut self, key: K) -> Self {
153        self.env_changes
154            .push(EnvChange::Remove(key.as_ref().to_owned()));
155        self
156    }
157
158    /// Removes all environment variables from this command.
159    pub fn env_clear(mut self) -> Self {
160        self.env_changes.push(EnvChange::Clear);
161        self
162    }
163
164    /// If set, the command's status code will not be checked, and
165    /// non-zero exit codes will not produce an error.
166    pub fn ignore_status(mut self) -> Self {
167        self.ignore_status = true;
168        self
169    }
170
171    /// Mutating variant of [`ignore_status`](Self::ignore_status).
172    pub fn set_ignore_status(&mut self, yes: bool) {
173        self.ignore_status = yes;
174    }
175
176    /// If set, the command's output will not be echoed to stdout.
177    pub fn quiet(mut self) -> Self {
178        self.quiet = true;
179        self
180    }
181
182    /// Mutating variant of [`quiet`](Self::quiet).
183    pub fn set_quiet(&mut self, yes: bool) {
184        self.quiet = yes;
185    }
186
187    /// If set, the command is treated as containing a secret and its
188    /// display will be redacted.
189    pub fn secret(mut self) -> Self {
190        self.secret = true;
191        self
192    }
193
194    /// Mutating variant of [`secret`](Self::secret).
195    pub fn set_secret(&mut self, yes: bool) {
196        self.secret = yes;
197    }
198
199    /// Passes data to the command's stdin.
200    pub fn stdin(mut self, stdin: impl AsRef<[u8]>) -> Self {
201        self.stdin_contents = Some(stdin.as_ref().to_vec());
202        self
203    }
204
205    /// If set, stdout is not captured.
206    pub fn ignore_stdout(mut self) -> Self {
207        self.ignore_stdout = true;
208        self
209    }
210
211    /// Mutating variant of [`ignore_stdout`](Self::ignore_stdout).
212    pub fn set_ignore_stdout(&mut self, yes: bool) {
213        self.ignore_stdout = yes;
214    }
215
216    /// If set, stderr is not captured.
217    pub fn ignore_stderr(mut self) -> Self {
218        self.ignore_stderr = true;
219        self
220    }
221
222    /// Mutating variant of [`ignore_stderr`](Self::ignore_stderr).
223    pub fn set_ignore_stderr(&mut self, yes: bool) {
224        self.ignore_stderr = yes;
225    }
226
227    /// Consume this command, apply the wrapper (if any), re-apply
228    /// shadowed state (env, stdin, flags), and return the final
229    /// [`xshell::Cmd`] ready for execution.
230    fn into_resolved(self) -> xshell::Cmd<'a> {
231        let mut cmd = match self.wrapper {
232            Some(wrapper) => wrapper.wrap_cmd(self.sh, self.inner),
233            None => self.inner,
234        };
235
236        // Re-apply env changes after wrapping to survive the wrapper's transformation
237        for change in self.env_changes {
238            match change {
239                EnvChange::Set(k, v) => cmd = cmd.env(k, v),
240                EnvChange::Remove(k) => cmd = cmd.env_remove(k),
241                EnvChange::Clear => cmd = cmd.env_clear(),
242            }
243        }
244        if let Some(stdin) = self.stdin_contents {
245            cmd = cmd.stdin(stdin);
246        }
247        cmd.set_ignore_status(self.ignore_status);
248        cmd.set_quiet(self.quiet);
249        cmd.set_secret(self.secret);
250        cmd.set_ignore_stdout(self.ignore_stdout);
251        cmd.set_ignore_stderr(self.ignore_stderr);
252
253        cmd
254    }
255
256    /// Run the command.
257    pub fn run(self) -> xshell::Result<()> {
258        self.into_resolved().run()
259    }
260
261    /// Run with inherited standard input, allowing interactive terminal use.
262    pub fn run_interactive(self) -> anyhow::Result<ExitStatus> {
263        anyhow::ensure!(
264            self.stdin_contents.is_none(),
265            "interactive commands cannot also supply stdin contents"
266        );
267        if !self.quiet {
268            eprintln!("$ {self}");
269        }
270        let ignore_status = self.ignore_status;
271        let description = self.to_string();
272        let mut command: std::process::Command = self.into_resolved().into();
273        let status = command.status()?;
274        anyhow::ensure!(
275            status.success() || ignore_status,
276            "command {description} exited with {status}"
277        );
278        Ok(status)
279    }
280
281    /// Run the command and return its stdout as a string, with leading
282    /// and trailing whitespace trimmed.
283    pub fn read(self) -> xshell::Result<String> {
284        self.into_resolved().read()
285    }
286
287    /// Run the command and return its stderr as a string, with leading
288    /// and trailing whitespace trimmed.
289    pub fn read_stderr(self) -> xshell::Result<String> {
290        self.into_resolved().read_stderr()
291    }
292
293    /// Run the command and return its full output.
294    pub fn output(self) -> xshell::Result<Output> {
295        self.into_resolved().output()
296    }
297}
298
299impl std::fmt::Display for FloweyCmd<'_> {
300    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301        if self.secret {
302            return f.write_str("<secret>");
303        }
304        // Show the unwrapped command for user-facing logging.
305        std::fmt::Display::fmt(&self.inner, f)
306    }
307}
308
309/// Serializable description of a command wrapper.
310///
311/// This enum can be stored in `pipeline.json` so that CI backends can
312/// reconstruct the appropriate wrapper at runtime. It is also used
313/// directly by [`FloweyShell`] and [`FloweyCmd`] to transform commands.
314#[derive(Clone, Debug, Serialize, Deserialize)]
315pub enum CommandWrapperKind {
316    /// Wrap commands with `nix-shell --pure --run "..."`.
317    NixShell {
318        /// Optional path to a `shell.nix` file. If `None`, nix-shell
319        /// uses its default discovery (looking for `shell.nix` /
320        /// `default.nix` in the current directory).
321        path: Option<std::path::PathBuf>,
322    },
323    /// Wrap commands with `sh -c "..."` (test-only).
324    #[cfg(test)]
325    ShCmd,
326    /// Wrap commands with `cmd /C "..."` (test-only, Windows).
327    #[cfg(test)]
328    CmdExe,
329    /// Replace the command with `echo WRAPPED: <cmd>` (test-only).
330    #[cfg(test)]
331    Prefix,
332}
333
334impl CommandWrapperKind {
335    /// Transform a command before execution.
336    ///
337    /// The `cmd` parameter contains only the program and arguments.
338    /// Environment variables, stdin, and flags are applied by
339    /// [`FloweyCmd`] after this method returns.
340    fn wrap_cmd<'a>(self, sh: &'a xshell::Shell, cmd: xshell::Cmd<'a>) -> xshell::Cmd<'a> {
341        let cmd_str = format!("{cmd}");
342        match self {
343            CommandWrapperKind::NixShell { path } => {
344                let mut wrapped = sh.cmd("nix-shell");
345                if let Some(path) = path {
346                    wrapped = wrapped.arg(path);
347                }
348                wrapped.arg("--pure").arg("--run").arg(cmd_str)
349            }
350            #[cfg(test)]
351            CommandWrapperKind::ShCmd => sh.cmd("sh").arg("-c").arg(cmd_str),
352            #[cfg(test)]
353            CommandWrapperKind::CmdExe => {
354                // Avoid nesting `cmd /C` when the command already targets cmd.
355                // This keeps Windows test wrappers stable across quoting rules.
356                let cmd_body = cmd_str
357                    .strip_prefix("cmd /C ")
358                    .unwrap_or(&cmd_str)
359                    .trim_matches('"');
360                sh.cmd("cmd").arg("/C").arg(cmd_body)
361            }
362            #[cfg(test)]
363            CommandWrapperKind::Prefix => sh.cmd("echo").arg(format!("WRAPPED: {cmd_str}")),
364        }
365    }
366}
367
368#[cfg(test)]
369#[expect(clippy::disallowed_macros, reason = "test module")]
370mod tests {
371    use super::*;
372
373    fn env_test_wrapper() -> CommandWrapperKind {
374        if cfg!(windows) {
375            CommandWrapperKind::CmdExe
376        } else {
377            CommandWrapperKind::ShCmd
378        }
379    }
380
381    fn print_env_cmd<'a>(sh: &'a FloweyShell, var: &str) -> xshell::Cmd<'a> {
382        if cfg!(windows) {
383            sh.xshell()
384                .cmd("cmd")
385                .arg("/C")
386                .arg(format!("if defined {var} (echo %{var}%) else exit /b 1"))
387        } else {
388            sh.xshell().cmd("printenv").arg(var)
389        }
390    }
391
392    fn fail_cmd<'a>(sh: &'a FloweyShell) -> xshell::Cmd<'a> {
393        if cfg!(windows) {
394            sh.xshell().cmd("cmd").arg("/C").arg("exit /b 1")
395        } else {
396            sh.xshell().cmd("false")
397        }
398    }
399
400    #[test]
401    fn no_wrapper_runs_command_directly() {
402        let sh = FloweyShell::new().unwrap();
403        let cmd = sh.wrap(xshell::cmd!(sh.xshell(), "echo hello"));
404        let output = cmd.read().unwrap();
405        assert_eq!(output, "hello");
406    }
407
408    #[test]
409    fn wrapper_transforms_command() {
410        let mut sh = FloweyShell::new().unwrap();
411        sh.set_wrapper(Some(CommandWrapperKind::Prefix));
412        let cmd = sh.wrap(xshell::cmd!(sh.xshell(), "my-program --flag"));
413        let output = cmd.read().unwrap();
414        assert_eq!(output, "WRAPPED: my-program --flag");
415    }
416
417    #[test]
418    fn interactive_commands_preserve_wrapping_and_status_handling() {
419        let mut sh = FloweyShell::new().unwrap();
420        assert!(sh.wrap(fail_cmd(&sh)).run_interactive().is_err());
421        assert_eq!(
422            sh.wrap(fail_cmd(&sh))
423                .ignore_status()
424                .run_interactive()
425                .unwrap()
426                .code(),
427            Some(1)
428        );
429        sh.set_wrapper(Some(CommandWrapperKind::Prefix));
430        sh.wrap(xshell::cmd!(sh.xshell(), "nonexistent-program --flag"))
431            .run_interactive()
432            .unwrap();
433        assert!(
434            sh.wrap(xshell::cmd!(sh.xshell(), "echo"))
435                .stdin("incompatible input")
436                .run_interactive()
437                .is_err()
438        );
439    }
440
441    #[test]
442    fn env_vars_survive_with_wrapper() {
443        let mut sh = FloweyShell::new().unwrap();
444        sh.set_wrapper(Some(env_test_wrapper()));
445
446        let cmd = sh
447            .wrap(print_env_cmd(&sh, "MY_FLOWEY_WRAP_TEST"))
448            .env("MY_FLOWEY_WRAP_TEST", "survived_wrapping");
449        let output = cmd.read().unwrap();
450        assert_eq!(output, "survived_wrapping");
451    }
452
453    #[test]
454    fn stdin_survives_wrapping() {
455        let sh = FloweyShell::new().unwrap();
456        let cmd = sh
457            .wrap(xshell::cmd!(sh.xshell(), "cat"))
458            .stdin("test input");
459        let output = cmd.read().unwrap();
460        assert_eq!(output, "test input");
461    }
462
463    #[test]
464    fn stdin_survives_with_wrapper() {
465        let mut sh = FloweyShell::new().unwrap();
466        sh.set_wrapper(Some(CommandWrapperKind::ShCmd));
467
468        let cmd = sh
469            .wrap(xshell::cmd!(sh.xshell(), "cat"))
470            .stdin("wrapped stdin test");
471        let output = cmd.read().unwrap();
472        assert_eq!(output, "wrapped stdin test");
473    }
474
475    #[test]
476    fn ignore_status_survives_wrapping() {
477        let mut sh = FloweyShell::new().unwrap();
478        sh.set_wrapper(Some(env_test_wrapper()));
479
480        // `false` exits with status 1 — without ignore_status this would error.
481        let cmd = sh.wrap(fail_cmd(&sh)).ignore_status();
482        assert!(cmd.run().is_ok());
483    }
484
485    #[test]
486    fn display_shows_unwrapped_command() {
487        let mut sh = FloweyShell::new().unwrap();
488        sh.set_wrapper(Some(CommandWrapperKind::Prefix));
489        let cmd = sh.wrap(xshell::cmd!(sh.xshell(), "my-program --flag value"));
490        assert_eq!(format!("{cmd}"), "my-program --flag value");
491    }
492
493    #[test]
494    fn nix_wrapper_display_without_path() {
495        let sh = FloweyShell::new().unwrap();
496        let cmd = CommandWrapperKind::NixShell { path: None }.wrap_cmd(
497            sh.xshell(),
498            xshell::cmd!(sh.xshell(), "cargo build --release"),
499        );
500        assert_eq!(
501            format!("{cmd}"),
502            "nix-shell --pure --run \"cargo build --release\""
503        );
504    }
505
506    #[test]
507    fn nix_wrapper_display_with_path() {
508        let sh = FloweyShell::new().unwrap();
509        let cmd = CommandWrapperKind::NixShell {
510            path: Some("/my/shell.nix".into()),
511        }
512        .wrap_cmd(sh.xshell(), xshell::cmd!(sh.xshell(), "cargo build"));
513        assert_eq!(
514            format!("{cmd}"),
515            "nix-shell /my/shell.nix --pure --run \"cargo build\""
516        );
517    }
518
519    #[test]
520    fn deref_exposes_shell_methods() {
521        let sh = FloweyShell::new().unwrap();
522        let _ = sh.current_dir();
523    }
524
525    #[test]
526    fn set_wrapper_clears_wrapper() {
527        let mut sh = FloweyShell::new().unwrap();
528        sh.set_wrapper(Some(CommandWrapperKind::Prefix));
529        sh.set_wrapper(None);
530        // With wrapper cleared, command should run directly.
531        let cmd = sh.wrap(xshell::cmd!(sh.xshell(), "echo direct"));
532        let output = cmd.read().unwrap();
533        assert_eq!(output, "direct");
534    }
535
536    #[test]
537    fn quiet_flag_survives_wrapping() {
538        let mut sh = FloweyShell::new().unwrap();
539        sh.set_wrapper(Some(CommandWrapperKind::Prefix));
540        // quiet() should not cause errors — just suppress echo to stderr.
541        let cmd = sh.wrap(xshell::cmd!(sh.xshell(), "echo test")).quiet();
542        let output = cmd.read().unwrap();
543        assert_eq!(output, "WRAPPED: echo test");
544    }
545
546    #[test]
547    fn args_accumulate_before_wrapping() {
548        let mut sh = FloweyShell::new().unwrap();
549        sh.set_wrapper(Some(CommandWrapperKind::Prefix));
550        let cmd = sh
551            .wrap(xshell::cmd!(sh.xshell(), "echo"))
552            .arg("one")
553            .arg("two");
554        let output = cmd.read().unwrap();
555        assert_eq!(output, "WRAPPED: echo one two");
556    }
557
558    #[test]
559    fn secret_display_is_redacted() {
560        let sh = FloweyShell::new().unwrap();
561        let cmd = sh
562            .wrap(xshell::cmd!(sh.xshell(), "curl --header secret-token"))
563            .secret();
564        assert_eq!(format!("{cmd}"), "<secret>");
565    }
566
567    #[test]
568    fn secret_display_redacted_with_wrapper() {
569        let mut sh = FloweyShell::new().unwrap();
570        sh.set_wrapper(Some(CommandWrapperKind::Prefix));
571        let cmd = sh
572            .wrap(xshell::cmd!(sh.xshell(), "curl --header secret-token"))
573            .secret();
574        assert_eq!(format!("{cmd}"), "<secret>");
575    }
576
577    #[test]
578    fn env_remove_survives_wrapping() {
579        let mut sh = FloweyShell::new().unwrap();
580        sh.set_wrapper(Some(env_test_wrapper()));
581
582        // Set a var via the shell, then remove it on the command.
583        // printenv should fail (exit 1) because the var is removed.
584        sh.set_var("FLOWEY_REMOVE_TEST", "present");
585        let cmd = sh
586            .wrap(print_env_cmd(&sh, "FLOWEY_REMOVE_TEST"))
587            .env_remove("FLOWEY_REMOVE_TEST")
588            .ignore_status();
589        let output = cmd.output().unwrap();
590        assert!(!output.status.success());
591    }
592
593    #[test]
594    fn env_clear_survives_wrapping() {
595        let mut sh = FloweyShell::new().unwrap();
596        sh.set_wrapper(Some(env_test_wrapper()));
597
598        // After env_clear, even PATH is gone. The wrapped command
599        // should still run (sh is resolved before env_clear applies),
600        // but the inner command won't find the var.
601        sh.set_var("FLOWEY_CLEAR_TEST", "present");
602        let cmd = sh
603            .wrap(print_env_cmd(&sh, "FLOWEY_CLEAR_TEST"))
604            .env_clear()
605            .ignore_status();
606        let output = cmd.output().unwrap();
607        assert!(!output.status.success());
608    }
609
610    #[test]
611    fn env_ordering_preserved_through_wrapping() {
612        let mut sh = FloweyShell::new().unwrap();
613        sh.set_wrapper(Some(env_test_wrapper()));
614
615        // Set, clear, then set again — only the final value should survive.
616        let cmd = sh
617            .wrap(print_env_cmd(&sh, "FLOWEY_ORDER_TEST"))
618            .env("FLOWEY_ORDER_TEST", "first")
619            .env_clear()
620            .env("FLOWEY_ORDER_TEST", "second");
621        let output = cmd.read().unwrap();
622        assert_eq!(output, "second");
623    }
624
625    #[test]
626    fn envs_plural_survives_wrapping() {
627        let mut sh = FloweyShell::new().unwrap();
628        sh.set_wrapper(Some(CommandWrapperKind::ShCmd));
629
630        let vars = vec![("FLOWEY_MULTI_A", "alpha"), ("FLOWEY_MULTI_B", "beta")];
631        // Print both vars separated by a space.
632        let cmd = sh
633            .wrap(xshell::cmd!(sh.xshell(), "sh"))
634            .arg("-c")
635            .arg("echo $FLOWEY_MULTI_A $FLOWEY_MULTI_B")
636            .envs(vars);
637        let output = cmd.read().unwrap();
638        assert_eq!(output, "alpha beta");
639    }
640}