use std::collections::BTreeSet;
use serde::Serialize;
use crate::deps::DepsGraph;
#[derive(Debug, Default, Serialize)]
pub struct DeriveReport {
pub derived: Vec<Derived>,
pub added: Vec<String>,
pub removed: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub unreached: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct Derived {
pub name: String,
pub reason: Option<String>,
}
pub fn derive(
graph: &DepsGraph,
keeps: &BTreeSet<String>,
owned: &BTreeSet<String>,
current: &BTreeSet<String>,
) -> DeriveReport {
let reachable = graph.reachable(keeps);
view(graph, &reachable, keeps, current, |name| {
owned.contains(name)
})
}
pub fn closure(
graph: &DepsGraph,
keeps: &BTreeSet<String>,
owned_elsewhere: Option<&BTreeSet<String>>,
current: &BTreeSet<String>,
from: &BTreeSet<String>,
base_prefixes: &[String],
) -> DeriveReport {
let (reached, collected) = graph.collectable(keeps, from, base_prefixes);
let misplaced =
|name: &str| keeps.contains(name) || owned_elsewhere.is_some_and(|o| o.contains(name));
let mut report = view(graph, &reached, keeps, current, |name| {
collected.contains(name) && !misplaced(name)
});
let (removed, unreached): (Vec<String>, Vec<String>) = std::mem::take(&mut report.removed)
.into_iter()
.partition(|name| misplaced(name));
report.removed = removed;
report.unreached = unreached;
report
}
pub fn apply_merge(path: &str, meta_from: &str, report: &DeriveReport) -> Result<(), String> {
let mut inventory = match std::path::Path::new(path).exists() {
true => sandogasa_inventory::load(path)?,
false => sandogasa_inventory::Inventory {
inventory: sandogasa_inventory::load(meta_from)?.inventory,
package: Vec::new(),
},
};
inventory
.package
.retain(|p| !report.removed.contains(&p.name));
let have: BTreeSet<String> = inventory.package.iter().map(|p| p.name.clone()).collect();
for d in report.derived.iter().filter(|d| !have.contains(&d.name)) {
inventory.package.push(sandogasa_inventory::Package {
name: d.name.clone(),
reason: d.reason.clone(),
..Default::default()
});
}
sandogasa_inventory::save(&inventory, path)?;
Ok(())
}
fn view(
graph: &DepsGraph,
reachable: &BTreeSet<String>,
keeps: &BTreeSet<String>,
current: &BTreeSet<String>,
include: impl Fn(&str) -> bool,
) -> DeriveReport {
let mut report = DeriveReport::default();
for name in reachable {
if keeps.contains(name) || !include(name) {
continue;
}
report.derived.push(Derived {
name: name.clone(),
reason: graph.witness_reason(name, reachable),
});
if !current.contains(name) {
report.added.push(name.clone());
}
}
let derived_names: BTreeSet<&str> = report.derived.iter().map(|d| d.name.as_str()).collect();
report.removed = current
.iter()
.filter(|c| !derived_names.contains(c.as_str()))
.cloned()
.collect();
report
}
pub fn format_report(report: &DeriveReport, applied: bool) -> String {
format_report_of("derived inventory", report, applied)
}
pub fn format_report_of(what: &str, report: &DeriveReport, applied: bool) -> String {
let mut out = String::new();
out.push_str(&format!(
"{what}: {} package(s) ({} to add, {} to remove{})\n",
report.derived.len(),
report.added.len(),
report.removed.len(),
if applied {
"; applied"
} else {
"; report only"
},
));
for name in &report.added {
out.push_str(&format!(" + {name}\n"));
}
for name in &report.removed {
out.push_str(&format!(" - {name}\n"));
}
if !report.unreached.is_empty() {
out.push_str(&format!(
" {} not reached by the graph, kept: {}\n",
report.unreached.len(),
report.unreached.join(", ")
));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::deps::GraphProvider;
fn graph() -> DepsGraph {
let mut g = DepsGraph {
roots: vec!["app-a".into(), "crate-c".into()],
..Default::default()
};
for (b, s) in [
("app-a", "app-a"),
("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, binary: &str, source: &str| {
g.requirers
.entry(cap.into())
.or_default()
.insert(requirer.into());
g.providers
.entry(cap.into())
.or_default()
.insert(GraphProvider {
binary: binary.into(),
source: source.into(),
repoid: "rawhide".into(),
});
};
edge(&mut g, "crate(c)", "app-a", "crate-c-devel", "crate-c");
edge(&mut g, "tool-t", "src:crate-c", "tool-t", "tool-t");
g
}
#[test]
fn the_closure_view_is_reachable_non_owned_non_keeps() {
let g = graph();
let keeps: BTreeSet<String> = ["app-a".to_string()].into();
let owned: BTreeSet<String> = ["app-a", "crate-c", "lib-l"]
.into_iter()
.map(String::from)
.collect();
let current: BTreeSet<String> = ["gone", "app-a", "crate-c"]
.into_iter()
.map(String::from)
.collect();
let from: BTreeSet<String> = ["rawhide".to_string()].into();
let report = closure(&g, &keeps, Some(&owned), ¤t, &from, &[]);
let names: Vec<&str> = report.derived.iter().map(|d| d.name.as_str()).collect();
assert_eq!(names, ["tool-t"], "reachable, not a keep, not owned");
assert_eq!(report.added, ["tool-t"]);
assert_eq!(report.removed, ["app-a", "crate-c"]);
assert_eq!(report.unreached, ["gone"]);
assert!(
report.derived[0]
.reason
.as_deref()
.is_some_and(|r| r.contains("crate-c") && r.contains("tool-t")),
"{:?}",
report.derived[0].reason
);
let dir = tempfile::tempdir().unwrap();
let meta = dir.path().join("keeps.toml");
std::fs::write(
&meta,
"[inventory]\nname = \"k\"\ndescription = \"k\"\nmaintainer = \"me\"\n",
)
.unwrap();
let out = dir.path().join("closure.toml");
std::fs::write(
&out,
"[inventory]\nname = \"c\"\ndescription = \"c\"\nmaintainer = \"me\"\n\n[[package]]\nname = \"gone\"\nreason = \"old\"\n\n[[package]]\nname = \"app-a\"\n\n[[package]]\nname = \"crate-c\"\n",
)
.unwrap();
apply_merge(out.to_str().unwrap(), meta.to_str().unwrap(), &report).unwrap();
let written = sandogasa_inventory::load(out.to_str().unwrap()).unwrap();
let entries: Vec<(&str, Option<&str>)> = written
.package
.iter()
.map(|p| (p.name.as_str(), p.reason.as_deref()))
.collect();
assert_eq!(
entries[0],
("gone", Some("old")),
"unreached stays, reason intact"
);
assert_eq!(entries[1].0, "tool-t");
assert_eq!(entries.len(), 2);
let report = closure(&g, &keeps, None, ¤t, &from, &[]);
let names: Vec<&str> = report.derived.iter().map(|d| d.name.as_str()).collect();
assert_eq!(names, ["crate-c", "tool-t"]);
let mut g2 = g.clone();
for (cap, requirer, binary, source, repoid) in [
(
"libc.so.6",
"tool-t",
"glibc",
"glibc",
"fedrq-centos-stream-baseos",
),
("libextra.so", "tool-t", "libextra", "extra", "epel"),
("libdeeper.so", "libextra", "libdeeper", "deeper", "rawhide"),
] {
g2.binary_sources.insert(binary.into(), source.into());
g2.requirers
.entry(cap.into())
.or_default()
.insert(requirer.into());
g2.providers
.entry(cap.into())
.or_default()
.insert(GraphProvider {
binary: binary.into(),
source: source.into(),
repoid: repoid.into(),
});
}
let base = ["fedrq-centos-stream-".to_string()];
let report = closure(&g2, &keeps, Some(&owned), ¤t, &from, &base);
let names: Vec<&str> = report.derived.iter().map(|d| d.name.as_str()).collect();
assert_eq!(
names,
["deeper", "tool-t"],
"glibc is base, extra is outside --from yet traversed"
);
assert!(
format_report_of("closure inventory", &report, true)
.starts_with("closure inventory: 2 package(s) (2 to add, 2 to remove; applied)"),
"{}",
format_report_of("closure inventory", &report, true)
);
}
#[test]
fn the_view_is_reachable_owned_non_keeps_with_witnesses() {
let keeps: BTreeSet<String> = ["app-a".to_string()].into();
let owned: BTreeSet<String> = ["app-a", "crate-c", "lib-l"].map(String::from).into();
let current: BTreeSet<String> = ["crate-c", "lib-l"].map(String::from).into();
let report = derive(&graph(), &keeps, &owned, ¤t);
let names: Vec<&str> = report.derived.iter().map(|d| d.name.as_str()).collect();
assert_eq!(names, ["crate-c"]);
assert_eq!(
report.derived[0].reason.as_deref(),
Some("dependency (rawhide): app-a requires crate(c)")
);
assert!(report.added.is_empty());
assert_eq!(report.removed, ["lib-l"]);
let corrected: BTreeSet<String> = ["crate-c".to_string()].into();
let again = derive(&graph(), &keeps, &owned, &corrected);
assert!(again.added.is_empty() && again.removed.is_empty());
let text = format_report(&again, false);
assert!(text.contains("1 package(s) (0 to add, 0 to remove; report only)"));
}
}