use crate::*;
#[derive(Default, Clone)]
pub(crate) struct FnInfo {
pub(crate) inferred: BTreeSet<String>,
pub(crate) direct: BTreeSet<String>,
pub(crate) calls: BTreeSet<String>,
}
pub(crate) fn load_fninfo(prefix: &str) -> BTreeMap<String, FnInfo> {
let mut out: BTreeMap<String, FnInfo> = BTreeMap::new();
for e in load_entries(prefix) {
let info = out.entry(e.func.clone()).or_default();
info.inferred.extend(e.inferred);
info.direct.extend(e.direct);
info.calls.extend(e.calls);
}
out
}
#[derive(Serialize, Clone)]
pub(crate) struct Change {
#[serde(rename = "fn")]
pub(crate) func: String,
pub(crate) gained: Vec<String>,
pub(crate) introduced: Vec<String>,
pub(crate) inherited: Vec<String>,
pub(crate) lost: Vec<String>,
pub(crate) status: String,
}
#[derive(Serialize)]
pub(crate) struct DiffJson<'a> {
pub(crate) baseline_version: &'a str,
pub(crate) engine_version: &'a str,
pub(crate) changes: Vec<Change>,
}
pub(crate) fn cmd_diff(args: &[String]) -> i32 {
if args.len() < 5 {
eprintln!("usage: candor-query diff <cur_prefix> <base_prefix> <0|1> <baseline_ver> <engine_ver>");
return 2;
}
let (cur_pre, base_pre, want_json, bver, ever) =
(&args[0], &args[1], args[2] == "1", args[3].as_str(), args[4].as_str());
for (which, pre) in [("current", cur_pre), ("baseline", base_pre)] {
if glob_reports(pre).is_empty() {
eprintln!("candor: no report files at {which} prefix `{pre}` — check the path.");
return 2;
}
}
let cur = load_fninfo(cur_pre);
let base = load_fninfo(base_pre);
let empty = BTreeSet::new();
let mut changes: Vec<Change> = Vec::new();
let keys: BTreeSet<&String> = cur.keys().chain(base.keys()).collect();
for fn_ in keys {
let ci = cur.get(fn_).map(|v| &v.inferred).unwrap_or(&empty);
let bi = base.get(fn_).map(|v| &v.inferred).unwrap_or(&empty);
if !cur.contains_key(fn_) {
if !bi.is_empty() {
changes.push(Change {
func: fn_.clone(),
gained: vec![],
introduced: vec![],
inherited: vec![],
lost: bi.iter().cloned().collect(),
status: "removed".into(),
});
}
continue;
}
let gained: Vec<String> = gained_effects(ci, bi);
let lost: Vec<String> =
if base.contains_key(fn_) { bi.difference(ci).cloned().collect() } else { vec![] };
if gained.is_empty() && lost.is_empty() {
continue;
}
let cd = &cur[fn_].direct;
let introduced: Vec<String> = gained.iter().filter(|e| cd.contains(*e)).cloned().collect();
let inherited: Vec<String> = gained.iter().filter(|e| !cd.contains(*e)).cloned().collect();
changes.push(Change {
func: fn_.clone(),
gained,
introduced,
inherited,
lost,
status: if base.contains_key(fn_) { "changed".into() } else { "new".into() },
});
}
if want_json {
changes.sort_by(|a, b| a.func.cmp(&b.func));
let out = DiffJson { baseline_version: bver, engine_version: ever, changes };
println!("{}", serde_json::to_string_pretty(&out).unwrap());
return 0;
}
if changes.is_empty() {
println!("candor: no effect changes vs {base_pre} (@{}).", q_or(bver));
return 0;
}
println!("candor diff — current (@{ever}) vs {base_pre} (@{})", q_or(bver));
if !bver.is_empty() && !ever.is_empty() && bver != ever {
println!(" ⚠ baseline @{bver} ≠ engine @{ever} — some changes may be the engine reclassifying, not your code.");
}
println!();
let calls_of: BTreeMap<&String, &BTreeSet<String>> =
cur.iter().map(|(fn_, v)| (fn_, &v.calls)).collect();
let sources: BTreeSet<String> =
changes.iter().filter(|c| !c.introduced.is_empty()).map(|c| c.func.clone()).collect();
let all_effects: BTreeSet<String> = changes.iter().flat_map(|c| c.gained.iter().cloned()).collect();
let mut top_level: BTreeSet<String> = BTreeSet::new();
let mut endpoints_of: BTreeMap<String, Vec<String>> = BTreeMap::new();
for e in &all_effects {
let gainers: BTreeSet<String> =
changes.iter().filter(|c| c.gained.contains(e)).map(|c| c.func.clone()).collect();
let mut called: BTreeSet<String> = BTreeSet::new();
for g in &gainers {
if let Some(cs) = calls_of.get(g) {
called.extend(cs.intersection(&gainers).cloned());
}
}
let tl: BTreeSet<String> = gainers.difference(&called).cloned().collect();
top_level.extend(tl.iter().cloned());
endpoints_of.insert(e.clone(), tl.difference(&sources).cloned().collect());
}
let consequential: BTreeSet<String> = sources.union(&top_level).cloned().collect();
changes.sort_by(|a, b| rank(a, &top_level).cmp(&rank(b, &top_level)).then_with(|| a.func.cmp(&b.func)));
let mut shown: BTreeSet<String> = BTreeSet::new();
for c in &changes {
if !consequential.contains(&c.func) && c.lost.is_empty() {
continue; }
shown.insert(c.func.clone());
let mut parts: Vec<String> = Vec::new();
parts.extend(c.introduced.iter().map(|e| format!("+{e}*")));
parts.extend(c.inherited.iter().map(|e| format!("+{e}")));
parts.extend(c.lost.iter().map(|e| format!("-{e}")));
let mark = if !c.gained.is_empty() { "+" } else { "-" };
let mut tags: Vec<&str> = Vec::new();
if c.status == "new" {
tags.push("new fn");
}
if c.status == "removed" {
tags.push("removed fn");
}
if top_level.contains(&c.func) && !sources.contains(&c.func) {
tags.push("top-level");
}
if c.gained.iter().any(|e| e == "Unknown") {
tags.push("⚠ now unresolvable");
}
let tag = if tags.is_empty() { String::new() } else { format!(" ({})", tags.join(", ")) };
println!(" {mark} {}{tag} {{ {} }}", c.func, parts.join(" "));
}
let mut hidden: Vec<String> =
changes.iter().filter(|c| !shown.contains(&c.func)).map(|c| c.func.clone()).collect();
hidden.sort();
if !hidden.is_empty() {
let head: Vec<String> = hidden.iter().take(4).cloned().collect();
let names = if hidden.len() > 4 {
format!("{}, +{} more", head.join(", "), hidden.len() - 4)
} else {
head.join(", ")
};
println!(" … {} intermediate caller(s) also inherit it: {names}", hidden.len());
}
println!();
println!(" * = introduced here; (top-level) = where the effect surfaces (an entry point / public API).");
for e in &all_effects {
let mut srcs: Vec<String> =
changes.iter().filter(|c| c.introduced.contains(e)).map(|c| c.func.clone()).collect();
srcs.sort();
let gainers: BTreeSet<String> =
changes.iter().filter(|c| c.gained.contains(e)).map(|c| c.func.clone()).collect();
let eps = endpoints_of.get(e).cloned().unwrap_or_default();
if !srcs.is_empty() {
let reach = if !eps.is_empty() { format!(" → reaches {}", eps.join(", ")) } else { String::new() };
let extra = gainers.len() as i64 - srcs.len() as i64 - eps.len() as i64;
let tail = if extra > 0 {
format!(" (+{extra} intermediate)")
} else if eps.is_empty() {
" (stays local)".to_string()
} else {
String::new()
};
println!(" {e}: introduced in {}{reach}{tail}", srcs.join(", "));
} else if !gainers.is_empty() {
let names = if !eps.is_empty() { eps.join(", ") } else { gainers.iter().cloned().collect::<Vec<_>>().join(", ") };
println!(" {e}: reaches {names} (source outside this crate/baseline)");
}
}
if changes.iter().any(|c| c.gained.iter().any(|e| e == "Unknown")) {
println!(" ⚠ a new Unknown means candor can no longer prove that function's effect set is complete — review it.");
}
0
}
pub(crate) fn rank(c: &Change, top_level: &BTreeSet<String>) -> u8 {
if !c.introduced.is_empty() {
0
} else if top_level.contains(&c.func) {
1
} else if !c.gained.is_empty() {
2
} else {
3
}
}
pub(crate) fn cmd_receipt(args: &[String]) -> i32 {
let Some(pre) = args.first().map(String::as_str) else {
eprintln!("usage: candor-query receipt <prefix>");
return 2;
};
let base = prefix_base(pre);
let fns = match load_entries_loud(pre) {
Ok(v) => v,
Err(c) => return c,
};
let (tally, unresolved) = tally_effects(&fns);
let unresolved = unresolved.len();
const ORDER: [&str; 10] =
["Db", "Net", "Fs", "Exec", "Env", "Clock", "Ipc", "Rand", "Clipboard", "Log"];
debug_assert_eq!(
ORDER.iter().copied().collect::<BTreeSet<_>>(),
EFFECTS.iter().copied().collect::<BTreeSet<_>>(),
"receipt ORDER must be a permutation of candor_report::EFFECTS",
);
let effects = ORDER
.iter()
.filter(|k| tally.get(**k).copied().unwrap_or(0) > 0)
.map(|k| format!("{} {k}", tally[*k]))
.collect::<Vec<_>>()
.join(", ");
let (mut calib_c, calib_p, calib_path) = load_calibrated(pre, &base);
calib_c.extend(calib_path); let encountered = encountered_set(pre);
let join = |s: &BTreeSet<String>| s.iter().cloned().collect::<Vec<_>>().join(" ");
println!("fns\t{}", fns.len());
println!("effects\t{effects}");
println!("unresolved\t{unresolved}");
println!("calibrated\t{}|{}", join(&calib_c), join(&calib_p));
println!("encountered\t{}", join(&encountered));
0
}
pub(crate) fn cmd_gains(args: &[String]) -> i32 {
let (cur_pre, base_pre) = match args {
[a, b, ..] => (a.as_str(), b.as_str()),
_ => {
eprintln!("usage: candor-query gains <cur_prefix> <base_prefix> [--json]");
return 2;
}
};
for (which, pre) in [("current", cur_pre), ("baseline", base_pre)] {
if glob_reports(pre).is_empty() {
eprintln!("candor: no report files at {which} prefix `{pre}` — check the path.");
return 2;
}
}
let cur = load_fninfo(cur_pre);
let base = load_fninfo(base_pre);
let empty = BTreeSet::new();
let mut out: Vec<(String, String)> = Vec::new();
for (func, info) in &cur {
let b = base.get(func).map(|i| &i.inferred).unwrap_or(&empty);
for e in gained_effects(&info.inferred, b) {
out.push((func.clone(), e));
}
}
out.sort();
if args.iter().any(|a| a == "--json" || a == "1") {
let mut gained: Vec<&str> = out.iter().map(|(_, e)| e.as_str()).collect();
gained.sort_unstable();
gained.dedup();
let by_function: Vec<_> = out
.iter()
.map(|(f, e)| serde_json::json!({ "fn": f, "effect": e }))
.collect();
let v = serde_json::json!({ "gained": gained, "byFunction": by_function });
println!("{}", serde_json::to_string_pretty(&v).unwrap());
return 0;
}
for (func, e) in out {
println!("{func}\t{e}");
}
0
}