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}