use std::collections::{BTreeMap, BTreeSet};
use serde::Serialize;
use crate::deps::DepsGraph;
#[derive(Debug, Default, Serialize)]
pub struct UnkeepReport {
pub removed: BTreeMap<String, Vec<String>>,
pub still_reached: BTreeMap<String, Vec<String>>,
pub moved: BTreeMap<String, String>,
pub freed: Vec<Freed>,
}
#[derive(Debug, Serialize)]
pub struct Freed {
pub name: String,
pub former_requirers: Vec<String>,
pub in_deps_files: Vec<String>,
}
pub fn plan(
graph: &DepsGraph,
names: &[String],
keeps: &BTreeMap<String, BTreeSet<String>>,
deps_files: &BTreeMap<String, BTreeSet<String>>,
) -> UnkeepReport {
let unkept: BTreeSet<&str> = names.iter().map(String::as_str).collect();
let all_keeps: BTreeSet<String> = keeps.values().flatten().cloned().collect();
let remaining: BTreeSet<String> = all_keeps
.iter()
.filter(|k| !unkept.contains(k.as_str()))
.cloned()
.collect();
let before = graph.reachable(&all_keeps);
let after = graph.reachable(&remaining);
let dependents = graph.dependents();
let dependents_of = |name: &str| -> Vec<String> {
dependents
.get(name)
.cloned()
.unwrap_or_default()
.into_iter()
.collect()
};
let mut report = UnkeepReport::default();
for name in names {
report.removed.insert(
name.clone(),
keeps
.iter()
.filter(|(_, pkgs)| pkgs.contains(name))
.map(|(file, _)| file.clone())
.collect(),
);
if after.contains(name) {
report
.still_reached
.insert(name.clone(), dependents_of(name));
if report.removed[name].is_empty() {
continue;
}
if let Some(reason) = graph.witness_reason(name, &after) {
report.moved.insert(name.clone(), reason);
}
}
}
for name in before.difference(&after) {
report.freed.push(Freed {
name: name.clone(),
former_requirers: dependents_of(name),
in_deps_files: deps_files
.iter()
.filter(|(_, pkgs)| pkgs.contains(name))
.map(|(file, _)| file.clone())
.collect(),
});
}
report
}
pub fn remove_from_inventory(path: &str, names: &BTreeSet<&str>) -> Result<usize, String> {
let mut inventory = sandogasa_inventory::load(path)?;
let before = inventory.package.len();
inventory
.package
.retain(|p| !names.contains(p.name.as_str()));
let removed = before - inventory.package.len();
if removed > 0 {
sandogasa_inventory::save(&inventory, path)?;
}
Ok(removed)
}
pub fn format_report(report: &UnkeepReport, applied: bool) -> String {
let mut out = String::new();
for (name, files) in &report.removed {
match files.is_empty() {
true => out.push_str(&format!("{name}: not in any keep inventory\n")),
false => out.push_str(&format!(
"{name}: {} from {}\n",
if applied { "removed" } else { "would remove" },
files.join(", ")
)),
}
}
for (name, requirers) in &report.still_reached {
match report.moved.contains_key(name) {
true => out.push_str(&format!(
"{name}: stays in the closure (via {}) — {} the derived inventory\n",
requirers.join(", "),
if applied { "moved to" } else { "would move to" },
)),
false => out.push_str(&format!(
"warning: the remaining keeps still reach {name} (via {}) — \
a cull would be rescued right back\n",
requirers.join(", ")
)),
}
}
match report.freed.is_empty() {
true => out.push_str("nothing else falls out of the closure\n"),
false => {
out.push_str(&format!("freed ({} package(s)):\n", report.freed.len()));
for f in &report.freed {
out.push_str(&format!(
" {} — was needed by {}{}\n",
f.name,
f.former_requirers.join(", "),
match f.in_deps_files.is_empty() {
true => String::new(),
false => format!(
" [{} {}]",
if applied { "removed from" } else { "in" },
f.in_deps_files.join(", ")
),
}
));
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::deps::GraphProvider;
fn provider(binary: &str, source: &str) -> GraphProvider {
GraphProvider {
binary: binary.to_string(),
source: source.to_string(),
repoid: "rawhide".to_string(),
}
}
fn graph() -> DepsGraph {
let mut g = DepsGraph {
roots: vec!["app-a".into(), "app-x".into(), "crate-c".into()],
..Default::default()
};
for (b, s) in [
("app-a", "app-a"),
("app-x", "app-x"),
("crate-c-devel", "crate-c"),
("src:crate-c", "crate-c"),
("tool-t", "tool-t"),
("liba", "lib-l"),
] {
g.binary_sources.insert(b.into(), s.into());
}
let edge = |g: &mut DepsGraph, cap: &str, requirer: &str, prov: GraphProvider| {
g.requirers
.entry(cap.into())
.or_default()
.insert(requirer.into());
g.providers.entry(cap.into()).or_default().insert(prov);
};
edge(&mut g, "liba.so", "app-a", provider("liba", "lib-l"));
edge(
&mut g,
"crate(c)",
"app-x",
provider("crate-c-devel", "crate-c"),
);
edge(
&mut g,
"tool-t",
"src:crate-c",
provider("tool-t", "tool-t"),
);
g
}
fn keeps() -> BTreeMap<String, BTreeSet<String>> {
BTreeMap::from([(
"essential.toml".to_string(),
["app-a".to_string(), "app-x".to_string()].into(),
)])
}
fn deps_files() -> BTreeMap<String, BTreeSet<String>> {
BTreeMap::from([(
"essential-deps.toml".to_string(),
["crate-c".to_string()].into(),
)])
}
#[test]
fn unkeeping_frees_the_chain_including_build_deps() {
let report = plan(&graph(), &["app-x".to_string()], &keeps(), &deps_files());
assert_eq!(report.removed["app-x"], ["essential.toml"]);
assert!(report.still_reached.is_empty());
let freed: Vec<&str> = report.freed.iter().map(|f| f.name.as_str()).collect();
assert_eq!(freed, ["app-x", "crate-c", "tool-t"]);
let crate_c = &report.freed[1];
assert_eq!(crate_c.former_requirers, ["app-x"]);
assert_eq!(crate_c.in_deps_files, ["essential-deps.toml"]);
}
#[test]
fn a_still_reached_package_is_a_warning_not_a_free() {
let mut g = graph();
g.requirers
.get_mut("crate(c)")
.unwrap()
.insert("app-a".to_string());
let report = plan(&g, &["app-x".to_string()], &keeps(), &deps_files());
let freed: Vec<&str> = report.freed.iter().map(|f| f.name.as_str()).collect();
assert_eq!(freed, ["app-x"]);
assert!(!report.still_reached.contains_key("app-x"));
}
#[test]
fn unkeeping_a_package_the_closure_reaches_warns() {
let mut g = graph();
g.binary_sources
.insert("app-x-libs".to_string(), "app-x".to_string());
let p = provider("app-x-libs", "app-x");
g.requirers
.entry("libx.so".to_string())
.or_default()
.insert("app-a".to_string());
g.providers
.entry("libx.so".to_string())
.or_default()
.insert(p);
let report = plan(&g, &["app-x".to_string()], &keeps(), &deps_files());
assert_eq!(report.still_reached["app-x"], ["app-a"]);
assert!(report.freed.is_empty());
}
#[test]
fn a_still_reached_keep_moves_to_the_derived_inventory() {
let keeps = BTreeMap::from([(
"essential.toml".to_string(),
[
"app-a".to_string(),
"app-x".to_string(),
"lib-l".to_string(),
]
.into(),
)]);
let report = plan(&graph(), &["lib-l".to_string()], &keeps, &deps_files());
assert_eq!(report.still_reached["lib-l"], ["app-a"]);
assert_eq!(
report.moved["lib-l"],
"dependency (rawhide): app-a requires liba.so"
);
assert!(report.freed.is_empty());
let text = format_report(&report, false);
assert!(text.contains("lib-l: stays in the closure (via app-a) — would move to"));
let no_lib = BTreeMap::from([(
"essential.toml".to_string(),
["app-a".to_string(), "app-x".to_string()].into(),
)]);
let report = plan(&graph(), &["lib-l".to_string()], &no_lib, &deps_files());
assert!(report.moved.is_empty());
assert!(
format_report(&report, false)
.contains("warning: the remaining keeps still reach lib-l")
);
}
#[test]
fn a_non_keep_is_reported_as_such_and_frees_nothing() {
let report = plan(&graph(), &["neovim".to_string()], &keeps(), &deps_files());
assert!(report.removed["neovim"].is_empty());
assert!(report.freed.is_empty());
assert!(report.still_reached.is_empty());
let text = format_report(&report, false);
assert!(text.contains("neovim: not in any keep inventory"));
}
#[test]
fn remove_from_inventory_edits_only_the_named() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("keeps.toml");
let path = path.to_str().unwrap();
std::fs::write(
path,
"[inventory]\nname = \"t\"\ndescription = \"t\"\nmaintainer = \"t\"\n\n\
[[package]]\nname = \"app-a\"\n\n[[package]]\nname = \"app-x\"\n",
)
.unwrap();
assert_eq!(remove_from_inventory(path, &["app-x"].into()).unwrap(), 1);
let left = sandogasa_inventory::load(path).unwrap();
let names: Vec<&str> = left.package.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["app-a"]);
}
}