Skip to main content

devscout_rs/render/
impact.rs

1use crate::graph;
2use crate::query;
3
4use super::blocks::rle;
5use super::markers::{compact_marker, heuristic_suffix, seed_kind_str, INFRA_SUFFIX};
6
7/// Default (non-compact) `impact` rendering.
8pub 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    // The affected count stays the count of files reached by real edges, and
22    // the guesses are declared beside it rather than folded into it. The
23    // parenthetical appears ONLY when there is something to declare, so a
24    // graph with no heuristic edges renders this line byte-for-byte unchanged.
25    let heuristic_note = if model.heuristic_affected != 0 {
26        format!(" (+{} heuristic)", model.heuristic_affected)
27    } else {
28        String::new()
29    };
30    // Test-coverage stage -- the term joins the summary only when the blast
31    // radius actually reaches a test file, so a graph built before this stage
32    // (and every query that reaches none) renders the line byte-for-byte as it
33    // always did. Its ABSENCE is the gap signal, which is why it is never
34    // printed as `tests=0`.
35    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        // A heuristic-only row's viaCount is zero by definition -- printing it
53        // would claim the file was reached by nothing. It reports the guesses
54        // that DID reach it; the row's own suffix is what says they were
55        // guesses.
56        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        // Present only on a row the interface hop actually reached, so every
64        // other row (and the whole line under `--no-iface`) carries no `via`
65        // suffix.
66        let iface_via = if r.iface_via.is_empty() {
67            String::new()
68        } else {
69            format!("  via {}", r.iface_via.join(", "))
70        };
71        // The same conditional-suffix rule again: a hub file says so on its own
72        // row, so the reader never has to join the trailer to the table to learn
73        // which row the walk stopped at.
74        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    // A trailer, not a footnote: the rows above are NARROWER than the graph
83    // allows, and the line names which contracts were held back, how broad each
84    // one is, and the flag that lets them through. A narrowing nobody can see is
85    // indistinguishable from a missing edge. Printed only when the brake fired.
86    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    // Its own trailer line, not a term on the interface brake's: the two brakes
96    // are undone by two different flags, and a line naming both would leave the
97    // reader guessing which flag widens which name.
98    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
118/// `--compact` `impact` rendering.
119pub 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    // The hop grouping preserves nothing order-relevant (the hop keys are
134    // explicitly re-sorted right after), so a plain HashMap is fine. Row order
135    // WITHIN a hop bucket must stay in `model.rows`' original
136    // (score-desc/hop/file-sorted) order for `rle` to collapse the intended runs
137    // -- `Vec::push` in a single forward pass preserves that.
138    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                // A heuristic-only row has a viaCount of zero by definition, so
152                // printing `via=0` would say "reached by nothing". It reports
153                // the guess count it was actually reached by instead, marked
154                // with the same one-character tier marker the refs tables use.
155                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                // Same conditional-suffix rule as the default renderer's `via`.
169                let iface_suffix = if r.iface_via.is_empty() {
170                    String::new()
171                } else {
172                    format!(" iface={}", r.iface_via.join(","))
173                };
174                // Same conditional-suffix rule as the default renderer's class.
175                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    // `affected` counts precisely-reached files; the heuristic term joins it
191    // only when there is one, and always BEFORE ` gap=`, so a graph with no
192    // heuristic edges renders the same summary line.
193    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    // The compact spelling of the default renderer's `braked:` trailer, appended
204    // LAST so every summary line the brake never touched is unchanged. One
205    // `braked=` term still, file entries after interface ones, each spelled
206    // `name:number` exactly as an interface entry.
207    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
232// Shared by both renderers below: a new block, not a per-row splice into the
233// native table, which is what keeps the no-import case byte-identical --
234// callers reach this only when `imported.rows` is non-empty.
235fn 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
256/// `render_impact_text`, plus the imported-edge section when an import is
257/// configured.
258///
259/// Byte-identical to [`render_impact_text`] when `imported.rows` is empty:
260/// the extra block is appended only when there is something to append.
261pub 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
275/// `render_impact_compact`, plus the imported-edge section when an import is
276/// configured. Byte-identical to [`render_impact_compact`] when
277/// `imported.rows` is empty.
278pub 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}