use super::{Backend, capture};
use crate::model::{Origin, Package, Source, World};
use std::collections::{HashMap, HashSet};
pub struct Dnf;
impl Backend for Dnf {
fn name(&self) -> &'static str {
"dnf"
}
fn build_world(&self) -> Result<World, String> {
let meta = capture(
"rpm",
&[
"-qa",
"--qf",
"%{NAME}\t%{VERSION}-%{RELEASE}\t%{SIZE}\t%{INSTALLTIME}\t%{SUMMARY}\n",
],
)?;
let provides = capture("rpm", &["-qa", "--qf", "%{NAME}\t[%{PROVIDENAME} ]\n"])?;
let requires = capture("rpm", &["-qa", "--qf", "%{NAME}\t[%{REQUIRENAME} ]\n"])?;
let files = capture("rpm", &["-qa", "--qf", "%{NAME}\t[%{FILENAMES} ]\n"])?;
let userinstalled = capture(
"dnf",
&[
"repoquery",
"--userinstalled",
"--qf",
"%{name}\n",
"--cacheonly",
],
)
.unwrap_or_default();
let mut world = build(&meta, &provides, &requires, &files, &userinstalled);
if let Ok(up) = capture(
"dnf",
&[
"repoquery",
"--upgrades",
"--qf",
"%{name}\t%{evr}\n",
"--cacheonly",
],
) {
apply_upgradable(&mut world, &up);
}
if let Ok(repos) = capture(
"dnf",
&[
"repoquery",
"--installed",
"--qf",
"%{name}\t%{from_repo}\n",
"--cacheonly",
],
) {
apply_origins(&mut world, &repos);
}
Ok(world)
}
}
pub fn build(
meta: &str,
provides: &str,
requires: &str,
files: &str,
userinstalled: &str,
) -> World {
let manual_set: HashSet<String> = userinstalled
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(String::from)
.collect();
let (mut packages, install_log) = parse_meta(meta);
for (name, pkg) in packages.iter_mut() {
pkg.manual = manual_set.contains(name);
}
let provider = provider_map(provides, files);
let (deps, rdeps) = resolve_graph(requires, &provider);
let manual = packages
.values()
.filter(|p| p.manual)
.map(|p| p.name.clone())
.collect();
World {
packages,
deps,
rdeps,
manual,
install_log,
}
}
pub fn parse_meta(meta: &str) -> (HashMap<String, Package>, Vec<(i64, String)>) {
let mut packages = HashMap::new();
let mut install_log: Vec<(i64, String)> = Vec::new();
for line in meta.lines() {
let f: Vec<&str> = line.splitn(5, '\t').collect();
if f.len() < 5 || f[0].is_empty() {
continue;
}
let name = f[0].to_string();
let size_kb = f[2].trim().parse::<u64>().unwrap_or(0) / 1024;
let epoch = f[3].trim().parse::<i64>().ok();
let (install_epoch, install_date) = match epoch {
Some(e) if e > 0 => {
let date = chrono::DateTime::from_timestamp(e, 0)
.map(|dt| dt.format("%Y-%m-%d").to_string());
install_log.push((e, name.clone()));
(Some(e), date)
}
_ => (None, None),
};
packages.insert(
name.clone(),
Package {
name,
version: f[1].to_string(),
candidate: None,
installed_size: size_kb,
description: f[4].to_string(),
details: None,
manual: false,
source: Source::System,
remote: None,
origin: Origin::Unknown,
install_epoch,
install_date,
},
);
}
install_log.sort_by_key(|(e, _)| *e);
(packages, install_log)
}
fn provider_map(provides: &str, files: &str) -> HashMap<String, String> {
let mut map: HashMap<String, String> = HashMap::new();
for (pkg, caps) in name_caps(provides).into_iter().chain(name_caps(files)) {
for cap in caps {
map.entry(cap).or_insert_with(|| pkg.clone());
}
}
map
}
fn resolve_graph(
requires: &str,
provider: &HashMap<String, String>,
) -> (HashMap<String, Vec<String>>, HashMap<String, Vec<String>>) {
let mut deps: HashMap<String, Vec<String>> = HashMap::new();
let mut rdeps: HashMap<String, Vec<String>> = HashMap::new();
for (pkg, reqs) in name_caps(requires) {
let mut seen = HashSet::new();
for req in reqs {
if req.starts_with("rpmlib(") || req.starts_with("rtld(") {
continue;
}
if let Some(prov) = provider.get(&req)
&& prov != &pkg
&& seen.insert(prov.clone())
{
deps.entry(pkg.clone()).or_default().push(prov.clone());
rdeps.entry(prov.clone()).or_default().push(pkg.clone());
}
}
}
(deps, rdeps)
}
pub fn apply_upgradable(world: &mut World, up: &str) {
for line in up.lines() {
let (name, evr) = match line.split_once('\t') {
Some(pair) => pair,
None => continue,
};
if let Some(pkg) = world.packages.get_mut(name.trim()) {
let evr = evr.trim();
if !evr.is_empty() {
pkg.candidate = Some(evr.to_string());
}
}
}
}
pub fn apply_origins(world: &mut World, text: &str) {
for line in text.lines() {
let (name, from_repo) = match line.split_once('\t') {
Some(pair) => pair,
None => continue,
};
if let Some(pkg) = world.packages.get_mut(name.trim()) {
pkg.origin = classify_repo(from_repo.trim());
}
}
}
fn classify_repo(from_repo: &str) -> Origin {
match from_repo {
"@commandline" | "@@commandline" => Origin::Local,
"@System" | "@@System" => Origin::Orphaned,
"" | "(none)" => Origin::Unknown,
_ => Origin::Repo,
}
}
fn name_caps(text: &str) -> Vec<(String, Vec<String>)> {
text.lines()
.filter_map(|line| {
let (name, rest) = line.split_once('\t')?;
if name.is_empty() {
return None;
}
let caps = rest.split_whitespace().map(String::from).collect();
Some((name.to_string(), caps))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
const META: &str = include_str!("../../tests/fixtures/dnf_meta.txt");
const PROVIDES: &str = include_str!("../../tests/fixtures/dnf_provides.txt");
const REQUIRES: &str = include_str!("../../tests/fixtures/dnf_requires.txt");
const FILES: &str = include_str!("../../tests/fixtures/dnf_files.txt");
const USERINSTALLED: &str = include_str!("../../tests/fixtures/dnf_userinstalled.txt");
fn world() -> World {
build(META, PROVIDES, REQUIRES, FILES, USERINSTALLED)
}
#[test]
fn parses_all_packages() {
let w = world();
assert_eq!(w.packages.len(), 241);
assert!(w.packages.contains_key("bash"));
assert!(w.packages.contains_key("glibc"));
}
#[test]
fn meta_size_and_installtime() {
let w = world();
let bash = &w.packages["bash"];
assert!(bash.installed_size > 0); assert!(bash.install_epoch.unwrap() > 0); assert!(bash.install_date.is_some());
}
#[test]
fn resolves_capability_deps_to_packages() {
let w = world();
assert!(
w.deps_of("bash").iter().any(|d| d == "glibc"),
"bash should depend on glibc via resolved soname"
);
assert!(
w.rdeps_of("glibc").iter().any(|r| r == "bash"),
"glibc should be required by bash"
);
}
#[test]
fn manual_set_from_userinstalled() {
let w = world();
assert!(w.is_manual("vim-minimal") || w.is_manual("vim"));
assert!(!w.is_manual("glibc"));
}
#[test]
fn install_log_sorted_and_populated() {
let w = world();
assert!(!w.install_log.is_empty());
assert!(w.install_log.windows(2).all(|x| x[0].0 <= x[1].0));
}
#[test]
fn upgradable_parsing() {
let mut w = world();
apply_upgradable(&mut w, "bash\t5.3.0-2.fc44\n");
assert_eq!(
w.packages["bash"].candidate.as_deref(),
Some("5.3.0-2.fc44")
);
}
#[test]
fn origin_from_repo_classification() {
assert_eq!(classify_repo("fedora"), Origin::Repo);
assert_eq!(classify_repo("updates"), Origin::Repo);
assert_eq!(classify_repo("@commandline"), Origin::Local);
assert_eq!(classify_repo("@System"), Origin::Orphaned);
assert_eq!(classify_repo(""), Origin::Unknown);
}
#[test]
fn apply_origins_sets_local() {
let mut w = world();
apply_origins(&mut w, "bash\t@commandline\n");
assert_eq!(w.packages["bash"].origin, Origin::Local);
}
}