1#![forbid(unsafe_code)]
2use std::fs;
4use std::io::{self, Error, ErrorKind};
5use std::process::{Command, Output};
6
7const LINUX_DISTROS: [(&str, PackageManager); 8] = [
8 ("alpine", PackageManager::Apk),
9 ("ubuntu", PackageManager::Apt),
10 ("debian", PackageManager::Apt),
11 ("fedora", PackageManager::Dnf),
12 ("rhel", PackageManager::Dnf),
13 ("arch", PackageManager::Pacman),
14 ("gentoo", PackageManager::Portage),
15 ("opensuse", PackageManager::Zypper),
16];
17
18#[derive(Copy, Clone, Debug, Eq, PartialEq)]
20pub enum PackageManager {
21 Apk,
23 Apt,
25 Dnf,
27 Pacman,
29 Portage,
31 Zypper,
33}
34
35impl PackageManager {
36 pub fn detect() -> io::Result<Self> {
44 let os_release = fs::read_to_string("/etc/os-release")?;
45 detect_from_os_release(&os_release)
46 }
47
48 #[must_use]
50 pub const fn name(&self) -> &'static str {
51 match self {
52 Self::Apk => "apk",
53 Self::Apt => "apt",
54 Self::Dnf => "dnf",
55 Self::Pacman => "pacman",
56 Self::Portage => "portage",
57 Self::Zypper => "zypper",
58 }
59 }
60
61 pub fn package_count(&self) -> io::Result<u64> {
68 self.package_count_with(run_count)
69 }
70
71 fn package_count_with(self, run: fn(&str) -> io::Result<u64>) -> io::Result<u64> {
72 #[allow(clippy::literal_string_with_formatting_args)]
73 match self {
74 Self::Apk => run("apk info | wc -l"),
75 Self::Apt => run("dpkg-query -f '${binary:Package}\\n' -W | wc -l"),
76 Self::Dnf | Self::Zypper => run("rpm -qa | wc -l"),
77 Self::Pacman => run("pacman -Q | wc -l"),
78 Self::Portage => run("qlist -I | wc -l"),
79 }
80 }
81}
82
83fn detect_from_os_release(os_release: &str) -> io::Result<PackageManager> {
84 let id = read_key(os_release, "ID");
85 let id_like = read_key(os_release, "ID_LIKE");
86 if id.is_none() && id_like.is_none() {
87 return Err(Error::new(ErrorKind::InvalidData, "missing ID and ID_LIKE"));
88 }
89
90 if let Some(distro) = id
91 && let Some(manager) = lookup(distro)
92 {
93 return Ok(manager);
94 }
95
96 if let Some(distros) = id_like {
97 for distro in distros.split_ascii_whitespace() {
98 if let Some(manager) = lookup(distro) {
99 return Ok(manager);
100 }
101 }
102 }
103
104 Err(Error::new(ErrorKind::InvalidInput, "unknown pkg manager"))
105}
106
107fn lookup(id: &str) -> Option<PackageManager> {
108 LINUX_DISTROS
109 .iter()
110 .find(|(distro, _)| *distro == id)
111 .map(|(_, manager)| *manager)
112}
113
114fn read_key<'a>(os: &'a str, prefix: &str) -> Option<&'a str> {
115 os.lines()
116 .filter_map(|line| line.trim_start().split_once('='))
117 .find(|(key, _)| *key == prefix)
118 .map(|(_, val)| val.trim_matches('"'))
119}
120
121fn run_cmd(cmd: &str) -> io::Result<Output> {
122 Command::new("sh").arg("-c").arg(cmd).output()
123}
124
125fn run_count(cmd: &str) -> io::Result<u64> {
126 run_count_with(cmd, run_cmd)
127}
128
129fn run_count_with(cmd: &str, run: fn(&str) -> io::Result<Output>) -> io::Result<u64> {
130 let output = run(cmd)?;
131 if !output.status.success() {
132 return Err(Error::other("command failed"));
133 }
134
135 let text = std::str::from_utf8(&output.stdout)
136 .map_err(|_| Error::new(ErrorKind::InvalidData, "non-utf8 output"))?;
137 parse_count(text)
138}
139
140fn parse_count(text: &str) -> io::Result<u64> {
141 let trimmed = text.trim();
142 if trimmed.is_empty() {
143 return Err(Error::new(ErrorKind::InvalidData, "empty output"));
144 }
145
146 trimmed
147 .parse::<u64>()
148 .map_err(|_| Error::new(ErrorKind::InvalidData, "invalid count"))
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use std::os::unix::process::ExitStatusExt;
155 use std::process::ExitStatus;
156
157 #[test]
158 fn supported_distro_count_matches_expected() {
159 assert_eq!(LINUX_DISTROS.len(), 8);
160 }
161
162 #[test]
163 fn detects_pacakge_managers_from_id() {
164 let cases = [
165 ("debian", "apt"),
166 ("fedora", "dnf"),
167 ("arch", "pacman"),
168 ("alpine", "apk"),
169 ("gentoo", "portage"),
170 ];
171
172 for (id, expected) in cases {
173 let sample = format!("NAME=Foo\nID={id}\n");
174 let pm = detect_from_os_release(&sample).expect("should match");
175 assert_eq!(pm.name(), expected);
176 }
177 }
178
179 #[test]
180 fn detects_pacakge_managers_from_id_like() {
181 let cases = [
182 ("almalinux", "rhel centos fedora", "dnf"),
183 ("linuxmint", "ubuntu", "apt"),
184 ("manjaro", "arch", "pacman"),
185 ("opensuse-tumbleweed", "opensuse suse", "zypper"),
186 ];
187
188 for (id, id_like, expected) in cases {
189 let sample = format!("NAME=Foo\nID={id}\nID_LIKE={id_like}\n");
190 let pm = detect_from_os_release(&sample).expect("should match");
191 assert_eq!(pm.name(), expected);
192 }
193 }
194
195 #[test]
196 fn prefers_id_over_id_like() {
197 let sample = "NAME=Foo\nID=ubuntu\nID_LIKE=debian\n";
198 let pm = detect_from_os_release(sample).expect("should match");
199 assert_eq!(pm.name(), "apt");
200 }
201
202 #[test]
203 fn rejects_missing_id_and_id_like() {
204 let sample = "NAME=Foo\n";
205 let err = detect_from_os_release(sample).unwrap_err();
206 assert_eq!(err.kind(), ErrorKind::InvalidData);
207 }
208
209 #[test]
210 fn rejects_unknown_id_like() {
211 let sample = "ID_LIKE=unknown";
212 let err = detect_from_os_release(sample).unwrap_err();
213 assert_eq!(err.kind(), ErrorKind::InvalidInput);
214 }
215
216 #[test]
217 fn rejects_unknown_id() {
218 let sample = "ID=unknown\n";
219 let err = detect_from_os_release(sample).unwrap_err();
220 assert_eq!(err.kind(), ErrorKind::InvalidInput);
221 }
222
223 fn fake_run(cmd: &str) -> io::Result<u64> {
224 match cmd {
225 "apk info | wc -l" => Ok(10),
226 "dpkg-query -f '${binary:Package}\\n' -W | wc -l" => Ok(20),
227 "rpm -qa | wc -l" => Ok(30),
228 "pacman -Q | wc -l" => Ok(40),
229 "qlist -I | wc -l" => Ok(50),
230 _ => Err(Error::new(ErrorKind::InvalidInput, "unknown cmd")),
231 }
232 }
233
234 #[test]
235 fn package_count_uses_expected_commands() {
236 let cases = [
237 (PackageManager::Apk, 10),
238 (PackageManager::Apt, 20),
239 (PackageManager::Dnf, 30),
240 (PackageManager::Pacman, 40),
241 (PackageManager::Portage, 50),
242 (PackageManager::Zypper, 30),
243 ];
244
245 for (pm, expected) in cases {
246 let count = pm.package_count_with(fake_run).expect("count ok");
247 assert_eq!(count, expected);
248 }
249 }
250
251 #[test]
252 fn fake_run_rejects_unknown_command() {
253 let err = fake_run("nope").unwrap_err();
254 assert_eq!(err.kind(), ErrorKind::InvalidInput);
255 }
256
257 #[allow(clippy::unnecessary_wraps)]
258 fn fake_output_ok(_cmd: &str) -> io::Result<Output> {
259 Ok(Output {
260 status: ExitStatus::from_raw(0),
261 stdout: b"42\n".to_vec(),
262 stderr: Vec::new(),
263 })
264 }
265
266 #[allow(clippy::unnecessary_wraps)]
267 fn fake_output_bad(_cmd: &str) -> io::Result<Output> {
268 Ok(Output {
269 status: ExitStatus::from_raw(1),
270 stdout: Vec::new(),
271 stderr: Vec::new(),
272 })
273 }
274
275 fn fake_output_err(_cmd: &str) -> io::Result<Output> {
276 Err(Error::new(ErrorKind::NotFound, "missing cmd"))
277 }
278
279 #[allow(clippy::unnecessary_wraps)]
280 fn fake_output_non_utf8(_cmd: &str) -> io::Result<Output> {
281 Ok(Output {
282 status: ExitStatus::from_raw(0),
283 stdout: vec![0xff, 0xfe, 0xfd],
284 stderr: Vec::new(),
285 })
286 }
287
288 #[test]
289 fn run_count_with_parses_stdout() {
290 let count = run_count_with("ignored", fake_output_ok).expect("count ok");
291 assert_eq!(count, 42);
292 }
293
294 #[test]
295 fn run_count_with_fails_on_status() {
296 let err = run_count_with("ignored", fake_output_bad).unwrap_err();
297 assert_eq!(err.kind(), ErrorKind::Other);
298 }
299
300 #[test]
301 fn run_count_with_rejects_non_utf8() {
302 let err = run_count_with("ignored", fake_output_non_utf8).unwrap_err();
303 assert_eq!(err.kind(), ErrorKind::InvalidData);
304 }
305
306 #[test]
307 fn run_count_with_propagates_runner_error() {
308 let err = run_count_with("ignored", fake_output_err).unwrap_err();
309 assert_eq!(err.kind(), ErrorKind::NotFound);
310 }
311
312 #[test]
313 fn run_count_reports_missing_command_failure() {
314 let err = run_count("cmd-that-should-not-exist").unwrap_err();
315 assert_eq!(err.kind(), ErrorKind::Other);
316 }
317
318 #[test]
319 fn parse_count_rejects_empty() {
320 let err = parse_count(" ").unwrap_err();
321 assert_eq!(err.kind(), ErrorKind::InvalidData);
322 }
323
324 #[test]
325 fn parse_count_rejects_invalid() {
326 let err = parse_count("nope").unwrap_err();
327 assert_eq!(err.kind(), ErrorKind::InvalidData);
328 }
329
330 #[test]
331 fn parse_count_accepts_valid() {
332 let count = parse_count(" 123 ").expect("count ok");
333 assert_eq!(count, 123);
334 }
335}