use crate::query;
const HEURISTIC_SUFFIX: &str = " (heuristic)";
const INFRA_SUFFIX: &str = " (infra)";
fn heuristic_suffix(heuristic: bool) -> &'static str {
if heuristic {
HEURISTIC_SUFFIX
} else {
""
}
}
fn source_suffix(source: &str) -> String {
if source.is_empty() {
String::new()
} else {
format!(" {source}")
}
}
pub fn seed_kind_str(kind: query::SeedKind) -> &'static str {
match kind {
query::SeedKind::File => "file",
query::SeedKind::Symbol => "symbol",
}
}
fn rle(values: &[String]) -> Vec<String> {
let mut out = Vec::new();
let mut i = 0;
while i < values.len() {
let mut j = i + 1;
while j < values.len() && values[j] == values[i] {
j += 1;
}
if j - i > 1 {
out.push(format!("{}x{}", values[i], j - i));
} else {
out.push(values[i].clone());
}
i = j;
}
out
}
fn group_by_file<R>(
rows: &[R],
file_of: fn(&R) -> &str,
line_fmt: impl Fn(&R) -> String,
) -> Vec<String> {
let mut out = Vec::new();
let mut i = 0;
while i < rows.len() {
let mut j = i + 1;
while j < rows.len() && file_of(&rows[j]) == file_of(&rows[i]) {
j += 1;
}
let lines: Vec<String> = rows[i..j].iter().map(|r| line_fmt(r)).collect();
out.push(format!("{}:{}", file_of(&rows[i]), rle(&lines).join(",")));
i = j;
}
out
}
fn ref_kind_block<R>(
out: &mut Vec<String>,
label: &str,
table: Option<&query::Table<R>>,
row_fmt: impl Fn(&R) -> String,
) {
let Some(t) = table else { return };
let dropped_note = if t.dropped != 0 {
format!(", {} dropped", t.dropped)
} else {
String::new()
};
out.push(format!(" {label} ({}{dropped_note}):", t.total));
for r in &t.rows {
out.push(format!(" {}", row_fmt(r)));
}
}
fn compact_block<R>(
out: &mut Vec<String>,
label: &str,
table: Option<&query::Table<R>>,
file_of: fn(&R) -> &str,
line_fmt: impl Fn(&R) -> String,
) {
let Some(t) = table else { return };
if t.total == 0 {
return;
}
let dropped_note = if t.dropped != 0 {
format!(", {} dropped", t.dropped)
} else {
String::new()
};
out.push(format!("{label} ({}{dropped_note}):", t.total));
for line in group_by_file(&t.rows, file_of, line_fmt) {
out.push(format!(" {line}"));
}
}
fn member_refs_line(m: &query::MemberRefs) -> String {
let named = m
.members
.iter()
.map(|e| format!("{} {}", e.name, e.count))
.collect::<Vec<_>>()
.join(", ");
let more = if m.dropped != 0 {
format!(" +{} more", m.dropped)
} else {
String::new()
};
format!(
"member refs: {} across {} member(s): {named}{more}",
m.total, m.member_count
)
}
pub fn render_refs_text(model: &query::RefsModel) -> String {
let mut out: Vec<String> = Vec::new();
out.push(format!("{} ({})", model.id, model.kind));
out.push(format!(
"def: {}",
model
.sites
.iter()
.map(|s| format!("{}:{}", s.file, s.line))
.collect::<Vec<_>>()
.join(" ")
));
out.push("inbound:".to_string());
ref_kind_block(
&mut out,
"inherits",
Some(&model.inbound.inherits),
|r: &query::InboundRow| {
format!(
"{}:{} inherits{}{}",
r.file,
r.line,
heuristic_suffix(r.heuristic),
source_suffix(&r.source)
)
},
);
ref_kind_block(
&mut out,
"uses-type",
Some(&model.inbound.uses_type),
|r: &query::InboundRow| {
format!(
"{}:{} uses-type{}{}",
r.file,
r.line,
heuristic_suffix(r.heuristic),
source_suffix(&r.source)
)
},
);
ref_kind_block(
&mut out,
"uses-member",
Some(&model.inbound.uses_member),
|r: &query::InboundRow| {
format!(
"{}:{} uses-member{}{}",
r.file,
r.line,
heuristic_suffix(r.heuristic),
source_suffix(&r.source)
)
},
);
let inbound_dropped = model.inbound.inherits.dropped
+ model.inbound.uses_type.dropped
+ model.inbound.uses_member.dropped;
if inbound_dropped != 0 {
out.push(format!(" +{inbound_dropped} more"));
}
if let Some(m) = &model.member_refs {
out.push(member_refs_line(m));
}
if let Some(ob) = &model.outbound {
out.push("outbound:".to_string());
ref_kind_block(
&mut out,
"inherits",
Some(&ob.inherits),
|r: &query::OutboundRow| {
format!(
"{}:{} inherits -> {}{}{}",
r.file,
r.line,
r.to_file,
heuristic_suffix(r.heuristic),
source_suffix(&r.source)
)
},
);
ref_kind_block(
&mut out,
"uses-type",
Some(&ob.uses_type),
|r: &query::OutboundRow| {
format!(
"{}:{} uses-type -> {}{}{}",
r.file,
r.line,
r.to_file,
heuristic_suffix(r.heuristic),
source_suffix(&r.source)
)
},
);
ref_kind_block(
&mut out,
"uses-member",
Some(&ob.uses_member),
|r: &query::OutboundRow| {
format!(
"{}:{} uses-member -> {}{}{}",
r.file,
r.line,
r.to_file,
heuristic_suffix(r.heuristic),
source_suffix(&r.source)
)
},
);
ref_kind_block(
&mut out,
"imports",
Some(&ob.imports),
|r: &query::ImportRow| {
format!(
"{}:{} imports -> {}{}",
r.file,
r.line,
r.target,
source_suffix(&r.source)
)
},
);
let outbound_dropped = ob.inherits.dropped
+ ob.uses_type.dropped
+ ob.uses_member.dropped
+ ob.imports.dropped;
if outbound_dropped != 0 {
out.push(format!(" +{outbound_dropped} more, use --all"));
}
}
let amb_in = &model.ambiguous.inbound;
let amb_out = &model.ambiguous.outbound;
if amb_in.total != 0 || amb_out.total != 0 {
out.push("ambiguous:".to_string());
let amb_row = |r: &query::AmbiguousRow| {
format!(
"{}:{} {} raw=\"{}\" candidates={}",
r.file, r.line, r.origin, r.raw, r.candidate_count
)
};
if amb_in.total != 0 {
ref_kind_block(&mut out, "inbound", Some(amb_in), amb_row);
}
if amb_out.total != 0 {
ref_kind_block(&mut out, "outbound", Some(amb_out), amb_row);
}
}
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_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)
));
}
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_refs_compact(model: &query::RefsModel) -> String {
let mut out: Vec<String> = Vec::new();
out.push(format!("{} ({})", model.id, model.kind));
out.push(format!(
"def: {}",
model
.sites
.iter()
.map(|s| format!("{}:{}", s.file, s.line))
.collect::<Vec<_>>()
.join(" ")
));
let file_of_ib: fn(&query::InboundRow) -> &str = |r| r.file.as_str();
let line_ib = |r: &query::InboundRow| {
if r.heuristic {
format!("{}h", r.line)
} else {
r.line.to_string()
}
};
compact_block(
&mut out,
"in:inherits",
Some(&model.inbound.inherits),
file_of_ib,
line_ib,
);
compact_block(
&mut out,
"in:uses-type",
Some(&model.inbound.uses_type),
file_of_ib,
line_ib,
);
compact_block(
&mut out,
"in:uses-member",
Some(&model.inbound.uses_member),
file_of_ib,
line_ib,
);
if let Some(ob) = &model.outbound {
let file_of_ob: fn(&query::OutboundRow) -> &str = |r| r.file.as_str();
let line_ob = |r: &query::OutboundRow| {
if r.heuristic {
format!("{}h", r.line)
} else {
r.line.to_string()
}
};
compact_block(
&mut out,
"out:inherits",
Some(&ob.inherits),
file_of_ob,
line_ob,
);
compact_block(
&mut out,
"out:uses-type",
Some(&ob.uses_type),
file_of_ob,
line_ob,
);
compact_block(
&mut out,
"out:uses-member",
Some(&ob.uses_member),
file_of_ob,
line_ob,
);
let file_of_imp: fn(&query::ImportRow) -> &str = |r| r.file.as_str();
let line_imp = |r: &query::ImportRow| r.line.to_string();
compact_block(
&mut out,
"out:imports",
Some(&ob.imports),
file_of_imp,
line_imp,
);
}
let amb_in = &model.ambiguous.inbound;
let amb_out = &model.ambiguous.outbound;
let file_of_amb: fn(&query::AmbiguousRow) -> &str = |r| r.file.as_str();
let line_amb =
|r: &query::AmbiguousRow| format!("{}(candidates={})", r.line, r.candidate_count);
compact_block(&mut out, "amb:in", Some(amb_in), file_of_amb, line_amb);
compact_block(&mut out, "amb:out", Some(amb_out), file_of_amb, line_amb);
let ob = model.outbound.as_ref();
let ob_sum = |f: fn(&query::OutboundTables) -> usize| ob.map_or(0, f);
let edges = model.inbound.inherits.total
+ model.inbound.uses_type.total
+ model.inbound.uses_member.total
+ ob_sum(|o| o.inherits.total + o.uses_type.total + o.uses_member.total + o.imports.total);
let shown = model.inbound.inherits.rows.len()
+ model.inbound.uses_type.rows.len()
+ model.inbound.uses_member.rows.len()
+ ob_sum(|o| {
o.inherits.rows.len()
+ o.uses_type.rows.len()
+ o.uses_member.rows.len()
+ o.imports.rows.len()
});
let dropped = model.inbound.inherits.dropped
+ model.inbound.uses_type.dropped
+ model.inbound.uses_member.dropped
+ ob_sum(|o| {
o.inherits.dropped + o.uses_type.dropped + o.uses_member.dropped + o.imports.dropped
});
let ambiguous = amb_in.total + amb_out.total;
let gap = if model.manifest_gap != 0 {
format!(" gap={}", model.manifest_gap)
} else {
String::new()
};
if let Some(m) = &model.member_refs {
let named = m
.members
.iter()
.map(|e| format!("{}={}", e.name, e.count))
.collect::<Vec<_>>()
.join(",");
let more = if m.dropped != 0 {
format!(",+{}", m.dropped)
} else {
String::new()
};
out.push(format!("mem: {named}{more}"));
}
out.push(format!(
"summary: edges={edges} shown={shown} dropped={dropped} ambiguous={ambiguous}{gap}"
));
out.join("\n")
}
fn read_def_line(model: &query::ReadModel) -> String {
let sites = &model.refs.sites;
if sites.is_empty() {
return "def: -".to_string();
}
let rendered: Vec<String> = sites
.iter()
.enumerate()
.map(|(i, s)| match (i, &model.span) {
(0, Some(sp)) => format!("{}:{}-{}", sp.file, sp.start_line, sp.end_line),
_ => format!("{}:{}", s.file, s.line),
})
.collect();
format!("def: {}", rendered.join(" "))
}
pub fn render_read_text(model: &query::ReadModel) -> String {
let rendered = render_refs_text(&model.refs);
let mut out: Vec<String> = rendered.split('\n').map(str::to_string).collect();
if out.len() > 1 {
out[1] = read_def_line(model);
}
if let Some(sp) = &model.span {
out.splice(2..2, sp.source.split('\n').map(str::to_string));
}
out.join("\n")
}
pub fn render_read_compact(model: &query::ReadModel) -> String {
let rendered = render_refs_compact(&model.refs);
let mut out: Vec<String> = rendered.split('\n').map(str::to_string).collect();
if out.len() > 1 {
out[1] = read_def_line(model);
}
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={}h", r.file, r.heuristic_count);
}
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")
}
pub fn render_tests_text(model: &query::TestsModel) -> String {
let mut out: Vec<String> = vec![format!("tests for {}", model.symbol)];
if model.rows.is_empty() {
out.push("no test references found".to_string());
return out.join("\n");
}
out.push(format!(
"covered by {} test file(s), {} reference(s)",
model.test_file_count, model.ref_count
));
out.push(String::new());
for r in &model.rows {
out.push(format!("{}{}", r.file, heuristic_suffix(r.heuristic)));
let lines = r
.lines
.iter()
.map(|l| l.to_string())
.collect::<Vec<_>>()
.join(", ");
for def_id in &r.test_defs {
out.push(format!(" {def_id} lines: {lines}"));
}
}
out.join("\n")
}
pub fn render_tests_compact(model: &query::TestsModel) -> String {
let heur = if model.heuristic_file_count != 0 {
format!(" heuristic={}", model.heuristic_file_count)
} else {
String::new()
};
let mut out: Vec<String> = vec![format!(
"tests {} files={} refs={}{heur}",
model.symbol, model.test_file_count, model.ref_count
)];
for r in &model.rows {
let lines = r
.lines
.iter()
.map(|l| {
if r.heuristic {
format!("{l}h")
} else {
l.to_string()
}
})
.collect::<Vec<_>>()
.join(",");
out.push(format!("{} {lines}", r.file));
}
out.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::query::{
AmbiguousRow, AmbiguousTables, DefSite, ImpactModel, ImpactRow, ImportRow, InboundRow,
InboundTables, OutboundRow, OutboundTables, RefsModel, SeedKind, Table, TestRow,
TestsModel,
};
fn table<R>(rows: Vec<R>, dropped: usize) -> Table<R> {
Table {
total: rows.len() + dropped,
dropped,
rows,
}
}
fn empty_inbound() -> InboundTables {
InboundTables {
inherits: table(vec![], 0),
uses_type: table(vec![], 0),
uses_member: table(vec![], 0),
}
}
fn empty_outbound() -> OutboundTables {
OutboundTables {
inherits: table(vec![], 0),
uses_type: table(vec![], 0),
uses_member: table(vec![], 0),
imports: table(vec![], 0),
}
}
fn empty_ambiguous() -> AmbiguousTables {
AmbiguousTables {
inbound: table(vec![], 0),
outbound: table(vec![], 0),
}
}
fn refs_model(
inbound: InboundTables,
outbound: OutboundTables,
ambiguous: AmbiguousTables,
manifest_gap: usize,
) -> RefsModel {
RefsModel {
query: "IFoo".to_string(),
id: "App.IFoo".to_string(),
kind: "interface".to_string(),
sites: vec![DefSite {
file: "src/IFoo.cs".to_string(),
line: 3,
}],
inbound,
outbound: Some(outbound),
ambiguous,
manifest_gap,
member_refs: None,
}
}
#[test]
fn refs_compact_one_header_per_kind_empty_kinds_print_nothing() {
let mut inbound = empty_inbound();
inbound.inherits = table(
vec![InboundRow {
file: "src/Foo.cs".into(),
line: 5,
heuristic: false,
source: String::new(),
}],
0,
);
let mut outbound = empty_outbound();
outbound.uses_type = table(
vec![OutboundRow {
file: "src/IFoo.cs".into(),
line: 3,
to_file: "src/Bar.cs".into(),
to: "App.Bar".into(),
heuristic: false,
source: String::new(),
}],
0,
);
let model = refs_model(inbound, outbound, empty_ambiguous(), 0);
let out = render_refs_compact(&model);
assert!(out.contains("in:inherits (1):\n src/Foo.cs:5"));
assert!(out.contains("out:uses-type (1):\n src/IFoo.cs:3"));
assert!(
!out.contains("in:uses-type"),
"a kind with zero rows must not print a header"
);
assert!(!out.contains("out:inherits"));
assert!(!out.contains("out:imports"));
assert!(
!out.contains("Bar.cs"),
"outbound target file is dropped in compact mode -- only path:line survives"
);
}
#[test]
fn refs_compact_same_file_line_collapses_to_nxn() {
let mut outbound = empty_outbound();
outbound.inherits = table(
vec![
OutboundRow {
file: "src/Widget.cs".into(),
line: 5,
to_file: "src/IWidget.cs".into(),
to: "App.IWidget".into(),
heuristic: false,
source: String::new(),
},
OutboundRow {
file: "src/Widget.cs".into(),
line: 5,
to_file: "src/IGadget.cs".into(),
to: "App.IGadget".into(),
heuristic: false,
source: String::new(),
},
],
0,
);
let model = refs_model(empty_inbound(), outbound, empty_ambiguous(), 0);
let out = render_refs_compact(&model);
assert!(out.contains("out:inherits (2):\n src/Widget.cs:5x2"));
}
#[test]
fn refs_compact_groups_multiple_lines_under_one_file_mention() {
let mut inbound = empty_inbound();
inbound.uses_type = table(
vec![
InboundRow {
file: "src/Consumers/Big.cs".into(),
line: 12,
heuristic: false,
source: String::new(),
},
InboundRow {
file: "src/Consumers/Big.cs".into(),
line: 40,
heuristic: false,
source: String::new(),
},
InboundRow {
file: "src/Consumers/Big.cs".into(),
line: 40,
heuristic: false,
source: String::new(),
},
InboundRow {
file: "src/Consumers/Small.cs".into(),
line: 3,
heuristic: false,
source: String::new(),
},
],
0,
);
let model = refs_model(inbound, empty_outbound(), empty_ambiguous(), 0);
let out = render_refs_compact(&model);
assert!(out.contains(
"in:uses-type (4):\n src/Consumers/Big.cs:12,40x2\n src/Consumers/Small.cs:3"
));
assert_eq!(
out.matches("src/Consumers/Big.cs").count(),
1,
"the repeated file path must appear exactly once"
);
}
#[test]
fn refs_compact_summary_line_folds_all_counts() {
let mut inbound = empty_inbound();
inbound.inherits = table(
vec![
InboundRow {
file: "src/A.cs".into(),
line: 1,
heuristic: false,
source: String::new(),
},
InboundRow {
file: "src/B.cs".into(),
line: 2,
heuristic: false,
source: String::new(),
},
],
3,
);
let mut ambiguous = empty_ambiguous();
ambiguous.inbound = table(
vec![AmbiguousRow {
file: "src/C.cs".into(),
line: 9,
origin: "uses-type".into(),
raw: "Config".into(),
candidate_count: 2,
}],
0,
);
let model = refs_model(inbound, empty_outbound(), ambiguous, 4);
let out = render_refs_compact(&model);
let last_line = out.lines().last().unwrap();
assert_eq!(
last_line,
"summary: edges=5 shown=2 dropped=3 ambiguous=1 gap=4"
);
assert!(out.contains("amb:in (1):\n src/C.cs:9(candidates=2)"));
assert!(
!out.contains("Config"),
"the raw ambiguous token is not a minimal column and must not appear"
);
}
fn impact_model(
rows: Vec<ImpactRow>,
total_affected: usize,
dropped: usize,
manifest_gap: usize,
) -> ImpactModel {
let heuristic_affected = rows.iter().filter(|r| r.heuristic).count();
ImpactModel {
kind: SeedKind::File,
seed_files: vec!["src/IFoo.cs".to_string()],
hops: 2,
total_affected,
rows,
dropped,
manifest_gap,
heuristic_affected,
tests_affected: 0,
braked: vec![],
braked_files: vec![],
}
}
fn impact_row(
file: &str,
hop: u32,
via_count: u32,
ambiguous_count: u32,
top_symbols: &[&str],
top_symbols_more: usize,
score: f64,
) -> ImpactRow {
ImpactRow {
file: file.into(),
hop,
via_count,
ambiguous_count,
top_symbols: top_symbols.iter().map(|s| (*s).to_string()).collect(),
top_symbols_more,
score,
heuristic_count: 0,
heuristic: false,
iface_via: vec![],
from_lines: vec![],
infra: false,
}
}
fn heuristic_impact_row(
file: &str,
hop: u32,
heuristic_count: u32,
top_symbols: &[&str],
score: f64,
) -> ImpactRow {
ImpactRow {
heuristic_count,
heuristic: true,
..impact_row(file, hop, 0, 0, top_symbols, 0, score)
}
}
#[test]
fn impact_compact_groups_rows_into_ascending_hop_buckets() {
let model = impact_model(
vec![
impact_row("src/Two.cs", 2, 1, 0, &["Foo"], 0, 0.1),
impact_row("src/One.cs", 1, 2, 1, &["Foo", "Bar"], 5, 0.5),
],
3,
0,
0,
);
let out = render_impact_compact("IFoo", &model);
let hop1 = out.find("hop 1").unwrap();
let hop2 = out.find("hop 2").unwrap();
assert!(
hop1 < hop2,
"hop 1 bucket must precede hop 2 regardless of row order in the model"
);
assert!(out.contains("hop 1 (1):\n src/One.cs via=2(+1amb)"));
assert!(out.contains("hop 2 (1):\n src/Two.cs via=1"));
assert!(!out.contains("Bar"), "compact drops top-symbols entirely");
assert!(out.contains("summary: affected=3 shown=2 dropped=0 ambiguous=1"));
}
#[test]
fn impact_compact_manifest_gap_folds_into_summary() {
let model = impact_model(vec![], 0, 0, 2);
let out = render_impact_compact("IFoo", &model);
let last_line = out.lines().last().unwrap();
assert_eq!(
last_line,
"summary: affected=0 shown=0 dropped=0 ambiguous=0 gap=2"
);
}
#[test]
fn ref_kind_block_missing_table_prints_nothing() {
let mut out: Vec<String> = vec!["before".to_string()];
ref_kind_block::<InboundRow>(&mut out, "inherits", None, |r| {
format!("{}:{}", r.file, r.line)
});
assert_eq!(out, vec!["before".to_string()]);
}
#[test]
fn compact_block_missing_table_prints_nothing() {
let mut out: Vec<String> = vec!["before".to_string()];
compact_block::<InboundRow>(
&mut out,
"in:inherits",
None,
|r| r.file.as_str(),
|r| r.line.to_string(),
);
assert_eq!(out, vec!["before".to_string()]);
}
#[test]
fn ref_kind_block_present_but_empty_table_still_prints_header() {
let mut out: Vec<String> = Vec::new();
let t: Table<InboundRow> = table(vec![], 0);
ref_kind_block(&mut out, "inherits", Some(&t), |r: &InboundRow| {
format!("{}:{}", r.file, r.line)
});
assert_eq!(out, vec![" inherits (0):".to_string()]);
}
#[test]
fn compact_block_present_but_empty_table_prints_nothing_unlike_ref_kind_block() {
let mut out: Vec<String> = Vec::new();
let t: Table<InboundRow> = table(vec![], 0);
compact_block(
&mut out,
"in:inherits",
Some(&t),
|r: &InboundRow| r.file.as_str(),
|r: &InboundRow| r.line.to_string(),
);
assert!(out.is_empty());
}
#[test]
fn ref_kind_block_dropped_note_only_appears_when_nonzero() {
let mut out: Vec<String> = Vec::new();
let t = table(
vec![InboundRow {
file: "a.cs".into(),
line: 1,
heuristic: false,
source: String::new(),
}],
2,
);
ref_kind_block(&mut out, "inherits", Some(&t), |r: &InboundRow| {
format!("{}:{}", r.file, r.line)
});
assert_eq!(out[0], " inherits (3, 2 dropped):");
}
#[test]
fn rle_collapses_consecutive_equal_runs_only() {
let values = vec![
"5".to_string(),
"5".to_string(),
"3".to_string(),
"3".to_string(),
"3".to_string(),
"1".to_string(),
];
assert_eq!(
rle(&values),
vec!["5x2".to_string(), "3x3".to_string(), "1".to_string()]
);
}
#[test]
fn seed_kind_str_matches_js_string_literals() {
assert_eq!(seed_kind_str(SeedKind::File), "file");
assert_eq!(seed_kind_str(SeedKind::Symbol), "symbol");
}
#[test]
fn member_refs_line_renders_after_the_inbound_block_and_caps_the_named_members() {
let mut model = refs_model(empty_inbound(), empty_outbound(), empty_ambiguous(), 0);
model.member_refs = Some(query::MemberRefs {
total: 3,
member_count: 2,
members: vec![
query::MemberRefEntry {
name: "EnableX".into(),
count: 2,
},
query::MemberRefEntry {
name: "EnableY".into(),
count: 1,
},
],
dropped: 0,
});
let out = render_refs_text(&model);
assert!(
out.contains("\nmember refs: 3 across 2 member(s): EnableX 2, EnableY 1"),
"{out}"
);
assert!(
render_refs_compact(&model).contains("\nmem: EnableX=2,EnableY=1\nsummary:"),
"{}",
render_refs_compact(&model)
);
model.member_refs = Some(query::MemberRefs {
total: 9,
member_count: 7,
members: vec![query::MemberRefEntry {
name: "A".into(),
count: 9,
}],
dropped: 6,
});
assert!(
render_refs_text(&model).contains("member refs: 9 across 7 member(s): A 9 +6 more"),
"{}",
render_refs_text(&model)
);
assert!(
render_refs_compact(&model).contains("mem: A=9,+6"),
"{}",
render_refs_compact(&model)
);
}
#[test]
fn a_model_with_no_member_refs_renders_exactly_as_before() {
let out = render_refs_text(&refs_model(
empty_inbound(),
empty_outbound(),
empty_ambiguous(),
0,
));
assert!(!out.contains("member refs:"), "{out}");
assert!(!render_refs_compact(&refs_model(
empty_inbound(),
empty_outbound(),
empty_ambiguous(),
0
))
.contains("mem: "));
}
#[test]
fn refs_text_suffixes_only_tagged_rows_and_never_an_imports_row() {
let mut inbound = empty_inbound();
inbound.uses_member = table(
vec![
InboundRow {
file: "src/Fact.cs".into(),
line: 4,
heuristic: false,
source: String::new(),
},
InboundRow {
file: "src/Guess.cs".into(),
line: 9,
heuristic: true,
source: String::new(),
},
],
0,
);
let mut outbound = empty_outbound();
outbound.uses_member = table(
vec![OutboundRow {
file: "src/Guess.cs".into(),
line: 9,
to_file: "src/Widget.cs".into(),
to: "App.Widget".into(),
heuristic: true,
source: String::new(),
}],
0,
);
outbound.imports = table(
vec![ImportRow {
file: "src/Guess.cs".into(),
line: 1,
target: "App.Core".into(),
source: String::new(),
}],
0,
);
let out = render_refs_text(&refs_model(inbound, outbound, empty_ambiguous(), 0));
assert!(out.contains(" src/Fact.cs:4 uses-member\n"), "{out}");
assert!(
out.contains(" src/Guess.cs:9 uses-member (heuristic)"),
"{out}"
);
assert!(
out.contains(" src/Guess.cs:9 uses-member -> src/Widget.cs (heuristic)"),
"{out}"
);
assert!(
out.ends_with(" src/Guess.cs:1 imports -> App.Core"),
"an imports row is never suffixed\n{out}"
);
assert_eq!(out.matches("(heuristic)").count(), 2);
}
#[test]
fn refs_compact_marks_a_heuristic_row_with_a_trailing_h_and_rle_keeps_them_distinct() {
let mut inbound = empty_inbound();
inbound.uses_member = table(
vec![
InboundRow {
file: "src/A.cs".into(),
line: 5,
heuristic: false,
source: String::new(),
},
InboundRow {
file: "src/A.cs".into(),
line: 5,
heuristic: true,
source: String::new(),
},
InboundRow {
file: "src/A.cs".into(),
line: 5,
heuristic: true,
source: String::new(),
},
],
0,
);
let out = render_refs_compact(&refs_model(inbound, empty_outbound(), empty_ambiguous(), 0));
assert!(
out.contains("in:uses-member (3):\n src/A.cs:5,5hx2"),
"`5` and `5h` are distinct RLE entries\n{out}"
);
}
#[test]
fn impact_text_declares_heuristic_reached_files_beside_the_count_only_when_there_are_some() {
let with_guess = impact_model(
vec![
impact_row("src/Direct.cs", 1, 1, 0, &["Widget"], 0, 0.5),
heuristic_impact_row("src/Guessed.cs", 1, 2, &["Widget"], 0.0),
],
1,
0,
0,
);
let out = render_impact_text("Widget", &with_guess);
assert!(
out.contains("affected files: 1 (+1 heuristic) shown: 2 dropped: 0"),
"{out}"
);
assert!(out.contains("src/Direct.cs 1 1 Widget\n"), "{out}");
assert!(
out.contains("src/Guessed.cs 1 2 Widget (heuristic)"),
"the via column reports the GUESS count, never the zero viaCount\n{out}"
);
let without = impact_model(
vec![impact_row("src/Direct.cs", 1, 1, 0, &["Widget"], 0, 0.5)],
1,
0,
0,
);
let out = render_impact_text("Widget", &without);
assert!(
out.contains("affected files: 1 shown: 1 dropped: 0"),
"byte-unchanged when there is nothing to declare\n{out}"
);
assert!(!out.contains("heuristic"), "{out}");
}
#[test]
fn impact_compact_summary_inserts_heuristic_before_gap() {
let model = impact_model(
vec![heuristic_impact_row(
"src/Guessed.cs",
1,
3,
&["Widget"],
0.0,
)],
0,
0,
2,
);
let out = render_impact_compact("Widget", &model);
assert!(out.contains("hop 1 (1):\n src/Guessed.cs via=3h"), "{out}");
assert_eq!(
out.lines().last().unwrap(),
"summary: affected=0 shown=1 dropped=0 ambiguous=0 heuristic=1 gap=2"
);
let none = impact_model(
vec![impact_row("src/Direct.cs", 1, 1, 0, &["Widget"], 0, 0.5)],
1,
0,
2,
);
assert_eq!(
render_impact_compact("Widget", &none)
.lines()
.last()
.unwrap(),
"summary: affected=1 shown=1 dropped=0 ambiguous=0 gap=2"
);
}
fn tests_model(rows: Vec<TestRow>) -> TestsModel {
let precise: Vec<&TestRow> = rows.iter().filter(|r| !r.heuristic).collect();
let guessed: Vec<&TestRow> = rows.iter().filter(|r| r.heuristic).collect();
TestsModel {
query: "OrderService".to_string(),
symbol: "App.Orders.OrderService".to_string(),
def_files: vec!["src/OrderService.cs".to_string()],
test_file_count: precise.len(),
ref_count: precise.iter().map(|r| r.ref_count).sum(),
heuristic_file_count: guessed.len(),
heuristic_ref_count: guessed.iter().map(|r| r.ref_count).sum(),
rows,
}
}
fn test_row(file: &str, test_defs: &[&str], lines: &[usize], heuristic: bool) -> TestRow {
TestRow {
file: file.into(),
test_defs: test_defs.iter().map(|s| (*s).to_string()).collect(),
lines: lines.to_vec(),
ref_count: lines.len(),
heuristic,
}
}
#[test]
fn tests_text_heads_the_file_and_indents_one_line_per_test_def() {
let model = tests_model(vec![test_row(
"tests/OrderServiceTests.cs",
&["App.Orders.Tests.OrderServiceTests"],
&[10, 11],
false,
)]);
assert_eq!(
render_tests_text(&model),
"tests for App.Orders.OrderService\ncovered by 1 test file(s), 2 reference(s)\n\ntests/OrderServiceTests.cs\n App.Orders.Tests.OrderServiceTests lines: 10, 11"
);
}
#[test]
fn tests_text_answers_the_zero_case_in_one_line_instead_of_a_header_over_a_void() {
let mut model = tests_model(vec![]);
model.symbol = "App.Orders.Untested".to_string();
assert_eq!(
render_tests_text(&model),
"tests for App.Orders.Untested\nno test references found"
);
}
#[test]
fn tests_text_marks_a_guessed_file_with_the_shared_heuristic_suffix() {
let model = tests_model(vec![
test_row(
"tests/OrderServiceTests.cs",
&["App.Orders.Tests.OrderServiceTests"],
&[10],
false,
),
test_row(
"tests/Partial.Extra.cs",
&["App.Orders.Tests.PartialTests"],
&[9],
true,
),
]);
let out = render_tests_text(&model);
assert!(
out.contains("covered by 1 test file(s), 1 reference(s)"),
"counts stay precise-only\n{out}"
);
assert!(
out.ends_with(
"tests/Partial.Extra.cs (heuristic)\n App.Orders.Tests.PartialTests lines: 9"
),
"{out}"
);
}
#[test]
fn tests_compact_folds_the_counts_into_the_header_and_drops_the_def_ids() {
let model = tests_model(vec![test_row(
"tests/OrderServiceTests.cs",
&["App.Orders.Tests.OrderServiceTests"],
&[10, 11],
false,
)]);
assert_eq!(
render_tests_compact(&model),
"tests App.Orders.OrderService files=1 refs=2\ntests/OrderServiceTests.cs 10,11"
);
let mut empty = tests_model(vec![]);
empty.symbol = "App.Orders.Untested".to_string();
assert_eq!(
render_tests_compact(&empty),
"tests App.Orders.Untested files=0 refs=0"
);
}
#[test]
fn tests_compact_declares_heuristic_files_in_the_header_and_marks_their_lines() {
let model = tests_model(vec![
test_row(
"tests/OrderServiceTests.cs",
&["App.Orders.Tests.OrderServiceTests"],
&[10],
false,
),
test_row(
"tests/Partial.Extra.cs",
&["App.Orders.Tests.PartialTests"],
&[9, 12],
true,
),
]);
assert_eq!(
render_tests_compact(&model),
"tests App.Orders.OrderService files=1 refs=1 heuristic=1\ntests/OrderServiceTests.cs 10\ntests/Partial.Extra.cs 9h,12h"
);
}
#[test]
fn impact_summaries_append_tests_only_when_the_blast_radius_reaches_one() {
let reached = ImpactModel {
tests_affected: 1,
..impact_model(
vec![impact_row(
"tests/OrderServiceTests.cs",
1,
1,
0,
&["OrderService"],
0,
0.5,
)],
1,
0,
0,
)
};
assert!(
render_impact_text("OrderService", &reached)
.contains("affected files: 1 shown: 1 dropped: 0 tests=1"),
"{}",
render_impact_text("OrderService", &reached)
);
assert_eq!(
render_impact_compact("OrderService", &reached)
.lines()
.last()
.unwrap(),
"summary: affected=1 shown=1 dropped=0 ambiguous=0 tests=1"
);
let none = impact_model(
vec![impact_row(
"src/Other.cs",
1,
1,
0,
&["OrderService"],
0,
0.5,
)],
1,
0,
0,
);
assert!(
render_impact_text("OrderService", &none)
.contains("affected files: 1 shown: 1 dropped: 0\n"),
"its ABSENCE is the gap signal -- never printed as tests=0"
);
assert_eq!(
render_impact_compact("OrderService", &none)
.lines()
.last()
.unwrap(),
"summary: affected=1 shown=1 dropped=0 ambiguous=0"
);
}
#[test]
fn impact_compact_orders_tests_after_heuristic_and_before_gap() {
let model = ImpactModel {
tests_affected: 1,
..impact_model(
vec![
impact_row(
"tests/OrderServiceTests.cs",
1,
1,
0,
&["OrderService"],
0,
0.5,
),
heuristic_impact_row("src/Guessed.cs", 1, 3, &["OrderService"], 0.0),
],
1,
0,
2,
)
};
assert_eq!(
render_impact_compact("OrderService", &model)
.lines()
.last()
.unwrap(),
"summary: affected=1 shown=2 dropped=0 ambiguous=0 heuristic=1 tests=1 gap=2"
);
}
}