use super::{Backend, capture};
use crate::model::{Origin, Package, Source, 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();
let mut last: Option<String> = None;
for line in meta_raw.lines() {
let fields: Vec<&str> = line.splitn(4, '\t').collect();
if fields.len() < 4 {
if let Some(name) = &last {
let extra = line.trim();
if !extra.is_empty()
&& extra != "."
&& let Some(pkg) = packages.get_mut(name)
{
let d = pkg.details.get_or_insert_with(String::new);
if d.len() < 240 {
if !d.is_empty() {
d.push(' ');
}
d.push_str(extra);
}
}
}
continue;
}
let name = fields[0].to_string();
if name.is_empty() {
last = None;
continue;
}
packages.insert(
name.clone(),
Package {
name: name.clone(),
version: fields[1].to_string(),
candidate: None,
installed_size: fields[2].trim().parse().unwrap_or(0),
description: fields[3].to_string(),
details: None, manual: false, source: Source::System,
remote: None,
origin: Origin::Unknown, install_epoch: None,
install_date: None,
},
);
last = Some(name);
}
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)
&& !candidate.is_empty()
{
pkg.candidate = Some(candidate.to_string());
}
}
}
let local_only =
parse_installed_local(&capture("apt", &["list", "--installed"]).unwrap_or_default());
let from_deb = parse_deb_installs();
for (name, pkg) in packages.iter_mut() {
pkg.origin = if !local_only.contains(name) {
Origin::Repo
} else if from_deb.contains(name) {
Origin::Local
} else {
Origin::Orphaned
};
}
let install_log = parse_dpkg_logs();
for (epoch, name) in &install_log {
if let Some(pkg) = packages.get_mut(name)
&& 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_installed_local(text: &str) -> HashSet<String> {
let mut set = HashSet::new();
for line in text.lines() {
if line.starts_with("Listing") || line.is_empty() {
continue;
}
let name = match line.split('/').next() {
Some(n) if !n.is_empty() => n,
_ => continue,
};
if let Some(flags) = line.split('[').nth(1)
&& flags.contains("local")
{
set.insert(name.to_string());
}
}
set
}
fn parse_deb_installs() -> HashSet<String> {
let mut set = HashSet::new();
let mut paths: Vec<std::path::PathBuf> = Vec::new();
if let Ok(entries) = std::fs::read_dir("/var/log/apt") {
for entry in entries.flatten() {
if entry
.file_name()
.to_string_lossy()
.starts_with("history.log")
{
paths.push(entry.path());
}
}
}
for path in paths {
let content = match read_maybe_gz(&path) {
Some(c) => c,
None => continue,
};
for entry in content.split("\n\n") {
let mut is_deb = false;
let mut install_line = "";
for line in entry.lines() {
if let Some(cmd) = line.strip_prefix("Commandline:") {
if cmd.contains(".deb") {
is_deb = true;
}
} else if let Some(inst) = line.strip_prefix("Install:") {
install_line = inst.trim();
}
}
if is_deb {
for name in parse_install_targets(install_line) {
set.insert(name);
}
}
}
}
set
}
fn parse_install_targets(line: &str) -> Vec<String> {
line.split("), ")
.filter_map(|entry| {
if entry.contains(", automatic") {
return None; }
let head = entry.split([':', ' ']).next()?;
if head.is_empty() {
None
} else {
Some(head.to_string())
}
})
.collect()
}
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())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn installed_local_flags() {
let text = "Listing...\n\
bash/testing,now 5.3-3 amd64 [installed]\n\
git-delta/now 0.19.1+ds-2 amd64 [installed,local]\n\
coreutils/testing,now 9.10-1 amd64 [installed]\n\
lazygit/now 0.60.0-1+forky amd64 [installed,local]\n";
let local = parse_installed_local(text);
assert!(local.contains("git-delta"));
assert!(local.contains("lazygit"));
assert!(!local.contains("bash"));
assert_eq!(local.len(), 2);
}
#[test]
fn install_targets_skip_automatic_and_handle_parens() {
let line = "lua-luv:amd64 (1.51.0-1-1+b1, automatic), \
neovim:amd64 (0.12.3-4), neovim-runtime:amd64 (0.12.3-4, automatic), \
dos2unix:amd64 (7.5.4-1), python3-msgpack:amd64 (1.1.2-4, automatic)";
let targets = parse_install_targets(line);
assert_eq!(targets, vec!["neovim".to_string(), "dos2unix".to_string()]);
}
}