1use crate::graph;
2use crate::query;
3
4use super::blocks::rle;
5use super::markers::{
6 compact_marker, heuristic_suffix, seed_kind_str, BUS_HOP_UNVERIFIED, INFRA_SUFFIX,
7};
8
9fn bus_origin_suffix(bus_only: bool, origin: Option<&query::BusOrigin>) -> String {
16 if !bus_only {
17 return String::new();
18 }
19 match origin {
20 Some(o) => format!(
21 " ({BUS_HOP_UNVERIFIED}: message={} handler={} handlerFile={})",
22 o.message, o.to, o.to_file
23 ),
24 None => format!(" ({BUS_HOP_UNVERIFIED})"),
25 }
26}
27
28pub fn render_impact_text(query: &str, model: &query::ImpactModel) -> String {
30 let mut out: Vec<String> = Vec::new();
31 let joined = model.seed_files.join(", ");
32 let seeds = if joined.is_empty() {
33 "-".to_string()
34 } else {
35 joined
36 };
37 out.push(format!(
38 "impact: {query} ({}, seed files: {seeds}) hops<={}",
39 seed_kind_str(model.kind),
40 model.hops
41 ));
42 let heuristic_note = if model.heuristic_affected != 0 {
47 format!(" (+{} heuristic)", model.heuristic_affected)
48 } else {
49 String::new()
50 };
51 let tests_note = if model.tests_affected != 0 {
57 format!(" tests={}", model.tests_affected)
58 } else {
59 String::new()
60 };
61 out.push(format!(
62 "affected files: {}{heuristic_note} shown: {} dropped: {}{tests_note}",
63 model.total_affected,
64 model.rows.len(),
65 model.dropped
66 ));
67 out.push("file hops via top-symbols".to_string());
68 for r in &model.rows {
69 let mut syms = r.top_symbols.join(", ");
70 if r.top_symbols_more != 0 {
71 syms.push_str(&format!(" +{}", r.top_symbols_more));
72 }
73 let via = if r.heuristic {
78 r.heuristic_count.to_string()
79 } else if r.ambiguous_count != 0 {
80 format!("{}(+{}amb)", r.via_count, r.ambiguous_count)
81 } else {
82 r.via_count.to_string()
83 };
84 let iface_via = if r.iface_via.is_empty() {
88 String::new()
89 } else {
90 format!(" via {}", r.iface_via.join(", "))
91 };
92 let class_suffix = if r.infra { INFRA_SUFFIX } else { "" };
96 let bus_only_suffix = bus_origin_suffix(r.bus_only, r.bus_origin.as_ref());
97 out.push(format!(
98 "{} {} {via} {syms}{iface_via}{}{class_suffix}{bus_only_suffix}",
99 r.file,
100 r.hop,
101 heuristic_suffix(r.heuristic, r.tier)
102 ));
103 }
104 if !model.braked.is_empty() {
109 let list = model
110 .braked
111 .iter()
112 .map(|b| format!("{} (fan-in {})", b.iface, b.fanin))
113 .collect::<Vec<_>>()
114 .join(", ");
115 out.push(format!("braked: {list} — raise --iface-max-fanin to widen"));
116 }
117 if !model.braked_files.is_empty() {
121 let list = model
122 .braked_files
123 .iter()
124 .map(|b| format!("{} (in-degree {})", b.file, b.indegree))
125 .collect::<Vec<_>>()
126 .join(", ");
127 out.push(format!(
128 "braked: {list} — raise --hub-max-indegree to widen"
129 ));
130 }
131 if model.manifest_gap != 0 {
132 out.push(format!(
133 "manifest gap: {} graph file(s) not in manifest",
134 model.manifest_gap
135 ));
136 }
137 out.join("\n")
138}
139
140pub fn render_impact_compact(query: &str, model: &query::ImpactModel) -> String {
142 let mut out: Vec<String> = Vec::new();
143 let joined = model.seed_files.join(", ");
144 let seeds = if joined.is_empty() {
145 "-".to_string()
146 } else {
147 joined
148 };
149 out.push(format!(
150 "impact: {query} ({}, seed: {seeds}) hops<={}",
151 seed_kind_str(model.kind),
152 model.hops
153 ));
154
155 let mut by_hop: std::collections::HashMap<u32, Vec<&query::ImpactRow>> =
161 std::collections::HashMap::new();
162 for r in &model.rows {
163 by_hop.entry(r.hop).or_default().push(r);
164 }
165 let mut hops: Vec<u32> = by_hop.keys().copied().collect();
166 hops.sort_unstable();
167 for hop in hops {
168 let rows = &by_hop[&hop];
169 out.push(format!("hop {hop} ({}):", rows.len()));
170 let lines: Vec<String> = rows
171 .iter()
172 .map(|r| {
173 if r.heuristic {
178 return format!(
179 "{} via={}{}",
180 r.file,
181 r.heuristic_count,
182 compact_marker(r.heuristic, r.tier)
183 );
184 }
185 let via = if r.ambiguous_count != 0 {
186 format!("{}(+{}amb)", r.via_count, r.ambiguous_count)
187 } else {
188 r.via_count.to_string()
189 };
190 let iface_suffix = if r.iface_via.is_empty() {
192 String::new()
193 } else {
194 format!(" iface={}", r.iface_via.join(","))
195 };
196 let class_suffix = if r.infra { " class=infra" } else { "" };
198 let bus_only_suffix = if r.bus_only {
203 " ? (rerun without --compact for the full row)"
204 } else {
205 ""
206 };
207 format!(
208 "{} via={via}{iface_suffix}{class_suffix}{bus_only_suffix}",
209 r.file
210 )
211 })
212 .collect();
213 for line in rle(&lines) {
214 out.push(format!(" {line}"));
215 }
216 }
217
218 let ambiguous: u32 = model.rows.iter().map(|r| r.ambiguous_count).sum();
219 let gap = if model.manifest_gap != 0 {
220 format!(" gap={}", model.manifest_gap)
221 } else {
222 String::new()
223 };
224 let heur = if model.heuristic_affected != 0 {
228 format!(" heuristic={}", model.heuristic_affected)
229 } else {
230 String::new()
231 };
232 let tests = if model.tests_affected != 0 {
233 format!(" tests={}", model.tests_affected)
234 } else {
235 String::new()
236 };
237 let braked = if model.braked.is_empty() && model.braked_files.is_empty() {
242 String::new()
243 } else {
244 let terms: Vec<String> = model
245 .braked
246 .iter()
247 .map(|b| format!("{}:{}", b.iface, b.fanin))
248 .chain(
249 model
250 .braked_files
251 .iter()
252 .map(|b| format!("{}:{}", b.file, b.indegree)),
253 )
254 .collect();
255 format!(" braked={}", terms.join(","))
256 };
257 out.push(format!(
258 "summary: affected={} shown={} dropped={} ambiguous={ambiguous}{heur}{tests}{gap}{braked}",
259 model.total_affected,
260 model.rows.len(),
261 model.dropped
262 ));
263 out.join("\n")
264}
265
266fn render_imported_block(
270 imported: &query::ImportedSection,
271 provenance: &graph::Provenance,
272) -> String {
273 let mut out = vec![format!(
274 "imported: {} reached shown: {} dropped: {} provenance {}",
275 imported.affected,
276 imported.rows.len(),
277 imported.dropped,
278 provenance.id
279 )];
280 out.push("file hop repo via".to_string());
281 for r in &imported.rows {
282 out.push(format!(
283 "{} {} {} {}",
284 r.file, r.hop, r.repo, r.imported_kind
285 ));
286 }
287 out.join("\n")
288}
289
290pub fn render_impact_text_with_imports(
296 query: &str,
297 model: &query::ImpactModel,
298 imported: &query::ImportedSection,
299 provenance: &graph::Provenance,
300) -> String {
301 let mut out = render_impact_text(query, model);
302 if !imported.rows.is_empty() {
303 out.push('\n');
304 out.push_str(&render_imported_block(imported, provenance));
305 }
306 out
307}
308
309pub fn render_impact_compact_with_imports(
313 query: &str,
314 model: &query::ImpactModel,
315 imported: &query::ImportedSection,
316 provenance: &graph::Provenance,
317) -> String {
318 let mut out = render_impact_compact(query, model);
319 if !imported.rows.is_empty() {
320 out.push('\n');
321 out.push_str(&render_imported_block(imported, provenance));
322 }
323 out
324}