use crate::model::World;
pub const FOUNDATION_THRESHOLD: usize = 25;
pub fn is_kernel_pkg(name: &str) -> bool {
name.starts_with("linux-")
|| name == "intel-microcode"
|| name == "amd64-microcode"
|| name.starts_with("firmware-")
|| name.starts_with("initramfs")
}
pub fn bfs_root(world: &World, start: &str) -> Option<Vec<String>> {
use std::collections::{HashMap, HashSet, VecDeque};
let mut visited: HashSet<&str> = HashSet::new();
let mut parent: HashMap<&str, &str> = HashMap::new();
let mut queue: VecDeque<&str> = VecDeque::new();
visited.insert(start);
queue.push_back(start);
let mut found: Option<&str> = None;
'search: while let Some(cur) = queue.pop_front() {
for dep in world.rdeps_of(cur) {
let dep = dep.as_str();
if !visited.insert(dep) {
continue;
}
parent.insert(dep, cur);
if world.is_manual(dep) {
found = Some(dep);
break 'search;
}
queue.push_back(dep);
}
}
let found = found?;
let mut path = vec![found.to_string()];
let mut node = found;
while let Some(&p) = parent.get(node) {
path.push(p.to_string());
node = p;
}
path.reverse();
Some(path)
}
pub fn same_session(world: &World, pkg: &str) -> Vec<String> {
let anchor = match world.packages.get(pkg).and_then(|p| p.install_epoch) {
Some(e) => e,
None => return Vec::new(),
};
const WINDOWS: [i64; 4] = [259_200, 86_400, 43_200, 21_600];
let collect = |half: i64| -> Vec<String> {
let (lo, hi) = (anchor - half, anchor + half);
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for (epoch, name) in &world.install_log {
if *epoch < lo || *epoch > hi {
continue;
}
if name == pkg {
continue;
}
if seen.insert(name.clone()) {
out.push(name.clone());
}
}
out.sort();
out
};
for half in WINDOWS {
let result = collect(half);
if result.len() <= 20 {
return result;
}
}
collect(21_600)
}
pub fn relative_time(epoch: i64) -> String {
let secs = chrono::Utc::now().timestamp() - epoch;
if secs < 0 {
return "in the future".to_string();
}
let days = secs / 86_400;
let plural = |n: i64| if n == 1 { "" } else { "s" };
match days {
0 => "today".to_string(),
1 => "yesterday".to_string(),
2..=6 => format!("{days} days ago"),
7..=29 => {
let w = days / 7;
format!("{w} week{} ago", plural(w))
}
30..=364 => {
let m = days / 30;
format!("{m} month{} ago", plural(m))
}
_ => {
let y = days / 365;
format!("{y} year{} ago", plural(y))
}
}
}
pub fn format_size(kb: u64) -> String {
if kb == 0 {
"n/a".to_string()
} else if kb >= 1024 {
format!("{:.1} MB", kb as f64 / 1024.0)
} else {
format!("{kb} KB")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::World;
#[test]
fn traces_an_auto_package_to_the_manual_one_that_pulled_it_in() {
let w = World::from_edges(
&[("firefox", "libnss3"), ("libnss3", "libfoo")],
&["firefox"],
);
let path = bfs_root(&w, "libfoo").expect("libfoo should trace to firefox");
assert_eq!(path.first().unwrap(), "libfoo");
assert_eq!(path.last().unwrap(), "firefox");
}
#[test]
fn picks_the_shortest_chain_when_several_manual_roots_exist() {
let w = World::from_edges(
&[
("firefox", "libnss3"),
("libnss3", "libfoo"),
("vlc", "libfoo"),
],
&["firefox", "vlc"],
);
let path = bfs_root(&w, "libfoo").unwrap();
assert_eq!(path.last().unwrap(), "vlc", "vlc is one hop away");
assert_eq!(path.len(), 2);
}
#[test]
fn returns_none_for_an_orphan_with_no_manual_ancestor() {
let w = World::from_edges(&[("liba", "libb")], &[]);
assert!(bfs_root(&w, "libb").is_none());
}
#[test]
fn survives_dependency_cycles() {
let w = World::from_edges(&[("liba", "libb"), ("libb", "liba")], &[]);
assert!(bfs_root(&w, "liba").is_none());
let w = World::from_edges(
&[("app", "liba"), ("liba", "libb"), ("libb", "liba")],
&["app"],
);
assert_eq!(bfs_root(&w, "libb").unwrap().last().unwrap(), "app");
}
#[test]
fn a_manual_package_still_traces_out_to_whatever_needs_it() {
let w = World::from_edges(&[("app", "libfoo")], &["app", "libfoo"]);
assert_eq!(bfs_root(&w, "libfoo").unwrap().last().unwrap(), "app");
assert!(bfs_root(&w, "app").is_none());
}
#[test]
fn same_session_is_empty_without_an_install_time() {
let w = World::from_edges(&[("app", "lib")], &["app"]);
assert!(same_session(&w, "app").is_empty());
}
#[test]
fn same_session_returns_packages_installed_near_the_anchor() {
let mut w = World::from_edges(&[("app", "lib")], &["app"]);
let t = 1_700_000_000;
w.packages.get_mut("app").unwrap().install_epoch = Some(t);
w.install_log = vec![
(t - 10, "installed-with-it".into()),
(t, "app".into()),
(t + 10, "also-with-it".into()),
(t + 60 * 60 * 24 * 30, "much-later".into()),
];
let session = same_session(&w, "app");
assert!(session.contains(&"installed-with-it".to_string()));
assert!(session.contains(&"also-with-it".to_string()));
assert!(!session.contains(&"much-later".to_string()));
assert!(!session.contains(&"app".to_string()), "excludes itself");
}
#[test]
fn kernel_packages_are_recognised() {
assert!(is_kernel_pkg("linux-image-6.1"));
assert!(is_kernel_pkg("intel-microcode"));
assert!(is_kernel_pkg("firmware-realtek"));
assert!(!is_kernel_pkg("firefox"));
assert!(!is_kernel_pkg("linuxlogo"), "prefix must be `linux-`");
}
#[test]
fn sizes_render_in_the_right_unit() {
assert_eq!(format_size(0), "n/a");
assert_eq!(format_size(512), "512 KB");
assert_eq!(format_size(1024), "1.0 MB");
}
#[test]
fn relative_time_reads_naturally() {
let now = chrono::Utc::now().timestamp();
assert_eq!(relative_time(now), "today");
assert_eq!(relative_time(now - 86_400), "yesterday");
assert_eq!(relative_time(now - 86_400 * 3), "3 days ago");
assert_eq!(relative_time(now - 86_400 * 14), "2 weeks ago");
assert_eq!(relative_time(now - 86_400 * 60), "2 months ago");
assert_eq!(relative_time(now - 86_400 * 400), "1 year ago");
}
}