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}