Skip to main content

krypt_pkg/
manager.rs

1//! Core `PackageManager` and `Runner` traits plus production/test impls.
2
3use std::collections::HashMap;
4use std::process::Command;
5use std::sync::Mutex;
6
7use thiserror::Error;
8
9// ─── PackageError ─────────────────────────────────────────────────────────────
10
11/// Errors produced by package manager operations.
12#[derive(Debug, Error)]
13pub enum PackageError {
14    /// A process could not be spawned or its output could not be read.
15    #[error("io error running package manager: {0}")]
16    Io(#[from] std::io::Error),
17
18    /// The manager binary is not available on PATH.
19    #[error("package manager not available on PATH")]
20    NotAvailable,
21
22    /// The manager exited with a non-zero status code.
23    #[error("package manager exited with status {status}: {stderr}")]
24    ExitFailure {
25        /// Exit code returned by the process.
26        status: i32,
27        /// Captured stderr from the process.
28        stderr: String,
29    },
30}
31
32// ─── RunOutcome ───────────────────────────────────────────────────────────────
33
34/// Result of a single process invocation.
35pub struct RunOutcome {
36    /// Process exit code.
37    pub status: i32,
38    /// Captured standard output.
39    pub stdout: String,
40    /// Captured standard error.
41    pub stderr: String,
42}
43
44// ─── Runner ───────────────────────────────────────────────────────────────────
45
46/// Abstraction over process execution so tests can verify behaviour without
47/// invoking real system commands.
48pub trait Runner: Send + Sync {
49    /// Run `cmd` with `args`. Returns a `RunOutcome` on success, or an I/O
50    /// error if the process could not be spawned at all.
51    fn run(&self, cmd: &str, args: &[&str]) -> Result<RunOutcome, std::io::Error>;
52}
53
54// ─── RealRunner ───────────────────────────────────────────────────────────────
55
56/// Production runner — spawns a child process via [`Command`].
57///
58/// stdout and stderr are captured (not inherited) and returned in
59/// [`RunOutcome`] so callers can include them in reports.
60pub struct RealRunner;
61
62impl Runner for RealRunner {
63    fn run(&self, cmd: &str, args: &[&str]) -> Result<RunOutcome, std::io::Error> {
64        let out = Command::new(cmd).args(args).output()?;
65        Ok(RunOutcome {
66            status: out.status.code().unwrap_or(-1),
67            stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
68            stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
69        })
70    }
71}
72
73// ─── MockRunner ───────────────────────────────────────────────────────────────
74
75/// Key used to look up scripted responses in the mock runner.
76type CallKey = (String, Vec<String>);
77
78/// Scripted response for one call.
79#[derive(Clone)]
80pub struct MockResponse {
81    /// Exit code to return.
82    pub status: i32,
83    /// Content to return as stdout.
84    pub stdout: String,
85    /// Content to return as stderr.
86    pub stderr: String,
87}
88
89impl MockResponse {
90    /// Convenience: exit 0, empty output.
91    pub fn success() -> Self {
92        Self {
93            status: 0,
94            stdout: String::new(),
95            stderr: String::new(),
96        }
97    }
98
99    /// Convenience: exit 1, empty output.
100    pub fn failure() -> Self {
101        Self {
102            status: 1,
103            stdout: String::new(),
104            stderr: String::new(),
105        }
106    }
107}
108
109/// Test runner that records every call and returns scripted responses.
110///
111/// Calls not registered with [`MockRunner::register`] return exit code 0 with
112/// empty output.
113pub struct MockRunner {
114    responses: HashMap<CallKey, MockResponse>,
115    calls: Mutex<Vec<(String, Vec<String>)>>,
116}
117
118impl MockRunner {
119    /// Create a new empty mock runner (all calls succeed by default).
120    pub fn new() -> Self {
121        Self {
122            responses: HashMap::new(),
123            calls: Mutex::new(Vec::new()),
124        }
125    }
126
127    /// Register a scripted response. `cmd` and `args` must match exactly.
128    #[must_use]
129    pub fn with(mut self, cmd: &str, args: &[&str], resp: MockResponse) -> Self {
130        let key = (cmd.to_owned(), args.iter().map(|s| s.to_string()).collect());
131        self.responses.insert(key, resp);
132        self
133    }
134
135    /// Return a snapshot of all calls made so far.
136    pub fn calls(&self) -> Vec<(String, Vec<String>)> {
137        self.calls.lock().unwrap().clone()
138    }
139}
140
141impl Default for MockRunner {
142    fn default() -> Self {
143        Self::new()
144    }
145}
146
147impl Runner for MockRunner {
148    fn run(&self, cmd: &str, args: &[&str]) -> Result<RunOutcome, std::io::Error> {
149        let key: CallKey = (cmd.to_owned(), args.iter().map(|s| s.to_string()).collect());
150        self.calls.lock().unwrap().push(key.clone());
151        let resp = self
152            .responses
153            .get(&key)
154            .cloned()
155            .unwrap_or(MockResponse::success());
156        Ok(RunOutcome {
157            status: resp.status,
158            stdout: resp.stdout,
159            stderr: resp.stderr,
160        })
161    }
162}
163
164// ─── PackageManager ───────────────────────────────────────────────────────────
165
166/// Abstraction over a system package manager.
167pub trait PackageManager: Send + Sync {
168    /// Stable lowercase identifier (e.g. `"pacman"`, `"apt"`).
169    ///
170    /// This matches the field name in `DepsGroup` in the config schema.
171    fn name(&self) -> &'static str;
172
173    /// Returns `true` when the manager's binary is on `PATH`.
174    fn is_available(&self) -> bool;
175
176    /// Returns `true` when `pkg` is already installed.
177    ///
178    /// Only errors on unexpected conditions — a clean "not installed" (exit 1
179    /// from a query command) is returned as `Ok(false)`.
180    fn is_installed(&self, runner: &dyn Runner, pkg: &str) -> Result<bool, PackageError>;
181
182    /// Install the given packages.
183    ///
184    /// Implementations may batch packages into a single invocation or loop one
185    /// at a time (winget).
186    fn install(&self, runner: &dyn Runner, packages: &[String]) -> Result<(), PackageError>;
187}