use crate::graph;
use crate::query;
use super::blocks::rle;
use super::markers::{compact_marker, heuristic_suffix, seed_kind_str, INFRA_SUFFIX};
pub fn render_impact_text(query: &str, model: &query::ImpactModel) -> String {
let mut out: Vec<String> = Vec::new();
let joined = model.seed_files.join(", ");
let seeds = if joined.is_empty() {
"-".to_string()
} else {
joined
};
out.push(format!(
"impact: {query} ({}, seed files: {seeds}) hops<={}",
seed_kind_str(model.kind),
model.hops
));
let heuristic_note = if model.heuristic_affected != 0 {
format!(" (+{} heuristic)", model.heuristic_affected)
} else {
String::new()
};
let tests_note = if model.tests_affected != 0 {
format!(" tests={}", model.tests_affected)
} else {
String::new()
};
out.push(format!(
"affected files: {}{heuristic_note} shown: {} dropped: {}{tests_note}",
model.total_affected,
model.rows.len(),
model.dropped
));
out.push("file hops via top-symbols".to_string());
for r in &model.rows {
let mut syms = r.top_symbols.join(", ");
if r.top_symbols_more != 0 {
syms.push_str(&format!(" +{}", r.top_symbols_more));
}
let via = if r.heuristic {
r.heuristic_count.to_string()
} else if r.ambiguous_count != 0 {
format!("{}(+{}amb)", r.via_count, r.ambiguous_count)
} else {
r.via_count.to_string()
};
let iface_via = if r.iface_via.is_empty() {
String::new()
} else {
format!(" via {}", r.iface_via.join(", "))
};
let class_suffix = if r.infra { INFRA_SUFFIX } else { "" };
out.push(format!(
"{} {} {via} {syms}{iface_via}{}{class_suffix}",
r.file,
r.hop,
heuristic_suffix(r.heuristic, r.tier)
));
}
if !model.braked.is_empty() {
let list = model
.braked
.iter()
.map(|b| format!("{} (fan-in {})", b.iface, b.fanin))
.collect::<Vec<_>>()
.join(", ");
out.push(format!("braked: {list} — raise --iface-max-fanin to widen"));
}
if !model.braked_files.is_empty() {
let list = model
.braked_files
.iter()
.map(|b| format!("{} (in-degree {})", b.file, b.indegree))
.collect::<Vec<_>>()
.join(", ");
out.push(format!(
"braked: {list} — raise --hub-max-indegree to widen"
));
}
if model.manifest_gap != 0 {
out.push(format!(
"manifest gap: {} graph file(s) not in manifest",
model.manifest_gap
));
}
out.join("\n")
}
pub fn render_impact_compact(query: &str, model: &query::ImpactModel) -> String {
let mut out: Vec<String> = Vec::new();
let joined = model.seed_files.join(", ");
let seeds = if joined.is_empty() {
"-".to_string()
} else {
joined
};
out.push(format!(
"impact: {query} ({}, seed: {seeds}) hops<={}",
seed_kind_str(model.kind),
model.hops
));
let mut by_hop: std::collections::HashMap<u32, Vec<&query::ImpactRow>> =
std::collections::HashMap::new();
for r in &model.rows {
by_hop.entry(r.hop).or_default().push(r);
}
let mut hops: Vec<u32> = by_hop.keys().copied().collect();
hops.sort_unstable();
for hop in hops {
let rows = &by_hop[&hop];
out.push(format!("hop {hop} ({}):", rows.len()));
let lines: Vec<String> = rows
.iter()
.map(|r| {
if r.heuristic {
return format!(
"{} via={}{}",
r.file,
r.heuristic_count,
compact_marker(r.heuristic, r.tier)
);
}
let via = if r.ambiguous_count != 0 {
format!("{}(+{}amb)", r.via_count, r.ambiguous_count)
} else {
r.via_count.to_string()
};
let iface_suffix = if r.iface_via.is_empty() {
String::new()
} else {
format!(" iface={}", r.iface_via.join(","))
};
let class_suffix = if r.infra { " class=infra" } else { "" };
format!("{} via={via}{iface_suffix}{class_suffix}", r.file)
})
.collect();
for line in rle(&lines) {
out.push(format!(" {line}"));
}
}
let ambiguous: u32 = model.rows.iter().map(|r| r.ambiguous_count).sum();
let gap = if model.manifest_gap != 0 {
format!(" gap={}", model.manifest_gap)
} else {
String::new()
};
let heur = if model.heuristic_affected != 0 {
format!(" heuristic={}", model.heuristic_affected)
} else {
String::new()
};
let tests = if model.tests_affected != 0 {
format!(" tests={}", model.tests_affected)
} else {
String::new()
};
let braked = if model.braked.is_empty() && model.braked_files.is_empty() {
String::new()
} else {
let terms: Vec<String> = model
.braked
.iter()
.map(|b| format!("{}:{}", b.iface, b.fanin))
.chain(
model
.braked_files
.iter()
.map(|b| format!("{}:{}", b.file, b.indegree)),
)
.collect();
format!(" braked={}", terms.join(","))
};
out.push(format!(
"summary: affected={} shown={} dropped={} ambiguous={ambiguous}{heur}{tests}{gap}{braked}",
model.total_affected,
model.rows.len(),
model.dropped
));
out.join("\n")
}
fn render_imported_block(
imported: &query::ImportedSection,
provenance: &graph::Provenance,
) -> String {
let mut out = vec![format!(
"imported: {} reached shown: {} dropped: {} provenance {}",
imported.affected,
imported.rows.len(),
imported.dropped,
provenance.id
)];
out.push("file hop repo via".to_string());
for r in &imported.rows {
out.push(format!(
"{} {} {} {}",
r.file, r.hop, r.repo, r.imported_kind
));
}
out.join("\n")
}
pub fn render_impact_text_with_imports(
query: &str,
model: &query::ImpactModel,
imported: &query::ImportedSection,
provenance: &graph::Provenance,
) -> String {
let mut out = render_impact_text(query, model);
if !imported.rows.is_empty() {
out.push('\n');
out.push_str(&render_imported_block(imported, provenance));
}
out
}
pub fn render_impact_compact_with_imports(
query: &str,
model: &query::ImpactModel,
imported: &query::ImportedSection,
provenance: &graph::Provenance,
) -> String {
let mut out = render_impact_compact(query, model);
if !imported.rows.is_empty() {
out.push('\n');
out.push_str(&render_imported_block(imported, provenance));
}
out
}