use std::collections::BTreeSet;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use crate::knowledge::query::{load_preferred_merged, KnowledgeView};
use crate::release::doctor::{blast_radius, RepoGraph};
use crate::warehouse::iceberg::IcebergWarehouse;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChangeKind {
Changed,
Removed,
}
impl ChangeKind {
fn glyph(self) -> &'static str {
match self {
ChangeKind::Changed => "~",
ChangeKind::Removed => "✗",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangedSymbol {
pub name: String,
pub change: ChangeKind,
}
impl ChangedSymbol {
pub fn removed(name: impl Into<String>) -> Self {
ChangedSymbol { name: name.into(), change: ChangeKind::Removed }
}
pub fn changed(name: impl Into<String>) -> Self {
ChangedSymbol { name: name.into(), change: ChangeKind::Changed }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CallSite {
pub crate_name: String,
pub caller: String,
pub callee: String,
pub call_kind: String,
pub file: String,
pub line: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SymbolImpact {
pub symbol: String,
pub change: ChangeKind,
pub call_sites: Vec<CallSite>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SemanticBlast {
pub target_repo: String,
pub target_crates: Vec<String>,
pub source: String,
pub impacts: Vec<SymbolImpact>,
pub unused_changes: Vec<ChangedSymbol>,
pub total_call_sites: usize,
pub affected_crates: Vec<String>,
}
impl SemanticBlast {
pub fn is_empty(&self) -> bool {
self.impacts.is_empty()
}
pub fn link_errors(&self) -> Vec<&SymbolImpact> {
self.impacts
.iter()
.filter(|i| i.change == ChangeKind::Removed && !i.call_sites.is_empty())
.collect()
}
pub fn has_link_error(&self) -> bool {
self.impacts
.iter()
.any(|i| i.change == ChangeKind::Removed && !i.call_sites.is_empty())
}
pub fn link_error_sites(&self) -> usize {
self.link_errors().iter().map(|i| i.call_sites.len()).sum()
}
}
pub fn diff_exported_symbols(
old: &[crate::knowledge::symbols::SymbolRow],
new: &[crate::knowledge::symbols::SymbolRow],
) -> Vec<ChangedSymbol> {
use std::collections::BTreeMap;
let is_pub = |v: &str| v == "pub";
let index = |rows: &[crate::knowledge::symbols::SymbolRow]| -> BTreeMap<String, Option<String>> {
rows.iter()
.filter(|r| is_pub(&r.visibility))
.map(|r| (r.item_name.clone(), r.signature.clone()))
.collect()
};
let old_idx = index(old);
let new_idx = index(new);
let mut out: Vec<ChangedSymbol> = Vec::new();
for (name, old_sig) in &old_idx {
match new_idx.get(name) {
None => out.push(ChangedSymbol::removed(name.clone())),
Some(new_sig) => {
if old_sig.is_some() && new_sig.is_some() && old_sig != new_sig {
out.push(ChangedSymbol::changed(name.clone()));
}
}
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
fn caller_crate(crate_name: &str, caller_path: &str) -> String {
if !crate_name.is_empty() {
return crate_name.to_string();
}
match caller_path.split_once("::") {
Some((head, _)) if !head.is_empty() => head.to_string(),
_ => caller_path.to_string(),
}
}
pub fn rank(
view: &KnowledgeView,
target_repo: &str,
target_crates: &BTreeSet<String>,
own_crates: &BTreeSet<String>,
changed: &[ChangedSymbol],
) -> SemanticBlast {
let mut impacts: Vec<SymbolImpact> = Vec::new();
let mut unused_changes: Vec<ChangedSymbol> = Vec::new();
let mut affected: BTreeSet<String> = BTreeSet::new();
for cs in changed {
let mut sites: Vec<CallSite> = view
.callers_of(&cs.name)
.into_iter()
.filter(|e| !own_crates.contains(&caller_crate(&e.crate_name, &e.caller_path)))
.map(|e| CallSite {
crate_name: caller_crate(&e.crate_name, &e.caller_path),
caller: e.caller_path.clone(),
callee: e.callee_ident.clone(),
call_kind: e.call_kind.clone(),
file: e.file.clone(),
line: e.line,
})
.collect();
if sites.is_empty() {
unused_changes.push(cs.clone());
continue;
}
sites.sort_by(|a, b| {
(&a.crate_name, &a.file, a.line).cmp(&(&b.crate_name, &b.file, b.line))
});
for s in &sites {
affected.insert(s.crate_name.clone());
}
impacts.push(SymbolImpact { symbol: cs.name.clone(), change: cs.change, call_sites: sites });
}
impacts.sort_by(|a, b| {
b.call_sites
.len()
.cmp(&a.call_sites.len())
.then(b.change.cmp(&a.change))
.then(a.symbol.cmp(&b.symbol))
});
let total_call_sites = impacts.iter().map(|i| i.call_sites.len()).sum();
SemanticBlast {
target_repo: target_repo.to_string(),
target_crates: target_crates.iter().cloned().collect(),
source: String::new(),
impacts,
unused_changes,
total_call_sites,
affected_crates: affected.into_iter().collect(),
}
}
pub fn compute(
wh: &IcebergWarehouse,
graphs: &[RepoGraph],
target_repo: &str,
changed: &[ChangedSymbol],
) -> Result<SemanticBlast> {
let own_crates: BTreeSet<String> = graphs
.iter()
.find(|g| g.repo == target_repo)
.map(|g| g.produces.clone())
.unwrap_or_default();
let dependents = blast_radius(graphs, target_repo);
if dependents.is_empty() {
let mut sb = SemanticBlast {
target_repo: target_repo.to_string(),
target_crates: own_crates.iter().cloned().collect(),
..Default::default()
};
sb.unused_changes = changed.to_vec();
return Ok(sb);
}
let (view, source) = load_preferred_merged(wh, &dependents)?;
let mut sb = rank(&view, target_repo, &own_crates, &own_crates, changed);
sb.source = source.to_string();
Ok(sb)
}
pub fn compute_for_dirty<'a>(
wh: &IcebergWarehouse,
graphs: &[RepoGraph],
changed_by_repo: impl IntoIterator<Item = (&'a str, &'a [ChangedSymbol])>,
) -> Result<std::collections::BTreeMap<String, SemanticBlast>> {
let mut out = std::collections::BTreeMap::new();
for (repo, changed) in changed_by_repo {
let sb = compute(wh, graphs, repo, changed)?;
if !sb.is_empty() {
out.insert(repo.to_string(), sb);
}
}
Ok(out)
}
pub fn format_semantic_blast(sb: &SemanticBlast) -> String {
let mut s = String::new();
if sb.is_empty() {
return s;
}
s.push_str(&format!(
" {} bump touches {} call site(s) in {} crate(s) [{}]:\n",
sb.target_repo,
sb.total_call_sites,
sb.affected_crates.len(),
if sb.source.is_empty() { "no-index" } else { &sb.source },
));
for imp in &sb.impacts {
s.push_str(&format!(
" {} {} ({} call site(s)):\n",
imp.change.glyph(),
imp.symbol,
imp.call_sites.len(),
));
for site in &imp.call_sites {
s.push_str(&format!(
" {}::{} — {}:{}\n",
site.crate_name, site.caller, site.file, site.line,
));
}
}
if !sb.unused_changes.is_empty() {
let names: Vec<&str> = sb.unused_changes.iter().map(|c| c.name.as_str()).collect();
s.push_str(&format!(" (no dependent call sites: {})\n", names.join(", ")));
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::knowledge::symbols::{CallEdgeRow, SymbolRow};
fn fixture() -> KnowledgeView {
let sym = |cr: &str, name: &str| SymbolRow {
crate_name: cr.into(),
module_path: format!("{cr}::api"),
item_kind: "fn".into(),
item_name: name.into(),
visibility: "pub".into(),
file: format!("{cr}/src/api.rs"),
line: 1,
doc_lines: 0,
signature: None,
};
let call = |cr: &str, caller: &str, callee: &str, file: &str, line: u32| CallEdgeRow {
crate_name: cr.into(),
caller_path: caller.into(),
callee_ident: callee.into(),
call_kind: "call".into(),
file: file.into(),
line,
};
let symbols = vec![
sym("facett", "render"),
sym("facett", "open"),
sym("facett", "secret"),
];
let calls = vec![
call("korp", "korp::view::draw", "facett::render", "korp/src/view.rs", 40),
call("korp", "korp::view::redraw", "render", "korp/src/view.rs", 88),
call("knut", "knut::ui::paint", "facett::render", "knut/src/ui.rs", 12),
call("nornir", "nornir::boot::run", "facett::open", "nornir/src/boot.rs", 7),
call("facett", "facett::api::render", "open", "facett/src/api.rs", 3),
call("korp", "korp::view::draw", "std::mem::swap", "korp/src/view.rs", 41),
];
KnowledgeView::new(symbols, calls)
}
fn own() -> BTreeSet<String> {
BTreeSet::from(["facett".to_string()])
}
#[test]
fn ranks_changed_symbols_by_real_call_site_count() {
let view = fixture();
let changed = [
ChangedSymbol::removed("render"),
ChangedSymbol::changed("open"),
ChangedSymbol::removed("secret"), ];
let sb = rank(&view, "facett", &own(), &own(), &changed);
let ranked: Vec<&str> = sb.impacts.iter().map(|i| i.symbol.as_str()).collect();
let correct_rank = ranked == ["render", "open"];
let render = &sb.impacts[0];
let self_call_excluded = render.call_sites.len() == 3
&& render.call_sites.iter().all(|s| s.crate_name != "facett");
let secret_unused = sb.unused_changes.iter().any(|c| c.name == "secret")
&& sb.impacts.iter().all(|i| i.symbol != "secret");
let totals = sb.total_call_sites == 4
&& sb.affected_crates == vec!["knut".to_string(), "korp".to_string(), "nornir".to_string()];
nornir_testmatrix::functional_status(
"semantic-blast",
"ranks_by_real_call_site_count",
correct_rank && self_call_excluded && secret_unused && totals,
&format!(
"rank={ranked:?} render_sites={} affected={:?} total={} unused={:?}",
render.call_sites.len(),
sb.affected_crates,
sb.total_call_sites,
sb.unused_changes.iter().map(|c| &c.name).collect::<Vec<_>>(),
),
);
assert!(correct_rank, "render (3 sites) must outrank open (1 site): {ranked:?}");
assert!(self_call_excluded, "the intra-facett self-call must be excluded from blast");
assert!(secret_unused, "an exported-but-uncalled changed symbol is `unused`, not an impact");
assert!(totals, "4 cross-crate call sites across knut/korp/nornir");
}
#[test]
fn suffix_and_bare_callees_both_resolve() {
let view = fixture();
let sb = rank(&view, "facett", &own(), &own(), &[ChangedSymbol::removed("render")]);
let render = &sb.impacts[0];
let has_qualified = render.call_sites.iter().any(|s| s.callee == "facett::render");
let has_bare = render.call_sites.iter().any(|s| s.callee == "render");
nornir_testmatrix::functional_status(
"semantic-blast",
"suffix_and_bare_callees_resolve",
has_qualified && has_bare,
&format!("callees={:?}", render.call_sites.iter().map(|s| &s.callee).collect::<Vec<_>>()),
);
assert!(has_qualified && has_bare, "reverse lookup matches both `facett::render` and bare `render`");
}
#[test]
fn no_call_sites_yields_empty_blast() {
let view = fixture();
let sb = rank(&view, "facett", &own(), &own(), &[ChangedSymbol::changed("nonexistent")]);
nornir_testmatrix::functional_status(
"semantic-blast",
"no_sites_is_empty",
sb.is_empty() && sb.total_call_sites == 0 && sb.unused_changes.len() == 1,
&format!("empty={} total={}", sb.is_empty(), sb.total_call_sites),
);
assert!(sb.is_empty(), "a symbol nobody calls produces an empty semantic blast");
}
#[test]
fn format_renders_headline_symbols_and_sites() {
let view = fixture();
let mut sb = rank(&view, "facett", &own(), &own(), &[ChangedSymbol::removed("render")]);
sb.source = "resolved/scip".to_string();
let text = format_semantic_blast(&sb);
assert!(text.contains("facett bump touches 3 call site(s) in 2 crate(s)"), "headline: {text}");
assert!(text.contains("[resolved/scip]"), "source tag surfaced");
assert!(text.contains("✗ render (3 call site(s))"), "removed glyph + count: {text}");
assert!(text.contains("korp/src/view.rs:40"), "a concrete call site is listed");
let empty = rank(&view, "facett", &own(), &own(), &[ChangedSymbol::changed("nope")]);
assert!(format_semantic_blast(&empty).is_empty(), "empty blast renders nothing");
}
#[test]
fn own_crates_filter_excludes_internal_callers() {
let view = fixture();
let own = BTreeSet::from(["facett".to_string(), "korp".to_string()]);
let sb = rank(&view, "facett", &BTreeSet::from(["facett".to_string()]), &own, &[ChangedSymbol::removed("render")]);
let render = &sb.impacts[0];
assert!(render.call_sites.iter().all(|s| s.crate_name != "korp"), "korp calls filtered out");
assert_eq!(render.call_sites.len(), 1, "only knut's cross-crate site survives");
assert_eq!(render.call_sites[0].crate_name, "knut");
}
#[test]
fn link_errors_flags_removed_with_live_call_sites_only() {
let view = fixture();
let sb = rank(
&view,
"facett",
&own(),
&own(),
&[ChangedSymbol::removed("render"), ChangedSymbol::changed("open")],
);
let errs = sb.link_errors();
let one_link_error = errs.len() == 1 && errs[0].symbol == "render";
let counts = sb.has_link_error() && sb.link_error_sites() == 3;
let changed_not_error = sb.impacts.iter().any(|i| i.symbol == "open")
&& errs.iter().all(|i| i.symbol != "open");
nornir_testmatrix::functional_status(
"semantic-blast",
"link_errors_only_removed",
one_link_error && counts && changed_not_error,
&format!(
"errors={:?} sites={} has={}",
errs.iter().map(|i| &i.symbol).collect::<Vec<_>>(),
sb.link_error_sites(),
sb.has_link_error(),
),
);
assert!(one_link_error, "only the removed `render` is a link error: {errs:?}");
assert!(counts, "3 broken call sites, has_link_error=true");
assert!(changed_not_error, "a still-resolving Changed symbol is not a link error");
}
#[test]
fn diff_exported_symbols_detects_removed_changed_ignores_additions_and_private() {
use crate::knowledge::symbols::SymbolRow;
let s = |name: &str, vis: &str, sig: Option<&str>| SymbolRow {
crate_name: "facett".into(),
module_path: "facett::api".into(),
item_kind: "fn".into(),
item_name: name.into(),
visibility: vis.into(),
file: "facett/src/api.rs".into(),
line: 1,
doc_lines: 0,
signature: sig.map(|x| x.into()),
};
let old = vec![
s("render", "pub", Some("fn render(&self)")),
s("open", "pub", Some("fn open() -> Self")),
s("secret", "pub", Some("fn secret()")),
s("internal", "pub(crate)", Some("fn internal()")),
];
let new = vec![
s("open", "pub", Some("fn open(path: &str) -> Self")), s("secret", "pub", Some("fn secret()")), s("added", "pub", Some("fn added()")), s("internal", "pub(crate)", Some("fn internal(x: u32)")), ];
let delta = diff_exported_symbols(&old, &new);
let by = |n: &str| delta.iter().find(|c| c.name == n).map(|c| c.change);
let ok = by("render") == Some(ChangeKind::Removed)
&& by("open") == Some(ChangeKind::Changed)
&& by("secret").is_none()
&& by("added").is_none()
&& by("internal").is_none();
nornir_testmatrix::functional_status(
"semantic-blast",
"diff_exported_symbols",
ok,
&format!("delta={:?}", delta.iter().map(|c| (&c.name, c.change)).collect::<Vec<_>>()),
);
assert_eq!(by("render"), Some(ChangeKind::Removed), "dropped pub symbol is removed");
assert_eq!(by("open"), Some(ChangeKind::Changed), "signature change is changed");
assert!(by("secret").is_none(), "unchanged symbol is not in the delta");
assert!(by("added").is_none(), "a pure addition breaks nobody → not in the delta");
assert!(by("internal").is_none(), "a pub(crate) symbol is not cross-crate surface");
}
}
#[cfg(test)]
mod warehouse_tests {
use super::*;
use crate::knowledge::symbols::{CallEdgeRow, SymbolRow, SymbolScan};
use crate::warehouse::iceberg::IcebergWarehouse;
use std::collections::BTreeSet;
fn sym(cr: &str, name: &str) -> SymbolRow {
SymbolRow {
crate_name: cr.into(),
module_path: format!("{cr}::api"),
item_kind: "fn".into(),
item_name: name.into(),
visibility: "pub".into(),
file: format!("{cr}/src/api.rs"),
line: 1,
doc_lines: 0,
signature: None,
}
}
fn call(cr: &str, caller: &str, callee: &str, file: &str, line: u32) -> CallEdgeRow {
CallEdgeRow {
crate_name: cr.into(),
caller_path: caller.into(),
callee_ident: callee.into(),
call_kind: "call".into(),
file: file.into(),
line,
}
}
fn seed(wh: &IcebergWarehouse, repo: &str, calls: Vec<CallEdgeRow>) {
let scan = SymbolScan {
snapshot_id: uuid::Uuid::new_v4(),
ts: chrono::Utc::now(),
repo: repo.to_string(),
symbols: vec![sym(repo, "marker")],
calls,
features: vec![],
tests: vec![],
};
wh.append_symbol_scan(&scan).unwrap();
}
fn graphs() -> Vec<RepoGraph> {
let g = |repo: &str, produces: &str, deps: &[&str]| RepoGraph {
repo: repo.into(),
produces: BTreeSet::from([produces.to_string()]),
deps: deps.iter().map(|s| s.to_string()).collect(),
..Default::default()
};
vec![
g("facett", "facett", &[]),
g("korp", "korp", &["facett"]),
g("knut", "knut", &["facett"]),
g("nornir", "nornir", &["facett"]),
]
}
fn seeded_wh(dir: &std::path::Path) -> IcebergWarehouse {
let wh = IcebergWarehouse::open(dir).unwrap();
seed(&wh, "korp", vec![
call("korp", "korp::view::draw", "facett::render", "korp/src/view.rs", 40),
call("korp", "korp::view::redraw", "render", "korp/src/view.rs", 88),
call("korp", "korp::view::draw", "std::mem::swap", "korp/src/view.rs", 41),
]);
seed(&wh, "knut", vec![
call("knut", "knut::ui::paint", "facett::render", "knut/src/ui.rs", 12),
]);
seed(&wh, "nornir", vec![
call("nornir", "nornir::boot::run", "facett::open", "nornir/src/boot.rs", 7),
]);
seed(&wh, "facett", vec![
call("facett", "facett::api::render", "open", "facett/src/api.rs", 3),
]);
wh
}
#[test]
fn compute_attaches_exact_call_sites_from_warehouse() {
let dir = tempfile::tempdir().unwrap();
let wh = seeded_wh(dir.path());
let graphs = graphs();
let changed = [
ChangedSymbol::removed("render"),
ChangedSymbol::changed("open"),
ChangedSymbol::removed("secret"),
];
let sb = compute(&wh, &graphs, "facett", &changed).unwrap();
assert_eq!(sb.source, "syn", "the syn read path answered the call-graph query");
assert_eq!(sb.target_repo, "facett");
assert_eq!(sb.target_crates, vec!["facett".to_string()]);
let ranked: Vec<&str> = sb.impacts.iter().map(|i| i.symbol.as_str()).collect();
assert_eq!(ranked, ["render", "open"], "render (3 sites) outranks open (1): {ranked:?}");
let render = &sb.impacts[0];
assert_eq!(render.call_sites.len(), 3, "3 cross-crate render sites (korp×2, knut×1)");
assert!(render.call_sites.iter().all(|s| s.crate_name != "facett"), "self-call excluded");
assert!(render.call_sites.iter().any(|s|
s.crate_name == "knut" && s.file == "knut/src/ui.rs" && s.line == 12));
assert!(render.call_sites.iter().any(|s|
s.crate_name == "korp" && s.file == "korp/src/view.rs" && s.line == 40));
assert_eq!(sb.total_call_sites, 4);
assert_eq!(
sb.affected_crates,
vec!["knut".to_string(), "korp".to_string(), "nornir".to_string()],
);
assert!(sb.unused_changes.iter().any(|c| c.name == "secret"), "secret is unused");
}
#[test]
fn compute_is_empty_for_internal_only_change() {
let dir = tempfile::tempdir().unwrap();
let wh = seeded_wh(dir.path());
let sb = compute(&wh, &graphs(), "facett", &[ChangedSymbol::changed("secret")]).unwrap();
assert!(sb.is_empty(), "no dependent calls `secret` → empty blast");
assert_eq!(sb.total_call_sites, 0);
assert_eq!(sb.unused_changes.len(), 1);
assert_eq!(sb.unused_changes[0].name, "secret");
}
#[test]
fn compute_for_dirty_keeps_only_impacted_repos() {
let dir = tempfile::tempdir().unwrap();
let wh = seeded_wh(dir.path());
let graphs = graphs();
let render_change = [ChangedSymbol::removed("render")];
let secret_change = [ChangedSymbol::changed("secret")];
let changed_by_repo: Vec<(&str, &[ChangedSymbol])> = vec![
("facett", &render_change),
("korp", &secret_change), ];
let map = compute_for_dirty(&wh, &graphs, changed_by_repo).unwrap();
assert!(map.contains_key("facett"), "facett's render bump is surfaced");
assert!(!map.contains_key("korp"), "an internal-only bump is not surfaced");
assert_eq!(map["facett"].total_call_sites, 3);
}
#[test]
fn compute_no_dependents_is_empty_with_unused_changes() {
let dir = tempfile::tempdir().unwrap();
let wh = seeded_wh(dir.path());
let sb = compute(&wh, &graphs(), "nornir", &[ChangedSymbol::removed("boot")]).unwrap();
assert!(sb.is_empty());
assert_eq!(sb.unused_changes.len(), 1);
assert_eq!(sb.unused_changes[0].name, "boot");
}
}