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