pacsea/logic/preflight/
command.rs

1//! Command execution abstraction for preflight operations.
2//!
3//! This module provides the [`CommandRunner`] trait and implementations for
4//! executing system commands, enabling testability through dependency injection.
5
6use std::fmt;
7
8/// What: Abstract command execution interface used for spawning helper
9/// binaries such as `pacman`.
10///
11/// Inputs:
12/// - `program`: Executable name to run (for example, `"pacman"`).
13/// - `args`: Slice of positional arguments passed to the executable.
14///
15/// Output:
16/// - `Ok(String)` containing UTF-8 stdout on success.
17/// - `Err(CommandError)` when the invocation fails or stdout is not valid UTF-8.
18///
19/// # Errors
20/// - Returns `Err(CommandError::Io)` when command spawning or execution fails
21/// - Returns `Err(CommandError::Utf8)` when stdout cannot be decoded as UTF-8
22/// - Returns `Err(CommandError::Failed)` when the command exits with a non-zero status
23///
24/// Details:
25/// - Implementations may stub command results to enable deterministic unit
26///   testing.
27/// - Production code relies on [`SystemCommandRunner`].
28pub trait CommandRunner {
29    /// # Errors
30    /// - Returns `Err(CommandError::Io)` when command spawning or execution fails
31    /// - Returns `Err(CommandError::Utf8)` when stdout cannot be decoded as UTF-8
32    /// - Returns `Err(CommandError::Failed)` when the command exits with a non-zero status
33    fn run(&self, program: &str, args: &[&str]) -> Result<String, CommandError>;
34}
35
36/// What: Real command runner backed by `std::process::Command`.
37///
38/// Inputs: Satisfies the [`CommandRunner`] trait without additional parameters.
39///
40/// Output:
41/// - Executes commands on the host system and captures stdout.
42///
43/// # Errors
44/// - Returns `Err(CommandError::Io)` when command spawning or execution fails
45/// - Returns `Err(CommandError::Utf8)` when stdout cannot be decoded as UTF-8
46/// - Returns `Err(CommandError::Failed)` when the command exits with a non-zero status
47///
48/// Details:
49/// - Errors from `std::process::Command::output` are surfaced as
50///   [`CommandError::Io`].
51#[derive(Default)]
52pub struct SystemCommandRunner;
53
54impl CommandRunner for SystemCommandRunner {
55    fn run(&self, program: &str, args: &[&str]) -> Result<String, CommandError> {
56        let output = std::process::Command::new(program).args(args).output()?;
57        if !output.status.success() {
58            return Err(CommandError::Failed {
59                program: program.to_string(),
60                args: args.iter().map(ToString::to_string).collect(),
61                status: output.status,
62            });
63        }
64        Ok(String::from_utf8(output.stdout)?)
65    }
66}
67
68/// What: Error type capturing command spawning, execution, and decoding
69/// failures.
70///
71/// Inputs: Generated internally by helper routines.
72///
73/// Output: Implements `Display`/`Error` for ergonomic propagation.
74///
75/// Details:
76/// - Represents various failure modes when executing system commands.
77/// - Wraps I/O errors, UTF-8 conversion failures, parsing issues, and
78///   non-success exit statuses.
79#[derive(Debug)]
80pub enum CommandError {
81    /// I/O error occurred.
82    Io(std::io::Error),
83    /// UTF-8 decoding error occurred.
84    Utf8(std::string::FromUtf8Error),
85    /// Command execution failed.
86    Failed {
87        /// Program name that failed.
88        program: String,
89        /// Command arguments.
90        args: Vec<String>,
91        /// Exit status of the failed command.
92        status: std::process::ExitStatus,
93    },
94    /// Parse error when processing command output.
95    Parse {
96        /// Program name that produced invalid output.
97        program: String,
98        /// Field name that failed to parse.
99        field: String,
100    },
101}
102
103impl fmt::Display for CommandError {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        match self {
106            Self::Io(err) => write!(f, "I/O error: {err}"),
107            Self::Utf8(err) => write!(f, "UTF-8 decoding error: {err}"),
108            Self::Failed {
109                program,
110                args,
111                status,
112            } => {
113                write!(f, "{program:?} {args:?} exited with status {status}")
114            }
115            Self::Parse { program, field } => {
116                write!(
117                    f,
118                    "{program} output did not contain expected field \"{field}\""
119                )
120            }
121        }
122    }
123}
124
125impl std::error::Error for CommandError {
126    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
127        match self {
128            Self::Io(err) => Some(err),
129            Self::Utf8(err) => Some(err),
130            Self::Failed { .. } | Self::Parse { .. } => None,
131        }
132    }
133}
134
135impl From<std::io::Error> for CommandError {
136    fn from(value: std::io::Error) -> Self {
137        Self::Io(value)
138    }
139}
140
141impl From<std::string::FromUtf8Error> for CommandError {
142    fn from(value: std::string::FromUtf8Error) -> Self {
143        Self::Utf8(value)
144    }
145}