Skip to main content

pkgmgr_info/
lib.rs

1#![forbid(unsafe_code)]
2//! Detect the system package manager and report installed package counts.
3use 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/// Supported Linux package managers.
19#[derive(Copy, Clone, Debug, Eq, PartialEq)]
20pub enum PackageManager {
21    /// Alpine `apk`.
22    Apk,
23    /// Debian/Ubuntu `apt`.
24    Apt,
25    /// Fedora/RHEL `dnf`.
26    Dnf,
27    /// Arch `pacman`.
28    Pacman,
29    /// Gentoo `portage`.
30    Portage,
31    /// openSUSE `zypper`.
32    Zypper,
33}
34
35impl PackageManager {
36    /// Detects the package manager by reading `/etc/os-release`.
37    ///
38    /// # Errors
39    ///
40    /// * `InvalidData` if the `/etc/os-release` does not contain an `ID=` and `ID_LIKE` entry.
41    /// * `InvalidInput` if the discovered ID is not in the supported list.
42    /// * Other `io::Error` variants propagated from `fs::read_to_string`.
43    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    /// Returns the package manager command name.
49    #[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    /// Returns the installed package count for the manager.
62    ///
63    /// # Errors
64    ///
65    /// Returns an error if the command fails, output is invalid UTF-8,
66    /// output is empty, or the count cannot be parsed as an integer.
67    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}