Skip to main content

pacsea/util/
command.rs

1//! Shared command execution abstraction.
2//!
3//! This module is the single home for synchronous, capture-only command
4//! execution in Pacsea. It provides the [`CommandRunner`] trait for
5//! dependency-injected execution, the production [`SystemCommandRunner`],
6//! the [`run_capture`] convenience wrapper, and the [`binary_available`]
7//! capability probe.
8//!
9//! Interactive, PTY-based, and terminal-spawning command paths (installs,
10//! privilege escalation, clipboard, browsers) intentionally do **not** go
11//! through this module.
12
13use std::fmt;
14
15/// What: Abstract command execution interface used for spawning helper
16/// binaries such as `pacman`.
17///
18/// Inputs:
19/// - `program`: Executable name to run (for example, `"pacman"`).
20/// - `args`: Slice of positional arguments passed to the executable.
21///
22/// Output:
23/// - `Ok(String)` containing UTF-8 stdout on success.
24/// - `Err(CommandError)` when the invocation fails or stdout is not valid UTF-8.
25///
26/// # Errors
27/// - Returns `Err(CommandError::Io)` when command spawning or execution fails
28/// - Returns `Err(CommandError::Utf8)` when stdout cannot be decoded as UTF-8
29/// - Returns `Err(CommandError::Failed)` when the command exits with a non-zero status
30///
31/// Details:
32/// - Implementations may stub command results to enable deterministic unit
33///   testing.
34/// - Production code relies on [`SystemCommandRunner`].
35pub trait CommandRunner {
36    /// # Errors
37    /// - Returns `Err(CommandError::Io)` when command spawning or execution fails
38    /// - Returns `Err(CommandError::Utf8)` when stdout cannot be decoded as UTF-8
39    /// - Returns `Err(CommandError::Failed)` when the command exits with a non-zero status
40    fn run(&self, program: &str, args: &[&str]) -> Result<String, CommandError>;
41}
42
43/// What: Real command runner backed by `std::process::Command`.
44///
45/// Inputs: Satisfies the [`CommandRunner`] trait without additional parameters.
46///
47/// Output:
48/// - Executes commands on the host system and captures stdout.
49///
50/// # Errors
51/// - Returns `Err(CommandError::Io)` when command spawning or execution fails
52/// - Returns `Err(CommandError::Utf8)` when stdout cannot be decoded as UTF-8
53/// - Returns `Err(CommandError::Failed)` when the command exits with a non-zero status
54///
55/// Details:
56/// - Errors from `std::process::Command::output` are surfaced as
57///   [`CommandError::Io`].
58/// - Emits `tracing::debug!` before spawning and on success, and
59///   `tracing::warn!` on spawn failure or non-zero exit, so callers get
60///   consistent diagnostics without duplicating log statements.
61#[derive(Default)]
62pub struct SystemCommandRunner;
63
64impl CommandRunner for SystemCommandRunner {
65    fn run(&self, program: &str, args: &[&str]) -> Result<String, CommandError> {
66        tracing::debug!(
67            command = program,
68            args = ?args,
69            arg_count = args.len(),
70            "executing command"
71        );
72
73        let output = match std::process::Command::new(program).args(args).output() {
74            Ok(output) => output,
75            Err(err) => {
76                tracing::warn!(
77                    command = program,
78                    args = ?args,
79                    error = %err,
80                    "failed to spawn command"
81                );
82                return Err(CommandError::Io(err));
83            }
84        };
85
86        let status_code = output.status.code();
87        let stdout_len = output.stdout.len();
88        let stderr_len = output.stderr.len();
89
90        if !output.status.success() {
91            tracing::warn!(
92                command = program,
93                args = ?args,
94                status = ?output.status,
95                status_code,
96                stdout_len,
97                stderr_len,
98                "command exited with non-zero status"
99            );
100            return Err(CommandError::Failed {
101                program: program.to_string(),
102                args: args.iter().map(ToString::to_string).collect(),
103                status: output.status,
104            });
105        }
106
107        tracing::debug!(
108            command = program,
109            args = ?args,
110            status = ?output.status,
111            status_code,
112            stdout_len,
113            stderr_len,
114            "command completed successfully"
115        );
116
117        Ok(String::from_utf8(output.stdout)?)
118    }
119}
120
121/// What: Error type capturing command spawning, execution, and decoding
122/// failures.
123///
124/// Inputs: Generated internally by helper routines.
125///
126/// Output: Implements `Display`/`Error` for ergonomic propagation.
127///
128/// Details:
129/// - Represents various failure modes when executing system commands.
130/// - Wraps I/O errors, UTF-8 conversion failures, parsing issues, and
131///   non-success exit statuses.
132#[derive(Debug)]
133pub enum CommandError {
134    /// I/O error occurred.
135    Io(std::io::Error),
136    /// UTF-8 decoding error occurred.
137    Utf8(std::string::FromUtf8Error),
138    /// Command execution failed.
139    Failed {
140        /// Program name that failed.
141        program: String,
142        /// Command arguments.
143        args: Vec<String>,
144        /// Exit status of the failed command.
145        status: std::process::ExitStatus,
146    },
147    /// Parse error when processing command output.
148    Parse {
149        /// Program name that produced invalid output.
150        program: String,
151        /// Field name that failed to parse.
152        field: String,
153    },
154}
155
156impl fmt::Display for CommandError {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        match self {
159            Self::Io(err) => write!(f, "I/O error: {err}"),
160            Self::Utf8(err) => write!(f, "UTF-8 decoding error: {err}"),
161            Self::Failed {
162                program,
163                args,
164                status,
165            } => {
166                write!(f, "{program:?} {args:?} exited with status {status}")
167            }
168            Self::Parse { program, field } => {
169                write!(
170                    f,
171                    "{program} output did not contain expected field \"{field}\""
172                )
173            }
174        }
175    }
176}
177
178impl std::error::Error for CommandError {
179    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
180        match self {
181            Self::Io(err) => Some(err),
182            Self::Utf8(err) => Some(err),
183            Self::Failed { .. } | Self::Parse { .. } => None,
184        }
185    }
186}
187
188impl From<std::io::Error> for CommandError {
189    fn from(value: std::io::Error) -> Self {
190        Self::Io(value)
191    }
192}
193
194impl From<std::string::FromUtf8Error> for CommandError {
195    fn from(value: std::string::FromUtf8Error) -> Self {
196        Self::Utf8(value)
197    }
198}
199
200/// What: Run a command via [`SystemCommandRunner`] and capture stdout as UTF-8.
201///
202/// Inputs:
203/// - `program`: Executable name to run (for example, `"pacman"`).
204/// - `args`: Slice of positional arguments passed to the executable.
205///
206/// Output:
207/// - `Ok(String)` containing UTF-8 stdout on success.
208/// - `Err(CommandError)` when spawning fails, the command exits non-zero, or
209///   stdout is not valid UTF-8.
210///
211/// # Errors
212/// - Returns `Err(CommandError::Io)` when command spawning or execution fails
213/// - Returns `Err(CommandError::Utf8)` when stdout cannot be decoded as UTF-8
214/// - Returns `Err(CommandError::Failed)` when the command exits with a non-zero status
215///
216/// Details:
217/// - Thin convenience wrapper around [`SystemCommandRunner::run`]; use the
218///   trait directly when dependency injection is needed for testing.
219pub fn run_capture(program: &str, args: &[&str]) -> Result<String, CommandError> {
220    SystemCommandRunner.run(program, args)
221}
222
223/// What: Check whether an external binary is available on the system.
224///
225/// Inputs:
226/// - `name`: Binary name to probe (for example, `"paru"` or `"fakeroot"`).
227///
228/// Output:
229/// - `true` when spawning `<name> --version` succeeds, `false` otherwise.
230///
231/// Details:
232/// - Runs `<name> --version` with stdin, stdout, and stderr attached to the
233///   null device, so nothing leaks to the terminal.
234/// - Only spawn success is checked (`output().is_ok()`); the exit status is
235///   intentionally ignored, matching the historical capability probes.
236#[must_use]
237pub fn binary_available(name: &str) -> bool {
238    use std::process::{Command, Stdio};
239
240    Command::new(name)
241        .args(["--version"])
242        .stdin(Stdio::null())
243        .stdout(Stdio::null())
244        .stderr(Stdio::null())
245        .output()
246        .is_ok()
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    /// What: Verify `run_capture` returns stdout for a successful command.
254    ///
255    /// Inputs:
256    /// - `echo hi` executed on Unix hosts.
257    ///
258    /// Output:
259    /// - Captured stdout equals `"hi\n"`.
260    ///
261    /// Details:
262    /// - Guards the success path of [`SystemCommandRunner::run`].
263    #[cfg(unix)]
264    #[test]
265    fn run_capture_returns_stdout_on_success() {
266        let out = run_capture("echo", &["hi"]).expect("echo should succeed");
267        assert_eq!(out, "hi\n");
268    }
269
270    /// What: Verify a non-zero exit maps to `CommandError::Failed`.
271    ///
272    /// Inputs:
273    /// - `false` executed on Unix hosts (always exits with status 1).
274    ///
275    /// Output:
276    /// - `Err(CommandError::Failed)` carrying the program name.
277    ///
278    /// Details:
279    /// - Guards the exit-status branch of [`SystemCommandRunner::run`].
280    #[cfg(unix)]
281    #[test]
282    fn run_capture_maps_nonzero_exit_to_failed() {
283        let err = run_capture("false", &[]).expect_err("false should fail");
284        match err {
285            CommandError::Failed { program, .. } => assert_eq!(program, "false"),
286            other => panic!("expected CommandError::Failed, got {other:?}"),
287        }
288    }
289
290    /// What: Verify a missing binary maps to `CommandError::Io`.
291    ///
292    /// Inputs:
293    /// - A program name that cannot exist in `PATH`.
294    ///
295    /// Output:
296    /// - `Err(CommandError::Io)`.
297    ///
298    /// Details:
299    /// - Guards the spawn-failure branch of [`SystemCommandRunner::run`].
300    #[test]
301    fn run_capture_maps_missing_binary_to_io() {
302        let err = run_capture("pacsea-definitely-not-a-real-binary-xyz", &[])
303            .expect_err("missing binary should fail");
304        assert!(matches!(err, CommandError::Io(_)));
305    }
306
307    /// What: Verify `binary_available` returns `true` for an existing binary.
308    ///
309    /// Inputs:
310    /// - `sh`, which is present on all Unix hosts.
311    ///
312    /// Output:
313    /// - `binary_available("sh")` is `true`.
314    ///
315    /// Details:
316    /// - Guards the positive probe path.
317    #[cfg(unix)]
318    #[test]
319    fn binary_available_true_for_sh() {
320        assert!(binary_available("sh"));
321    }
322
323    /// What: Verify `binary_available` returns `false` for a missing binary.
324    ///
325    /// Inputs:
326    /// - A nonsense binary name that cannot exist in `PATH`.
327    ///
328    /// Output:
329    /// - `binary_available(...)` is `false`.
330    ///
331    /// Details:
332    /// - Guards the negative probe path.
333    #[test]
334    fn binary_available_false_for_missing_binary() {
335        assert!(!binary_available("pacsea-definitely-not-a-real-binary-xyz"));
336    }
337}