use std::collections::{BTreeMap, BTreeSet};
use std::io::IsTerminal;
use std::sync::atomic::Ordering::Relaxed;
use sandogasa_review::{Answer, Arg, Choice, Menu};
use serde::Serialize;
use crate::workspace::{Closure, Workspace};
use crate::{dependents, deps, derive, kondo, unkeep};
pub struct Options<'a> {
pub ws: &'a Workspace,
pub into: Option<String>,
pub yes: bool,
pub json: bool,
pub verbose: bool,
pub dry_run: bool,
}
#[derive(Debug, Default, Serialize)]
pub struct ClosureReport {
pub name: String,
pub external: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub skipped: Option<String>,
pub new_keeps: Vec<String>,
pub dropped_keeps: Vec<String>,
pub derived_added: Vec<String>,
pub derived_removed: Vec<String>,
pub promoted: Vec<String>,
pub demoted: Vec<String>,
pub closure_added: Vec<String>,
pub closure_removed: Vec<String>,
pub closure_unreached: Vec<String>,
pub capabilities_offline: usize,
pub capabilities_online: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct Watched {
pub candidate: String,
pub keep: String,
}
#[derive(Debug, Default, Serialize)]
pub struct Report {
pub closures: Vec<ClosureReport>,
pub watched: Vec<Watched>,
pub triage_candidates: Vec<String>,
}
fn names_of(path: &str) -> Result<BTreeSet<String>, String> {
Ok(sandogasa_inventory::load(path)?
.package
.into_iter()
.map(|p| p.name)
.collect())
}
fn shipped_names_of(path: &str) -> Result<BTreeSet<String>, String> {
Ok(sandogasa_inventory::load(path)?
.package
.into_iter()
.filter(|p| !p.is_unshipped())
.map(|p| p.name)
.collect())
}
fn read_graph(path: &str) -> Result<deps::DepsGraph, String> {
deps::DepsGraph::load(path)
}
fn write_graph(path: &str, graph: &deps::DepsGraph) -> Result<(), String> {
let bytes = serde_json::to_vec_pretty(graph).map_err(|e| e.to_string())?;
std::fs::write(path, bytes).map_err(|e| format!("writing {path}: {e}"))
}
fn ask_pair(summary: &str, choices: &[Choice; 2], default: char) -> Result<Answer, String> {
sandogasa_review::ask(
summary,
&Menu {
choices,
default: Some(default),
all: true,
quit: true,
default_arg: None,
},
)
}
fn walk_filters(c: &Closure) -> (BTreeSet<String>, Vec<String>) {
let from: BTreeSet<String> = if c.from.is_empty() {
["epel".to_string()].into()
} else {
c.from.iter().cloned().collect()
};
let base: Vec<String> = if c.base_repo.is_empty() {
vec!["fedrq-centos-stream-".to_string()]
} else {
c.base_repo.clone()
};
(from, base)
}
fn walk_new(
c: &Closure,
graph: &mut deps::DepsGraph,
names: &[String],
owned: &BTreeSet<String>,
verbose: bool,
) -> Result<(deps::DepsReport, usize, usize), String> {
sandogasa_cli::require_tools(&[("fedrq", "sudo dnf install fedrq", Some("--version"))])?;
let fedrq = sandogasa_fedrq::Fedrq {
branch: Some(c.branch.clone()),
repo: c.repo.clone(),
};
let query = deps::GraphBackedQuery::new(&fedrq, graph);
let (from, base) = walk_filters(c);
let (report, new_graph) = deps::walk(
&query,
names,
&deps::WalkOpts {
from: &from,
base_prefixes: &base,
build: !c.runtime_only,
fixpoint_own: Some(owned),
verbose,
},
)?;
let (offline, online) = (query.offline.load(Relaxed), query.online.load(Relaxed));
graph.merge(new_graph);
Ok((report, offline, online))
}
fn reconcile_closure(
opts: &Options,
c: &Closure,
owned: &BTreeSet<String>,
retired: &BTreeSet<String>,
interactive: bool,
) -> Result<ClosureReport, String> {
let ws = opts.ws;
let resolve = |p: &str| ws.dir.join(p).to_string_lossy().into_owned();
let mut report = ClosureReport {
name: c.name.clone(),
external: c.external,
..Default::default()
};
let Some(graph_path) = c.graph.as_deref().map(resolve) else {
report.skipped =
Some("no graph in the workspace: run `poi-tracker --closure NAME deps` once".into());
return Ok(report);
};
if !std::path::Path::new(&graph_path).exists() {
report.skipped = Some(format!(
"{graph_path} does not exist yet: run `poi-tracker --closure {} deps` once",
c.name
));
return Ok(report);
}
let mut graph = read_graph(&graph_path)?;
let keeps_files: Vec<String> = c.keeps.iter().map(|p| resolve(p)).collect();
let into = opts.into.clone().unwrap_or_else(|| keeps_files[0].clone());
let maintainer = sandogasa_inventory::load(&keeps_files[0])?
.inventory
.maintainer;
let mut quiet = !interactive;
for _round in 0..5 {
let mut keep_file_of: BTreeMap<String, String> = BTreeMap::new();
for f in &keeps_files {
for n in shipped_names_of(f)? {
keep_file_of.entry(n).or_insert_with(|| f.clone());
}
}
let keeps: BTreeSet<String> = keep_file_of.keys().cloned().collect();
let roots: BTreeSet<String> = graph.roots.iter().cloned().collect();
let new_keeps: Vec<String> = keeps
.iter()
.filter(|k| !roots.contains(*k) || !graph.walked(k))
.cloned()
.collect();
let reachable = graph.reachable(&keeps);
let dropped: Vec<String> = roots
.iter()
.filter(|r| !keeps.contains(*r) && !reachable.contains(*r) && !retired.contains(*r))
.cloned()
.collect();
for d in &dropped {
if !report.dropped_keeps.contains(d) {
report.dropped_keeps.push(d.clone());
}
}
if !new_keeps.is_empty() && opts.dry_run {
report.new_keeps.extend(new_keeps.iter().cloned());
} else if !new_keeps.is_empty() {
if opts.verbose {
eprintln!(
"[reconcile] {}: walking {} new keep(s)",
c.name,
new_keeps.len()
);
}
let (walk, offline, online) = walk_new(c, &mut graph, &new_keeps, owned, opts.verbose)?;
report.capabilities_offline += offline;
report.capabilities_online += online;
write_graph(&graph_path, &graph)?;
report.new_keeps.extend(new_keeps.iter().cloned());
let _ = walk;
}
if let Some(closure_file) = c.closure.as_deref().map(resolve) {
let current = match std::path::Path::new(&closure_file).exists() {
true => names_of(&closure_file)?,
false => Default::default(),
};
let (from, base) = walk_filters(c);
let owned_elsewhere = c.derived.is_some().then_some(owned);
let cl = derive::closure(&graph, &keeps, owned_elsewhere, ¤t, &from, &base);
for name in &cl.added {
if !report.closure_added.contains(name) {
report.closure_added.push(name.clone());
}
}
for name in &cl.removed {
if !report.closure_removed.contains(name) {
report.closure_removed.push(name.clone());
}
}
for name in &cl.unreached {
if !report.closure_unreached.contains(name) {
report.closure_unreached.push(name.clone());
}
}
if !opts.dry_run && !(cl.added.is_empty() && cl.removed.is_empty()) {
derive::apply_merge(&closure_file, &keeps_files[0], &cl)?;
}
}
let mut promoted_now: Vec<String> = Vec::new();
if let Some(derived_path) = c.derived.as_deref().map(resolve) {
let current = match std::path::Path::new(&derived_path).exists() {
true => names_of(&derived_path)?,
false => Default::default(),
};
let d = derive::derive(&graph, &keeps, owned, ¤t);
let reasons: BTreeMap<&str, &Option<String>> = d
.derived
.iter()
.map(|x| (x.name.as_str(), &x.reason))
.collect();
for name in &d.added {
if report.derived_added.contains(name) {
continue;
}
let mut choice = 'r';
if !quiet && !c.external {
let why = reasons
.get(name.as_str())
.and_then(|r| r.as_deref())
.unwrap_or("reachable from the keeps");
match ask_pair(
&format!("{name} now rides in the derived inventory — {why}"),
&[Choice::new('r', "ride"), Choice::new('e', "essential")],
'r',
)? {
Answer::Pick { key, .. } => choice = key,
Answer::All => quiet = true,
Answer::Quit => {
quiet = true;
interactive_off();
}
}
}
if choice == 'e' {
if !opts.dry_run {
kondo::file_into_inventory(&into, name, &maintainer)?;
}
promoted_now.push(name.clone());
report.promoted.push(name.clone());
} else {
report.derived_added.push(name.clone());
}
}
for name in &d.removed {
if !report.derived_removed.contains(name) {
report.derived_removed.push(name.clone());
}
}
let final_report = if promoted_now.is_empty() {
d
} else {
let mut k = keeps.clone();
k.extend(promoted_now.iter().cloned());
derive::derive(&graph, &k, owned, ¤t)
};
if !opts.dry_run {
crate::apply_derived(&derived_path, &keeps_files[0], &final_report)
.map_err(|e| e.to_string())?;
}
}
if !c.external && c.derived.is_some() {
let classified = dependents::classify(&graph, &keeps);
let mut demote: Vec<String> = Vec::new();
for carried in classified.carried.iter().filter(|k| k.devel_only) {
if report.demoted.contains(&carried.name) || report.promoted.contains(&carried.name)
{
continue;
}
if quiet {
break;
}
match ask_pair(
&format!(
"{} is a keep only devel-only edges carry ({} → it)",
carried.name,
carried.dependents.join(", ")
),
&[Choice::new('k', "keep"), Choice::new('d', "demote")],
'k',
)? {
Answer::Pick { key: 'd', .. } => demote.push(carried.name.clone()),
Answer::Pick { .. } => {}
Answer::All | Answer::Quit => {
quiet = true;
break;
}
}
}
if !demote.is_empty() {
let mut by_file: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for n in &demote {
if let Some(f) = keep_file_of.get(n) {
by_file.entry(f.as_str()).or_default().insert(n.as_str());
}
}
if !opts.dry_run {
for (f, names) in by_file {
unkeep::remove_from_inventory(f, &names)?;
}
}
report.demoted.extend(demote);
continue;
}
}
if promoted_now.is_empty() || opts.dry_run {
break;
}
}
Ok(report)
}
fn offer_watching(
opts: &Options,
c: &Closure,
candidates: &[String],
owned: &BTreeSet<String>,
) -> Result<Vec<Watched>, String> {
let ws = opts.ws;
let resolve = |p: &str| ws.dir.join(p).to_string_lossy().into_owned();
let mut foreign: BTreeSet<String> = BTreeSet::new();
for cl in &ws.closures {
for p in cl
.keeps
.iter()
.chain(cl.closure.iter())
.chain(cl.derived.iter())
{
let path = resolve(p);
if std::path::Path::new(&path).exists() {
foreign.extend(names_of(&path)?);
}
}
}
foreign.retain(|n| !owned.contains(n));
if foreign.is_empty() || candidates.is_empty() {
return Ok(Vec::new());
}
sandogasa_cli::require_tools(&[("fedrq", "sudo dnf install fedrq", Some("--version"))])?;
let fedrq = sandogasa_fedrq::Fedrq {
branch: Some(c.branch.clone()),
repo: c.repo.clone(),
};
let into = opts.into.clone().unwrap_or_else(|| resolve(&c.keeps[0]));
let maintainer = sandogasa_inventory::load(&resolve(&c.keeps[0]))?
.inventory
.maintainer;
let mut watched = Vec::new();
for cand in candidates {
let bins = fedrq.subpkgs_names(cand).unwrap_or_default();
if bins.is_empty() {
continue;
}
let needers: Vec<String> = fedrq
.whatrequires(&bins)
.unwrap_or_default()
.into_iter()
.filter(|s| foreign.contains(s))
.collect();
let Some(first) = needers.first() else {
continue;
};
let choices = [
Choice::with('e', "essential", Arg::Optional("inventory")),
Choice::new('t', "triage"),
];
let answer = sandogasa_review::ask(
&format!(
"{cand} is needed by closure package(s) you do not own: {} — make {first} essential?",
needers.join(", ")
),
&Menu {
choices: &choices,
default: Some('t'),
all: false,
quit: true,
default_arg: None,
},
)?;
match answer {
Answer::Pick { key: 'e', arg } => {
let dest = arg.unwrap_or_else(|| into.clone());
if !opts.dry_run {
kondo::file_into_inventory(&dest, first, &maintainer)?;
}
watched.push(Watched {
candidate: cand.clone(),
keep: first.clone(),
});
}
Answer::Quit => return Ok(watched),
Answer::Pick { .. } | Answer::All => {}
}
}
Ok(watched)
}
fn interactive_off() {
eprintln!("(taking the defaults from here on)");
}
fn format_closure(r: &ClosureReport) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let tag = if r.external { " (external)" } else { "" };
let _ = writeln!(out, "closure {}{tag}:", r.name);
if let Some(s) = &r.skipped {
let _ = writeln!(out, " skipped — {s}");
return out;
}
let list = |label: &str, v: &[String]| -> String {
if v.is_empty() {
String::new()
} else {
let shown: Vec<&str> = v.iter().take(12).map(String::as_str).collect();
let more = if v.len() > 12 {
format!(", … {} more", v.len() - 12)
} else {
String::new()
};
format!(" {label} ({}): {}{more}\n", v.len(), shown.join(", "))
}
};
out.push_str(&list("new or never-walked keeps walked", &r.new_keeps));
if !r.new_keeps.is_empty() {
let _ = writeln!(
out,
" {} capabilit{} answered from the graph, {} resolved live",
r.capabilities_offline,
if r.capabilities_offline == 1 {
"y"
} else {
"ies"
},
r.capabilities_online
);
}
out.push_str(&list("former keeps nothing reaches now", &r.dropped_keeps));
out.push_str(&list("added to the closure inventory", &r.closure_added));
out.push_str(&list("left the closure inventory", &r.closure_removed));
out.push_str(&list(
"in the closure inventory but not reached by the graph (kept; a full walk settles them)",
&r.closure_unreached,
));
out.push_str(&list("entered the derived inventory", &r.derived_added));
out.push_str(&list("left the derived inventory", &r.derived_removed));
out.push_str(&list("promoted to essential", &r.promoted));
out.push_str(&list("demoted to derived", &r.demoted));
if out.lines().count() == 1 {
let _ = writeln!(out, " nothing changed");
}
out
}
pub fn run(opts: &Options) -> Result<Report, String> {
let ws = opts.ws;
let resolve = |p: &str| ws.dir.join(p).to_string_lossy().into_owned();
let owned_path = ws
.owned
.as_deref()
.map(resolve)
.ok_or("the workspace has no `owned` inventory")?;
let owned = names_of(&owned_path)?;
let mut retired: BTreeSet<String> = BTreeSet::new();
for p in &ws.retired {
let path = resolve(p);
if std::path::Path::new(&path).exists() {
retired.extend(names_of(&path)?);
}
}
let interactive = !opts.yes && !opts.json && std::io::stdin().is_terminal();
let mut report = Report::default();
for c in &ws.closures {
let r = reconcile_closure(opts, c, &owned, &retired, interactive)?;
if !opts.json {
print!("{}", format_closure(&r));
}
report.closures.push(r);
}
let mut essential: BTreeSet<String> = BTreeSet::new();
for p in ws.essential() {
let path = resolve(&p);
if std::path::Path::new(&path).exists() {
essential.extend(names_of(&path)?);
}
}
let prior = match ws.cull.as_deref().map(resolve) {
Some(p) if std::path::Path::new(&p).exists() => kondo::prior_culled(&p)?,
_ => Default::default(),
};
let candidates_of = |essential: &BTreeSet<String>| -> Vec<String> {
owned
.iter()
.filter(|n| !essential.contains(*n) && !prior.contains(*n))
.cloned()
.collect()
};
report.triage_candidates = candidates_of(&essential);
if interactive
&& !opts.dry_run
&& let Some(c) = ws
.closures
.iter()
.find(|c| !c.external && c.graph.is_some())
{
let watched = offer_watching(opts, c, &report.triage_candidates, &owned)?;
if !watched.is_empty() {
report.watched = watched;
let r = reconcile_closure(opts, c, &owned, &retired, interactive)?;
if !opts.json {
print!("{}", format_closure(&r));
}
report.closures.push(r);
let mut essential: BTreeSet<String> = BTreeSet::new();
for p in ws.essential() {
let path = resolve(&p);
if std::path::Path::new(&path).exists() {
essential.extend(names_of(&path)?);
}
}
report.triage_candidates = candidates_of(&essential);
}
}
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
fn report(name: &str) -> ClosureReport {
ClosureReport {
name: name.to_string(),
..Default::default()
}
}
#[test]
fn a_skipped_closure_says_why_and_reports_nothing_else() {
let mut r = report("hyperscale");
r.skipped = Some("no graph yet".to_string());
r.new_keeps = vec!["nginx".to_string()];
let out = format_closure(&r);
assert_eq!(out, "closure hyperscale:\n skipped — no graph yet\n");
}
#[test]
fn a_closure_that_changed_nothing_says_so() {
let out = format_closure(&report("hyperscale"));
assert_eq!(out, "closure hyperscale:\n nothing changed\n");
}
#[test]
fn an_external_closure_is_marked_as_one() {
let mut r = report("rpmfusion");
r.external = true;
assert!(format_closure(&r).starts_with("closure rpmfusion (external):"));
}
#[test]
fn a_long_list_is_truncated_with_the_remainder_counted() {
let mut r = report("hyperscale");
r.closure_added = (0..15).map(|i| format!("pkg{i}")).collect();
let out = format_closure(&r);
assert!(out.contains("added to the closure inventory (15): pkg0, "));
assert!(out.contains("pkg11, … 3 more"), "{out}");
assert!(!out.contains("pkg12"), "the 13th entry should be elided");
let mut r = report("hyperscale");
r.closure_added = (0..12).map(|i| format!("pkg{i}")).collect();
let out = format_closure(&r);
assert!(out.contains("pkg11"));
assert!(!out.contains("more"), "{out}");
}
#[test]
fn the_capability_tally_agrees_with_itself_on_number() {
let mut r = report("hyperscale");
r.new_keeps = vec!["nginx".to_string()];
r.capabilities_offline = 1;
r.capabilities_online = 4;
assert!(
format_closure(&r).contains("1 capability answered from the graph, 4 resolved live")
);
r.capabilities_offline = 2;
assert!(format_closure(&r).contains("2 capabilities answered from the graph"));
}
#[test]
fn each_kind_of_change_gets_its_own_line() {
let mut r = report("hyperscale");
r.dropped_keeps = vec!["old".to_string()];
r.closure_removed = vec!["gone".to_string()];
r.closure_unreached = vec!["orphan".to_string()];
r.derived_added = vec!["new-derived".to_string()];
r.derived_removed = vec!["ex-derived".to_string()];
r.promoted = vec!["up".to_string()];
r.demoted = vec!["down".to_string()];
let out = format_closure(&r);
for expected in [
"former keeps nothing reaches now (1): old",
"left the closure inventory (1): gone",
"kept; a full walk settles them) (1): orphan",
"entered the derived inventory (1): new-derived",
"left the derived inventory (1): ex-derived",
"promoted to essential (1): up",
"demoted to derived (1): down",
] {
assert!(out.contains(expected), "missing {expected:?} in:\n{out}");
}
assert!(!out.contains("nothing changed"));
}
}