Skip to main content

devscout_rs/render/
impact.rs

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
9// The default-renderer suffix for a row every path to it crossed a
10// `bus-hop`: the SAME disclosure sentence `refs`/`read`'s own bus-hop rows
11// carry, plus the identity to re-check -- an impact row is a file-level
12// aggregate with no message/handler fields of its own to show otherwise,
13// unlike a `refs` row which already names them. Empty when the row is not
14// bus-only, or when (defensively) no origin identity survived to be shown.
15fn 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
28/// Default (non-compact) `impact` rendering.
29pub 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    // The affected count stays the count of files reached by real edges, and
43    // the guesses are declared beside it rather than folded into it. The
44    // parenthetical appears ONLY when there is something to declare, so a
45    // graph with no heuristic edges renders this line byte-for-byte unchanged.
46    let heuristic_note = if model.heuristic_affected != 0 {
47        format!(" (+{} heuristic)", model.heuristic_affected)
48    } else {
49        String::new()
50    };
51    // Test-coverage stage -- the term joins the summary only when the blast
52    // radius actually reaches a test file, so a graph built before this stage
53    // (and every query that reaches none) renders the line byte-for-byte as it
54    // always did. Its ABSENCE is the gap signal, which is why it is never
55    // printed as `tests=0`.
56    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        // A heuristic-only row's viaCount is zero by definition -- printing it
74        // would claim the file was reached by nothing. It reports the guesses
75        // that DID reach it; the row's own suffix is what says they were
76        // guesses.
77        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        // Present only on a row the interface hop actually reached, so every
85        // other row (and the whole line under `--no-iface`) carries no `via`
86        // suffix.
87        let iface_via = if r.iface_via.is_empty() {
88            String::new()
89        } else {
90            format!("  via {}", r.iface_via.join(", "))
91        };
92        // The same conditional-suffix rule again: a hub file says so on its own
93        // row, so the reader never has to join the trailer to the table to learn
94        // which row the walk stopped at.
95        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    // A trailer, not a footnote: the rows above are NARROWER than the graph
105    // allows, and the line names which contracts were held back, how broad each
106    // one is, and the flag that lets them through. A narrowing nobody can see is
107    // indistinguishable from a missing edge. Printed only when the brake fired.
108    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    // Its own trailer line, not a term on the interface brake's: the two brakes
118    // are undone by two different flags, and a line naming both would leave the
119    // reader guessing which flag widens which name.
120    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
140/// `--compact` `impact` rendering.
141pub 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    // The hop grouping preserves nothing order-relevant (the hop keys are
156    // explicitly re-sorted right after), so a plain HashMap is fine. Row order
157    // WITHIN a hop bucket must stay in `model.rows`' original
158    // (score-desc/hop/file-sorted) order for `rle` to collapse the intended runs
159    // -- `Vec::push` in a single forward pass preserves that.
160    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                // A heuristic-only row has a viaCount of zero by definition, so
174                // printing `via=0` would say "reached by nothing". It reports
175                // the guess count it was actually reached by instead, marked
176                // with the same one-character tier marker the refs tables use.
177                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                // Same conditional-suffix rule as the default renderer's `via`.
191                let iface_suffix = if r.iface_via.is_empty() {
192                    String::new()
193                } else {
194                    format!(" iface={}", r.iface_via.join(","))
195                };
196                // Same conditional-suffix rule as the default renderer's class.
197                let class_suffix = if r.infra { " class=infra" } else { "" };
198                // The marker-plus-path-to-full-row shape `refs`' own compact
199                // bus block established: compact has no room for the full
200                // disclosure text or the origin identity, only a marker and
201                // where to find them.
202                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    // `affected` counts precisely-reached files; the heuristic term joins it
225    // only when there is one, and always BEFORE ` gap=`, so a graph with no
226    // heuristic edges renders the same summary line.
227    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    // The compact spelling of the default renderer's `braked:` trailer, appended
238    // LAST so every summary line the brake never touched is unchanged. One
239    // `braked=` term still, file entries after interface ones, each spelled
240    // `name:number` exactly as an interface entry.
241    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
266// Shared by both renderers below: a new block, not a per-row splice into the
267// native table, which is what keeps the no-import case byte-identical --
268// callers reach this only when `imported.rows` is non-empty.
269fn 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
290/// `render_impact_text`, plus the imported-edge section when an import is
291/// configured.
292///
293/// Byte-identical to [`render_impact_text`] when `imported.rows` is empty:
294/// the extra block is appended only when there is something to append.
295pub 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
309/// `render_impact_compact`, plus the imported-edge section when an import is
310/// configured. Byte-identical to [`render_impact_compact`] when
311/// `imported.rows` is empty.
312pub 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}