devscout_rs/render/
impact.rs1use crate::graph;
2use crate::query;
3
4use super::blocks::rle;
5use super::markers::{compact_marker, heuristic_suffix, seed_kind_str, INFRA_SUFFIX};
6
7pub fn render_impact_text(query: &str, model: &query::ImpactModel) -> String {
9 let mut out: Vec<String> = Vec::new();
10 let joined = model.seed_files.join(", ");
11 let seeds = if joined.is_empty() {
12 "-".to_string()
13 } else {
14 joined
15 };
16 out.push(format!(
17 "impact: {query} ({}, seed files: {seeds}) hops<={}",
18 seed_kind_str(model.kind),
19 model.hops
20 ));
21 let heuristic_note = if model.heuristic_affected != 0 {
26 format!(" (+{} heuristic)", model.heuristic_affected)
27 } else {
28 String::new()
29 };
30 let tests_note = if model.tests_affected != 0 {
36 format!(" tests={}", model.tests_affected)
37 } else {
38 String::new()
39 };
40 out.push(format!(
41 "affected files: {}{heuristic_note} shown: {} dropped: {}{tests_note}",
42 model.total_affected,
43 model.rows.len(),
44 model.dropped
45 ));
46 out.push("file hops via top-symbols".to_string());
47 for r in &model.rows {
48 let mut syms = r.top_symbols.join(", ");
49 if r.top_symbols_more != 0 {
50 syms.push_str(&format!(" +{}", r.top_symbols_more));
51 }
52 let via = if r.heuristic {
57 r.heuristic_count.to_string()
58 } else if r.ambiguous_count != 0 {
59 format!("{}(+{}amb)", r.via_count, r.ambiguous_count)
60 } else {
61 r.via_count.to_string()
62 };
63 let iface_via = if r.iface_via.is_empty() {
67 String::new()
68 } else {
69 format!(" via {}", r.iface_via.join(", "))
70 };
71 let class_suffix = if r.infra { INFRA_SUFFIX } else { "" };
75 out.push(format!(
76 "{} {} {via} {syms}{iface_via}{}{class_suffix}",
77 r.file,
78 r.hop,
79 heuristic_suffix(r.heuristic, r.tier)
80 ));
81 }
82 if !model.braked.is_empty() {
87 let list = model
88 .braked
89 .iter()
90 .map(|b| format!("{} (fan-in {})", b.iface, b.fanin))
91 .collect::<Vec<_>>()
92 .join(", ");
93 out.push(format!("braked: {list} — raise --iface-max-fanin to widen"));
94 }
95 if !model.braked_files.is_empty() {
99 let list = model
100 .braked_files
101 .iter()
102 .map(|b| format!("{} (in-degree {})", b.file, b.indegree))
103 .collect::<Vec<_>>()
104 .join(", ");
105 out.push(format!(
106 "braked: {list} — raise --hub-max-indegree to widen"
107 ));
108 }
109 if model.manifest_gap != 0 {
110 out.push(format!(
111 "manifest gap: {} graph file(s) not in manifest",
112 model.manifest_gap
113 ));
114 }
115 out.join("\n")
116}
117
118pub fn render_impact_compact(query: &str, model: &query::ImpactModel) -> String {
120 let mut out: Vec<String> = Vec::new();
121 let joined = model.seed_files.join(", ");
122 let seeds = if joined.is_empty() {
123 "-".to_string()
124 } else {
125 joined
126 };
127 out.push(format!(
128 "impact: {query} ({}, seed: {seeds}) hops<={}",
129 seed_kind_str(model.kind),
130 model.hops
131 ));
132
133 let mut by_hop: std::collections::HashMap<u32, Vec<&query::ImpactRow>> =
139 std::collections::HashMap::new();
140 for r in &model.rows {
141 by_hop.entry(r.hop).or_default().push(r);
142 }
143 let mut hops: Vec<u32> = by_hop.keys().copied().collect();
144 hops.sort_unstable();
145 for hop in hops {
146 let rows = &by_hop[&hop];
147 out.push(format!("hop {hop} ({}):", rows.len()));
148 let lines: Vec<String> = rows
149 .iter()
150 .map(|r| {
151 if r.heuristic {
156 return format!(
157 "{} via={}{}",
158 r.file,
159 r.heuristic_count,
160 compact_marker(r.heuristic, r.tier)
161 );
162 }
163 let via = if r.ambiguous_count != 0 {
164 format!("{}(+{}amb)", r.via_count, r.ambiguous_count)
165 } else {
166 r.via_count.to_string()
167 };
168 let iface_suffix = if r.iface_via.is_empty() {
170 String::new()
171 } else {
172 format!(" iface={}", r.iface_via.join(","))
173 };
174 let class_suffix = if r.infra { " class=infra" } else { "" };
176 format!("{} via={via}{iface_suffix}{class_suffix}", r.file)
177 })
178 .collect();
179 for line in rle(&lines) {
180 out.push(format!(" {line}"));
181 }
182 }
183
184 let ambiguous: u32 = model.rows.iter().map(|r| r.ambiguous_count).sum();
185 let gap = if model.manifest_gap != 0 {
186 format!(" gap={}", model.manifest_gap)
187 } else {
188 String::new()
189 };
190 let heur = if model.heuristic_affected != 0 {
194 format!(" heuristic={}", model.heuristic_affected)
195 } else {
196 String::new()
197 };
198 let tests = if model.tests_affected != 0 {
199 format!(" tests={}", model.tests_affected)
200 } else {
201 String::new()
202 };
203 let braked = if model.braked.is_empty() && model.braked_files.is_empty() {
208 String::new()
209 } else {
210 let terms: Vec<String> = model
211 .braked
212 .iter()
213 .map(|b| format!("{}:{}", b.iface, b.fanin))
214 .chain(
215 model
216 .braked_files
217 .iter()
218 .map(|b| format!("{}:{}", b.file, b.indegree)),
219 )
220 .collect();
221 format!(" braked={}", terms.join(","))
222 };
223 out.push(format!(
224 "summary: affected={} shown={} dropped={} ambiguous={ambiguous}{heur}{tests}{gap}{braked}",
225 model.total_affected,
226 model.rows.len(),
227 model.dropped
228 ));
229 out.join("\n")
230}
231
232fn render_imported_block(
236 imported: &query::ImportedSection,
237 provenance: &graph::Provenance,
238) -> String {
239 let mut out = vec![format!(
240 "imported: {} reached shown: {} dropped: {} provenance {}",
241 imported.affected,
242 imported.rows.len(),
243 imported.dropped,
244 provenance.id
245 )];
246 out.push("file hop repo via".to_string());
247 for r in &imported.rows {
248 out.push(format!(
249 "{} {} {} {}",
250 r.file, r.hop, r.repo, r.imported_kind
251 ));
252 }
253 out.join("\n")
254}
255
256pub fn render_impact_text_with_imports(
262 query: &str,
263 model: &query::ImpactModel,
264 imported: &query::ImportedSection,
265 provenance: &graph::Provenance,
266) -> String {
267 let mut out = render_impact_text(query, model);
268 if !imported.rows.is_empty() {
269 out.push('\n');
270 out.push_str(&render_imported_block(imported, provenance));
271 }
272 out
273}
274
275pub fn render_impact_compact_with_imports(
279 query: &str,
280 model: &query::ImpactModel,
281 imported: &query::ImportedSection,
282 provenance: &graph::Provenance,
283) -> String {
284 let mut out = render_impact_compact(query, model);
285 if !imported.rows.is_empty() {
286 out.push('\n');
287 out.push_str(&render_imported_block(imported, provenance));
288 }
289 out
290}