use super::{capture, Backend};
use crate::model::{Package, World};
use chrono::NaiveDateTime;
use flate2::read::GzDecoder;
use std::collections::{HashMap, HashSet};
use std::io::Read;
pub struct Apt;
impl Backend for Apt {
fn name(&self) -> &'static str {
"apt"
}
fn build_world(&self) -> Result<World, String> {
let manual: HashSet<String> = capture("apt-mark", &["showmanual"])?
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(String::from)
.collect();
let meta_raw = capture(
"dpkg-query",
&[
"-W",
"-f=${Package}\t${Version}\t${Installed-Size}\t${Description}\n",
],
)?;
let mut packages: HashMap<String, Package> = HashMap::new();
for line in meta_raw.lines() {
let fields: Vec<&str> = line.splitn(4, '\t').collect();
if fields.len() < 4 {
continue; }
let name = fields[0].to_string();
if name.is_empty() {
continue;
}
packages.insert(
name.clone(),
Package {
name,
version: fields[1].to_string(),
candidate: None,
installed_size: fields[2].trim().parse().unwrap_or(0),
description: fields[3].to_string(),
manual: false, install_epoch: None,
install_date: None,
},
);
}
for (name, pkg) in packages.iter_mut() {
pkg.manual = manual.contains(name);
}
let dep_raw = capture(
"dpkg-query",
&["-W", "-f=${Package}\t${Depends}\t${Recommends}\n"],
)?;
let mut deps: HashMap<String, Vec<String>> = HashMap::new();
let mut rdeps: HashMap<String, Vec<String>> = HashMap::new();
for line in dep_raw.lines() {
let f: Vec<&str> = line.splitn(3, '\t').collect();
if f.is_empty() || f[0].is_empty() {
continue;
}
let pkg = f[0];
let combined = format!(
"{},{}",
f.get(1).copied().unwrap_or(""),
f.get(2).copied().unwrap_or("")
);
let mut seen = HashSet::new();
for dep in parse_dep_field(&combined) {
if dep == pkg || !seen.insert(dep.clone()) {
continue;
}
deps.entry(pkg.to_string()).or_default().push(dep.clone());
rdeps.entry(dep).or_default().push(pkg.to_string());
}
}
if let Ok(up_raw) = capture("apt", &["list", "--upgradable"]) {
for line in up_raw.lines() {
if line.starts_with("Listing") || line.is_empty() {
continue;
}
let name = match line.split('/').next() {
Some(n) if !n.is_empty() => n,
_ => continue,
};
let candidate = line.split_whitespace().nth(1).unwrap_or("");
if let Some(pkg) = packages.get_mut(name) {
if !candidate.is_empty() {
pkg.candidate = Some(candidate.to_string());
}
}
}
}
let install_log = parse_dpkg_logs();
for (epoch, name) in &install_log {
if let Some(pkg) = packages.get_mut(name) {
if pkg.install_epoch.is_none() {
pkg.install_epoch = Some(*epoch);
pkg.install_date = Some(epoch_to_date(*epoch));
}
}
}
Ok(World {
packages,
deps,
rdeps,
manual,
install_log,
})
}
}
fn parse_dep_field(field: &str) -> Vec<String> {
field
.split(',')
.filter_map(|raw| {
let mut s = raw.trim();
if let Some(i) = s.find('|') {
s = s[..i].trim(); }
if let Some(i) = s.find('(') {
s = s[..i].trim(); }
let name = s.split(':').next().unwrap_or(s).trim(); if name.is_empty() {
None
} else {
Some(name.to_string())
}
})
.collect()
}
fn parse_dpkg_logs() -> Vec<(i64, String)> {
let mut events: Vec<(i64, String)> = Vec::new();
let mut paths: Vec<std::path::PathBuf> = Vec::new();
if let Ok(entries) = std::fs::read_dir("/var/log") {
for entry in entries.flatten() {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with("dpkg.log") {
paths.push(entry.path());
}
}
}
for path in paths {
let content = match read_maybe_gz(&path) {
Some(c) => c,
None => continue,
};
for line in content.lines() {
let mut it = line.split_whitespace();
let (date, time, action) = match (it.next(), it.next(), it.next()) {
(Some(d), Some(t), Some(a)) => (d, t, a),
_ => continue,
};
if action != "install" {
continue;
}
let pkg = match it.next() {
Some(p) => p.split(':').next().unwrap_or(p).to_string(),
None => continue,
};
if let Some(epoch) = parse_log_timestamp(date, time) {
events.push((epoch, pkg));
}
}
}
events.sort_by_key(|(epoch, _)| *epoch);
events
}
fn read_maybe_gz(path: &std::path::Path) -> Option<String> {
let bytes = std::fs::read(path).ok()?;
if path.extension().map(|e| e == "gz").unwrap_or(false) {
let mut s = String::new();
GzDecoder::new(&bytes[..]).read_to_string(&mut s).ok()?;
Some(s)
} else {
Some(String::from_utf8_lossy(&bytes).into_owned())
}
}
fn parse_log_timestamp(date: &str, time: &str) -> Option<i64> {
NaiveDateTime::parse_from_str(&format!("{date} {time}"), "%Y-%m-%d %H:%M:%S")
.ok()
.map(|dt| dt.and_utc().timestamp())
}
fn epoch_to_date(epoch: i64) -> String {
chrono::DateTime::from_timestamp(epoch, 0)
.map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "unknown".to_string())
}