embuild/
cmd.rs

1//! Command building and running utilities.
2
3use std::ffi::OsStr;
4use std::io;
5use std::process::{self, Command, ExitStatus};
6
7/// Error when trying to execute a command.
8#[derive(Debug, thiserror::Error)]
9pub enum CmdError {
10    /// The command failed to start.
11    #[error("command '{0}' failed to start")]
12    NoRun(String, #[source] io::Error),
13    /// The command exited unsucessfully (with non-zero exit status).
14    #[error("command '{0}' exited with non-zero status code {1}")]
15    Unsuccessful(String, i32, #[source] Option<anyhow::Error>),
16    /// The command was terminated unexpectedly.
17    #[error("command '{0}' was terminated unexpectedly")]
18    Terminated(String),
19}
20
21impl CmdError {
22    /// Create a [`CmdError::NoRun`].
23    pub fn no_run(cmd: &process::Command, error: io::Error) -> Self {
24        CmdError::NoRun(format!("{cmd:?}"), error)
25    }
26
27    /// Convert a [`process::ExitStatus`] into a `Result<(), CmdError>`.
28    pub fn status_into_result(
29        status: process::ExitStatus,
30        cmd: &process::Command,
31        cmd_output: impl FnOnce() -> Option<String>,
32    ) -> Result<(), Self> {
33        if status.success() {
34            Ok(())
35        } else if let Some(code) = status.code() {
36            Err(CmdError::Unsuccessful(
37                format!("{cmd:?}"),
38                code,
39                cmd_output().map(anyhow::Error::msg),
40            ))
41        } else {
42            Err(CmdError::Terminated(format!("{cmd:?}")))
43        }
44    }
45}
46
47/// A wrapper over a [`std::process::Command`] with more features.
48#[derive(Debug)]
49pub struct Cmd {
50    /// The actual [`std::process::Command`] wrapped.
51    pub cmd: std::process::Command,
52    ignore_exitcode: bool,
53}
54
55impl std::ops::Deref for Cmd {
56    type Target = std::process::Command;
57
58    fn deref(&self) -> &Self::Target {
59        &self.cmd
60    }
61}
62
63impl std::ops::DerefMut for Cmd {
64    fn deref_mut(&mut self) -> &mut Self::Target {
65        &mut self.cmd
66    }
67}
68
69impl From<std::process::Command> for Cmd {
70    fn from(cmd: std::process::Command) -> Self {
71        Cmd {
72            cmd,
73            ignore_exitcode: false,
74        }
75    }
76}
77
78impl From<Cmd> for std::process::Command {
79    fn from(cmd: Cmd) -> Self {
80        cmd.into_inner()
81    }
82}
83
84impl Cmd {
85    /// Construct a new [`Cmd`] for launching `program` (see
86    /// [`std::process::Command::new`]).
87    pub fn new(program: impl AsRef<OsStr>) -> Self {
88        Self {
89            cmd: Command::new(program),
90            ignore_exitcode: false,
91        }
92    }
93
94    /// Ignore the exit code when executing this command.
95    ///
96    /// Applies to:
97    /// - [`Cmd::run`]
98    /// - [`Cmd::output`]
99    /// - [`Cmd::stdout`]
100    /// - [`Cmd::stderr`]
101    pub fn ignore_exitcode(&mut self) -> &mut Self {
102        self.ignore_exitcode = true;
103        self
104    }
105
106    /// Run the command to completion.
107    ///
108    /// If [`Cmd::ignore_exitcode`] has been called a program that exited with an error
109    /// will also return [`Ok`], otherwise it will return [`Err`].
110    /// A program that failed to start will always return an [`Err`].
111    ///
112    /// [`std::process::Command::status`] is used internally.
113    pub fn run(&mut self) -> Result<(), CmdError> {
114        self.cmd
115            .status()
116            .map_err(|e| CmdError::no_run(&self.cmd, e))
117            .and_then(|v| {
118                if self.ignore_exitcode {
119                    Ok(())
120                } else {
121                    CmdError::status_into_result(v, &self.cmd, || None)
122                }
123            })
124    }
125
126    /// Run the command and get its [`ExitStatus`].
127    pub fn status(&mut self) -> Result<ExitStatus, CmdError> {
128        self.cmd
129            .status()
130            .map_err(|e| CmdError::no_run(&self.cmd, e))
131    }
132
133    fn print_output(&self, output: &std::process::Output) {
134        // TODO: add some way to quiet this output
135        use std::io::Write;
136        std::io::stdout().write_all(&output.stdout[..]).ok();
137        std::io::stderr().write_all(&output.stderr[..]).ok();
138    }
139
140    /// Run the command to completion and use its [`std::process::Output`] with `func`.
141    ///
142    /// If [`Cmd::ignore_exitcode`] has been called a program that exited with an error
143    /// will also return [`Ok`], otherwise it will return [`Err`].
144    /// A program that failed to start will always return an [`Err`].
145    ///
146    /// [`std::process::Command::output`] is used internally.
147    pub fn output<T>(
148        &mut self,
149        func: impl FnOnce(std::process::Output) -> T,
150    ) -> Result<T, CmdError> {
151        match self.cmd.output() {
152            Err(err) => Err(CmdError::no_run(&self.cmd, err)),
153            Ok(result) => if self.ignore_exitcode {
154                self.print_output(&result);
155                Ok(())
156            } else {
157                CmdError::status_into_result(result.status, &self.cmd, || {
158                    Some(
159                        String::from_utf8_lossy(&result.stderr[..])
160                            .trim_end()
161                            .to_string(),
162                    )
163                })
164            }
165            .map_err(|e| {
166                self.print_output(&result);
167                e
168            })
169            .map(|_| func(result)),
170        }
171    }
172
173    /// Run the command to completion and get its stdout output.
174    ///
175    /// See [`Cmd::output`].
176    pub fn stdout(&mut self) -> Result<String, CmdError> {
177        self.output(|output| {
178            String::from_utf8_lossy(&output.stdout[..])
179                .trim_end()
180                .to_string()
181        })
182    }
183
184    /// Run the command to completion and get its stderr output.
185    ///
186    /// See [`Cmd::output`].
187    pub fn stderr(&mut self) -> Result<String, CmdError> {
188        self.output(|output| {
189            String::from_utf8_lossy(&output.stderr[..])
190                .trim_end()
191                .to_string()
192        })
193    }
194
195    /// Turn this [`Cmd`] into its underlying [`std::process::Command`].
196    pub fn into_inner(self) -> std::process::Command {
197        self.cmd
198    }
199}
200
201/// Build a command using a given [`std::process::Command`] or [`Cmd`] and return it.
202///
203/// The first argument is expected to be a [`std::process::Command`] or [`Cmd`] instance.
204///
205/// For a `new` builder the second argument, the program to run (passed to
206/// [`std::process::Command::new`]) is mandatory. Every comma seperated argument
207/// thereafter is added to the command's arguments. Arguments after an `@`-sign specify
208/// collections of arguments (specifically `impl IntoIterator<Item = impl AsRef<OsStr>`).
209/// The opional `key=value` arguments after a semicolon are simply translated to calling
210/// the `std::process::Command::<key>` method with `value` as its arguments.
211///
212/// **Note:**
213/// `@`-arguments must be followed by at least one normal argument. For example
214///  `cmd_build!(new, "cmd", @args)` will not compile but `cmd_build!(new, "cmd", @args,
215/// "other")` will. You can use `key=value` arguments to work around this limitation:
216/// `cmd_build!(new, "cmd"; args=(args))`.
217///
218/// At the end the built [`std::process::Command`] is returned.
219///
220/// # Examples
221/// ```
222/// # use embuild::{cmd::Cmd, cmd_build};
223/// let args_list = ["--foo", "--bar", "value"];
224/// let mut cmd = Cmd::new("git");
225/// let mut cmd = cmd_build!(cmd, @args_list, "clone"; arg=("url.com"), env=("var", "value"));
226/// ```
227#[macro_export]
228macro_rules! cmd_build {
229    ($builder:ident $(, $(@$cmdargs:expr,)* $cmdarg:expr)* $(; $($k:ident = $v:tt),*)?) => {{
230        $(
231            $($builder .args($cmdargs);)*
232            $builder .arg($cmdarg);
233        )*
234        $($($builder . $k $v;)*)?
235
236        $builder
237    }}
238}
239
240/// Create a new [`Cmd`] instance.
241///
242/// This is a simple wrapper over the [`std::process::Command`] and [`Cmd`] API. It
243/// expects at least one argument for the program to run. Every comma seperated argument
244/// thereafter is added to the command's arguments. Arguments after an `@`-sign specify
245/// collections of arguments (specifically `impl IntoIterator<Item = impl AsRef<OsStr>`).
246/// The opional `key=value` arguments after a semicolon are simply translated to calling
247/// the `Cmd::<key>` method with `value` as its arguments.
248///
249/// **Note:**
250///  `@`-arguments must be followed by at least one normal argument. For example
251/// `cmd!("cmd", @args)` will not compile but `cmd!("cmd", @args, "other")` will. You can
252/// use `key=value` arguments to work around this limitation: `cmd!("cmd"; args=(args))`.
253///
254/// # Examples
255/// ```
256/// # use embuild::cmd;
257/// let args_list = ["--foo", "--bar", "value"];
258/// let mut cmd = cmd!("git", @args_list, "clone"; arg=("url.com"), env=("var", "value"));
259/// ```
260#[macro_export]
261macro_rules! cmd {
262    ($cmd:expr $(, $(@$cmdargs:expr,)* $cmdarg:expr)* $(; $($k:ident = $v:tt),*)?) => {{
263        let mut cmd = $crate::cmd::Cmd::new($cmd);
264        $crate::cmd_build!(cmd $(, $(@$cmdargs,)* $cmdarg)* $(; $($k = $v),* )?)
265    }};
266}