Skip to main content

devscout_rs/
render.rs

1// Default / --compact rendering for `refs`/`impact`. This module owns
2// `render_refs_text`/`render_impact_text` (default), `render_refs_compact`/
3// `render_impact_compact` (`--compact`), and their shared helpers
4// (`ref_kind_block`/`compact_block`/`rle`/`group_by_file`). Analytics/dev output
5// (triage/analyze/report) is out of scope here.
6//
7// `--json` output is NOT built here -- it lives in cli.rs alongside the rest of
8// the CLI-level `--json` flag handling, matching the module split.
9//
10// Rendering notes (`ref_kind_block`/`compact_block`):
11//
12// - `ref_kind_block` (default renderer) prints its header line whenever the
13//   table itself is present, EVEN IF `total == 0` -- only a genuinely
14//   missing/undefined table suppresses the header entirely. `compact_block`
15//   additionally suppresses on `total == 0` (empty tables print nothing in
16//   `--compact`, not even a header). This asymmetry is real and preserved:
17//   see `ref_kind_block_present_but_empty_table_still_prints_header` below.
18// - "Missing table" tolerance (the `Option<&Table<R>>` parameter): query.rs's
19//   typed `RefsModel`/`ImpactModel` always supply a concrete (possibly
20//   zero-total) `Table` for every kind, so the `None` branch is unreachable from
21//   a real model. It is kept because the block helpers are tested directly with
22//   an absent table to exercise exactly this path; it is not reachable through
23//   the public render_* functions.
24
25use crate::query;
26
27// The ONE place a heuristic row is marked in the default text renderer, and the
28// marker is a plain-language word rather than a symbol: the consumer is a model
29// reading a wall of `file:line kind` rows, and a sigil it has to look up is the
30// same as no marker at all. A row with no flag renders no suffix.
31const HEURISTIC_SUFFIX: &str = " (heuristic)";
32
33// The same shape of suffix as `HEURISTIC_SUFFIX`, for the hub-file class, so a
34// row carries its own reason and the two never need to be told apart by
35// position.
36const INFRA_SUFFIX: &str = " (infra)";
37
38// `HEURISTIC_SUFFIX` when the row is a guess, `""` otherwise. Applied inside each
39// row formatter rather than inside `ref_kind_block`, because the two tables that
40// can never hold a guess (imports, ambiguous) carry no flag to read -- they get
41// no suffix, the same as any non-guess row.
42fn heuristic_suffix(heuristic: bool) -> &'static str {
43    if heuristic {
44        HEURISTIC_SUFFIX
45    } else {
46        ""
47    }
48}
49
50// The source snippet, two spaces then the text, appended AFTER the heuristic
51// suffix by `ref_kind_block`. Only inbound rows ever carry a source line; every
52// other table's rows leave it empty and render no suffix.
53fn source_suffix(source: &str) -> String {
54    if source.is_empty() {
55        String::new()
56    } else {
57        format!("  {source}")
58    }
59}
60
61/// The impact seed kind as a string (`"file"`/`"symbol"`), used in the impact
62/// renderers' header line and in cli.rs's `--json` and error messages.
63pub fn seed_kind_str(kind: query::SeedKind) -> &'static str {
64    match kind {
65        query::SeedKind::File => "file",
66        query::SeedKind::Symbol => "symbol",
67    }
68}
69
70// Run-length encoding: collapses a run of `n > 1` identical consecutive strings
71// into `"{value}x{n}"`; a run of length 1 passes through unchanged. Not
72// order-sensitive beyond adjacency -- callers are responsible for handing it
73// already-grouped/sorted input.
74fn rle(values: &[String]) -> Vec<String> {
75    let mut out = Vec::new();
76    let mut i = 0;
77    while i < values.len() {
78        let mut j = i + 1;
79        while j < values.len() && values[j] == values[i] {
80            j += 1;
81        }
82        if j - i > 1 {
83            out.push(format!("{}x{}", values[i], j - i));
84        } else {
85            out.push(values[i].clone());
86        }
87        i = j;
88    }
89    out
90}
91
92// Rows arrive already sorted file-then-line (`build_refs_model`'s own `table()`
93// sort), so consecutive rows sharing a file are adjacent -- this groups them
94// into one `file:line1,line2,...` entry (RLE-collapsed) instead of repeating the
95// file path per row.
96// `file_of` is a plain `fn` pointer, not `impl Fn`: every caller passes a
97// non-capturing closure (only ever reads its own parameter), and a `fn`
98// pointer's elided input/output lifetimes are implicitly higher-ranked
99// (`for<'a> fn(&'a R) -> &'a str`), which lets the SAME accessor be reused
100// across several `compact_block` calls below without the compiler pinning
101// it to one concrete lifetime (which is what happens if the parameter is
102// `impl Fn(&R) -> &str` and the closure is bound to a `let` once and
103// passed to multiple call sites).
104fn group_by_file<R>(
105    rows: &[R],
106    file_of: fn(&R) -> &str,
107    line_fmt: impl Fn(&R) -> String,
108) -> Vec<String> {
109    let mut out = Vec::new();
110    let mut i = 0;
111    while i < rows.len() {
112        let mut j = i + 1;
113        while j < rows.len() && file_of(&rows[j]) == file_of(&rows[i]) {
114            j += 1;
115        }
116        let lines: Vec<String> = rows[i..j].iter().map(|r| line_fmt(r)).collect();
117        out.push(format!("{}:{}", file_of(&rows[i]), rle(&lines).join(",")));
118        i = j;
119    }
120    out
121}
122
123// Used by the DEFAULT (non-compact) renderer: a missing table is skipped, but a
124// present-but-empty table still prints its header, just with no row lines under
125// it.
126fn ref_kind_block<R>(
127    out: &mut Vec<String>,
128    label: &str,
129    table: Option<&query::Table<R>>,
130    row_fmt: impl Fn(&R) -> String,
131) {
132    let Some(t) = table else { return };
133    let dropped_note = if t.dropped != 0 {
134        format!(", {} dropped", t.dropped)
135    } else {
136        String::new()
137    };
138    out.push(format!("  {label} ({}{dropped_note}):", t.total));
139    for r in &t.rows {
140        out.push(format!("    {}", row_fmt(r)));
141    }
142}
143
144// Used by `--compact`: a missing OR present-but-EMPTY table prints nothing at
145// all, unlike `ref_kind_block` above.
146fn compact_block<R>(
147    out: &mut Vec<String>,
148    label: &str,
149    table: Option<&query::Table<R>>,
150    file_of: fn(&R) -> &str,
151    line_fmt: impl Fn(&R) -> String,
152) {
153    let Some(t) = table else { return };
154    if t.total == 0 {
155        return;
156    }
157    let dropped_note = if t.dropped != 0 {
158        format!(", {} dropped", t.dropped)
159    } else {
160        String::new()
161    };
162    out.push(format!("{label} ({}{dropped_note}):", t.total));
163    for line in group_by_file(&t.rows, file_of, line_fmt) {
164        out.push(format!("  {line}"));
165    }
166}
167
168// The one line that splits an enum's inbound member edges by which MEMBER they
169// land on. `refs Toggles` already counted them all under `uses-member`; this
170// says how many of that total were member-level and which members carried them,
171// which is the half of the answer a caller otherwise has to run a second query
172// (or a grep) to get. Printed only for an enum with at least one member-level
173// reference, so no other symbol's output moves.
174fn member_refs_line(m: &query::MemberRefs) -> String {
175    let named = m
176        .members
177        .iter()
178        .map(|e| format!("{} {}", e.name, e.count))
179        .collect::<Vec<_>>()
180        .join(", ");
181    let more = if m.dropped != 0 {
182        format!(" +{} more", m.dropped)
183    } else {
184        String::new()
185    };
186    format!(
187        "member refs: {} across {} member(s): {named}{more}",
188        m.total, m.member_count
189    )
190}
191
192/// Renders a reference-query model as human-readable text.
193pub fn render_refs_text(model: &query::RefsModel) -> String {
194    let mut out: Vec<String> = Vec::new();
195    out.push(format!("{}  ({})", model.id, model.kind));
196    out.push(format!(
197        "def: {}",
198        model
199            .sites
200            .iter()
201            .map(|s| format!("{}:{}", s.file, s.line))
202            .collect::<Vec<_>>()
203            .join("  ")
204    ));
205
206    out.push("inbound:".to_string());
207    ref_kind_block(
208        &mut out,
209        "inherits",
210        Some(&model.inbound.inherits),
211        |r: &query::InboundRow| {
212            format!(
213                "{}:{}  inherits{}{}",
214                r.file,
215                r.line,
216                heuristic_suffix(r.heuristic),
217                source_suffix(&r.source)
218            )
219        },
220    );
221    ref_kind_block(
222        &mut out,
223        "uses-type",
224        Some(&model.inbound.uses_type),
225        |r: &query::InboundRow| {
226            format!(
227                "{}:{}  uses-type{}{}",
228                r.file,
229                r.line,
230                heuristic_suffix(r.heuristic),
231                source_suffix(&r.source)
232            )
233        },
234    );
235    ref_kind_block(
236        &mut out,
237        "uses-member",
238        Some(&model.inbound.uses_member),
239        |r: &query::InboundRow| {
240            format!(
241                "{}:{}  uses-member{}{}",
242                r.file,
243                r.line,
244                heuristic_suffix(r.heuristic),
245                source_suffix(&r.source)
246            )
247        },
248    );
249    // One trailer for the three kinds, because they share one cap: the
250    // per-kind headers say how much each kind lost, this says what the call as
251    // a whole did not return. `--all` lifts this cap too, the same lever the
252    // outbound trailer below names, but the text here already reports the true
253    // drop count and is unchanged whether or not `--all` is set.
254    let inbound_dropped = model.inbound.inherits.dropped
255        + model.inbound.uses_type.dropped
256        + model.inbound.uses_member.dropped;
257    if inbound_dropped != 0 {
258        out.push(format!("  +{inbound_dropped} more"));
259    }
260    if let Some(m) = &model.member_refs {
261        out.push(member_refs_line(m));
262    }
263
264    if let Some(ob) = &model.outbound {
265        out.push("outbound:".to_string());
266        ref_kind_block(
267            &mut out,
268            "inherits",
269            Some(&ob.inherits),
270            |r: &query::OutboundRow| {
271                format!(
272                    "{}:{}  inherits  -> {}{}{}",
273                    r.file,
274                    r.line,
275                    r.to_file,
276                    heuristic_suffix(r.heuristic),
277                    source_suffix(&r.source)
278                )
279            },
280        );
281        ref_kind_block(
282            &mut out,
283            "uses-type",
284            Some(&ob.uses_type),
285            |r: &query::OutboundRow| {
286                format!(
287                    "{}:{}  uses-type  -> {}{}{}",
288                    r.file,
289                    r.line,
290                    r.to_file,
291                    heuristic_suffix(r.heuristic),
292                    source_suffix(&r.source)
293                )
294            },
295        );
296        ref_kind_block(
297            &mut out,
298            "uses-member",
299            Some(&ob.uses_member),
300            |r: &query::OutboundRow| {
301                format!(
302                    "{}:{}  uses-member  -> {}{}{}",
303                    r.file,
304                    r.line,
305                    r.to_file,
306                    heuristic_suffix(r.heuristic),
307                    source_suffix(&r.source)
308                )
309            },
310        );
311        ref_kind_block(
312            &mut out,
313            "imports",
314            Some(&ob.imports),
315            |r: &query::ImportRow| {
316                format!(
317                    "{}:{}  imports  -> {}{}",
318                    r.file,
319                    r.line,
320                    r.target,
321                    source_suffix(&r.source)
322                )
323            },
324        );
325        // One trailer for the four outbound kinds, because they share one cap
326        // (mirrors the inbound trailer above). Printing only ever happens when
327        // `--all` would actually return more rows, so the hint is never dead
328        // advice -- true of the inbound trailer above too, which is why that one
329        // stays text-only rather than gaining the same hint.
330        let outbound_dropped = ob.inherits.dropped
331            + ob.uses_type.dropped
332            + ob.uses_member.dropped
333            + ob.imports.dropped;
334        if outbound_dropped != 0 {
335            out.push(format!("  +{outbound_dropped} more, use --all"));
336        }
337    }
338
339    let amb_in = &model.ambiguous.inbound;
340    let amb_out = &model.ambiguous.outbound;
341    if amb_in.total != 0 || amb_out.total != 0 {
342        out.push("ambiguous:".to_string());
343        let amb_row = |r: &query::AmbiguousRow| {
344            format!(
345                "{}:{}  {}  raw=\"{}\"  candidates={}",
346                r.file, r.line, r.origin, r.raw, r.candidate_count
347            )
348        };
349        if amb_in.total != 0 {
350            ref_kind_block(&mut out, "inbound", Some(amb_in), amb_row);
351        }
352        if amb_out.total != 0 {
353            ref_kind_block(&mut out, "outbound", Some(amb_out), amb_row);
354        }
355    }
356
357    if model.manifest_gap != 0 {
358        out.push(format!(
359            "manifest gap: {} graph file(s) not in manifest",
360            model.manifest_gap
361        ));
362    }
363    out.join("\n")
364}
365
366/// Default (non-compact) `impact` rendering.
367pub fn render_impact_text(query: &str, model: &query::ImpactModel) -> String {
368    let mut out: Vec<String> = Vec::new();
369    let joined = model.seed_files.join(", ");
370    let seeds = if joined.is_empty() {
371        "-".to_string()
372    } else {
373        joined
374    };
375    out.push(format!(
376        "impact: {query}  ({}, seed files: {seeds})  hops<={}",
377        seed_kind_str(model.kind),
378        model.hops
379    ));
380    // The affected count stays the count of files reached by real edges, and
381    // the guesses are declared beside it rather than folded into it. The
382    // parenthetical appears ONLY when there is something to declare, so a
383    // graph with no heuristic edges renders this line byte-for-byte unchanged.
384    let heuristic_note = if model.heuristic_affected != 0 {
385        format!(" (+{} heuristic)", model.heuristic_affected)
386    } else {
387        String::new()
388    };
389    // Test-coverage stage -- the term joins the summary only when the blast
390    // radius actually reaches a test file, so a graph built before this stage
391    // (and every query that reaches none) renders the line byte-for-byte as it
392    // always did. Its ABSENCE is the gap signal, which is why it is never
393    // printed as `tests=0`.
394    let tests_note = if model.tests_affected != 0 {
395        format!(" tests={}", model.tests_affected)
396    } else {
397        String::new()
398    };
399    out.push(format!(
400        "affected files: {}{heuristic_note}  shown: {}  dropped: {}{tests_note}",
401        model.total_affected,
402        model.rows.len(),
403        model.dropped
404    ));
405    out.push("file  hops  via  top-symbols".to_string());
406    for r in &model.rows {
407        let mut syms = r.top_symbols.join(", ");
408        if r.top_symbols_more != 0 {
409            syms.push_str(&format!(" +{}", r.top_symbols_more));
410        }
411        // A heuristic-only row's viaCount is zero by definition -- printing it
412        // would claim the file was reached by nothing. It reports the guesses
413        // that DID reach it; the row's own suffix is what says they were
414        // guesses.
415        let via = if r.heuristic {
416            r.heuristic_count.to_string()
417        } else if r.ambiguous_count != 0 {
418            format!("{}(+{}amb)", r.via_count, r.ambiguous_count)
419        } else {
420            r.via_count.to_string()
421        };
422        // Present only on a row the interface hop actually reached, so every
423        // other row (and the whole line under `--no-iface`) carries no `via`
424        // suffix.
425        let iface_via = if r.iface_via.is_empty() {
426            String::new()
427        } else {
428            format!("  via {}", r.iface_via.join(", "))
429        };
430        // The same conditional-suffix rule again: a hub file says so on its own
431        // row, so the reader never has to join the trailer to the table to learn
432        // which row the walk stopped at.
433        let class_suffix = if r.infra { INFRA_SUFFIX } else { "" };
434        out.push(format!(
435            "{}  {}  {via}  {syms}{iface_via}{}{class_suffix}",
436            r.file,
437            r.hop,
438            heuristic_suffix(r.heuristic)
439        ));
440    }
441    // A trailer, not a footnote: the rows above are NARROWER than the graph
442    // allows, and the line names which contracts were held back, how broad each
443    // one is, and the flag that lets them through. A narrowing nobody can see is
444    // indistinguishable from a missing edge. Printed only when the brake fired.
445    if !model.braked.is_empty() {
446        let list = model
447            .braked
448            .iter()
449            .map(|b| format!("{} (fan-in {})", b.iface, b.fanin))
450            .collect::<Vec<_>>()
451            .join(", ");
452        out.push(format!("braked: {list} — raise --iface-max-fanin to widen"));
453    }
454    // Its own trailer line, not a term on the interface brake's: the two brakes
455    // are undone by two different flags, and a line naming both would leave the
456    // reader guessing which flag widens which name.
457    if !model.braked_files.is_empty() {
458        let list = model
459            .braked_files
460            .iter()
461            .map(|b| format!("{} (in-degree {})", b.file, b.indegree))
462            .collect::<Vec<_>>()
463            .join(", ");
464        out.push(format!(
465            "braked: {list} — raise --hub-max-indegree to widen"
466        ));
467    }
468    if model.manifest_gap != 0 {
469        out.push(format!(
470            "manifest gap: {} graph file(s) not in manifest",
471            model.manifest_gap
472        ));
473    }
474    out.join("\n")
475}
476
477/// `--compact` `refs` rendering.
478pub fn render_refs_compact(model: &query::RefsModel) -> String {
479    let mut out: Vec<String> = Vec::new();
480    out.push(format!("{}  ({})", model.id, model.kind));
481    out.push(format!(
482        "def: {}",
483        model
484            .sites
485            .iter()
486            .map(|s| format!("{}:{}", s.file, s.line))
487            .collect::<Vec<_>>()
488            .join("  ")
489    ));
490
491    // `file_of_*` are explicitly typed as `fn(&R) -> &str` (not left to
492    // inference) so the closure is assigned its higher-ranked signature
493    // (`for<'a> fn(&'a R) -> &'a str`) at the point of declaration --
494    // without the annotation, an unannotated `let` binds the closure to
495    // one concrete (non-reusable) lifetime instead, which then fails to
496    // coerce back down at `compact_block`'s `fn` parameter on the second
497    // and later call sites below.
498    // Compact mode exists to spend as few bytes as possible, so the marker
499    // here is one character rather than the default renderer's word. It is
500    // still mandatory: an unmarked guess sitting in a list of facts is the
501    // exact failure this resolver is built to avoid, and a small output is no
502    // defence. `5h` never collides with a line number, and the run-length
503    // collapse treats `5` and `5h` as the distinct entries they are.
504    //
505    // Compact groups a file's hits into one `path:line,line` entry, which
506    // leaves no slot for a per-hit source line -- the snippet is a
507    // default-renderer and `--json` affordance only.
508    let file_of_ib: fn(&query::InboundRow) -> &str = |r| r.file.as_str();
509    let line_ib = |r: &query::InboundRow| {
510        if r.heuristic {
511            format!("{}h", r.line)
512        } else {
513            r.line.to_string()
514        }
515    };
516    compact_block(
517        &mut out,
518        "in:inherits",
519        Some(&model.inbound.inherits),
520        file_of_ib,
521        line_ib,
522    );
523    compact_block(
524        &mut out,
525        "in:uses-type",
526        Some(&model.inbound.uses_type),
527        file_of_ib,
528        line_ib,
529    );
530    compact_block(
531        &mut out,
532        "in:uses-member",
533        Some(&model.inbound.uses_member),
534        file_of_ib,
535        line_ib,
536    );
537
538    if let Some(ob) = &model.outbound {
539        let file_of_ob: fn(&query::OutboundRow) -> &str = |r| r.file.as_str();
540        let line_ob = |r: &query::OutboundRow| {
541            if r.heuristic {
542                format!("{}h", r.line)
543            } else {
544                r.line.to_string()
545            }
546        };
547        compact_block(
548            &mut out,
549            "out:inherits",
550            Some(&ob.inherits),
551            file_of_ob,
552            line_ob,
553        );
554        compact_block(
555            &mut out,
556            "out:uses-type",
557            Some(&ob.uses_type),
558            file_of_ob,
559            line_ob,
560        );
561        compact_block(
562            &mut out,
563            "out:uses-member",
564            Some(&ob.uses_member),
565            file_of_ob,
566            line_ob,
567        );
568
569        let file_of_imp: fn(&query::ImportRow) -> &str = |r| r.file.as_str();
570        let line_imp = |r: &query::ImportRow| r.line.to_string();
571        compact_block(
572            &mut out,
573            "out:imports",
574            Some(&ob.imports),
575            file_of_imp,
576            line_imp,
577        );
578    }
579
580    let amb_in = &model.ambiguous.inbound;
581    let amb_out = &model.ambiguous.outbound;
582    let file_of_amb: fn(&query::AmbiguousRow) -> &str = |r| r.file.as_str();
583    let line_amb =
584        |r: &query::AmbiguousRow| format!("{}(candidates={})", r.line, r.candidate_count);
585    compact_block(&mut out, "amb:in", Some(amb_in), file_of_amb, line_amb);
586    compact_block(&mut out, "amb:out", Some(amb_out), file_of_amb, line_amb);
587
588    // The missing-table tolerance documented at the top of this file is what an
589    // absent `outbound` falls through here: the three outbound tables plus
590    // imports contribute nothing to any of the three sums when the caller did not
591    // ask for them.
592    let ob = model.outbound.as_ref();
593    let ob_sum = |f: fn(&query::OutboundTables) -> usize| ob.map_or(0, f);
594    let edges = model.inbound.inherits.total
595        + model.inbound.uses_type.total
596        + model.inbound.uses_member.total
597        + ob_sum(|o| o.inherits.total + o.uses_type.total + o.uses_member.total + o.imports.total);
598    let shown = model.inbound.inherits.rows.len()
599        + model.inbound.uses_type.rows.len()
600        + model.inbound.uses_member.rows.len()
601        + ob_sum(|o| {
602            o.inherits.rows.len()
603                + o.uses_type.rows.len()
604                + o.uses_member.rows.len()
605                + o.imports.rows.len()
606        });
607    let dropped = model.inbound.inherits.dropped
608        + model.inbound.uses_type.dropped
609        + model.inbound.uses_member.dropped
610        + ob_sum(|o| {
611            o.inherits.dropped + o.uses_type.dropped + o.uses_member.dropped + o.imports.dropped
612        });
613    let ambiguous = amb_in.total + amb_out.total;
614    let gap = if model.manifest_gap != 0 {
615        format!(" gap={}", model.manifest_gap)
616    } else {
617        String::new()
618    };
619    // Same fact as the default renderer's line, spelled the way every other
620    // compact block is: `name=count`, no prose, and absent entirely when there is
621    // nothing to say.
622    if let Some(m) = &model.member_refs {
623        let named = m
624            .members
625            .iter()
626            .map(|e| format!("{}={}", e.name, e.count))
627            .collect::<Vec<_>>()
628            .join(",");
629        let more = if m.dropped != 0 {
630            format!(",+{}", m.dropped)
631        } else {
632            String::new()
633        };
634        out.push(format!("mem: {named}{more}"));
635    }
636    out.push(format!(
637        "summary: edges={edges} shown={shown} dropped={dropped} ambiguous={ambiguous}{gap}"
638    ));
639    out.join("\n")
640}
641
642// The `def:` line both `read` renderers open with: the declaring sites in
643// refs' own order, with the PRIMARY site carrying the recorded span
644// (`file:start-end`) when one is on record. A site without a span fact
645// prints start-only -- an absent end is never rendered as a range. When the
646// model carries a source block it goes under this line, verbatim.
647fn read_def_line(model: &query::ReadModel) -> String {
648    let sites = &model.refs.sites;
649    if sites.is_empty() {
650        return "def: -".to_string();
651    }
652    let rendered: Vec<String> = sites
653        .iter()
654        .enumerate()
655        .map(|(i, s)| match (i, &model.span) {
656            (0, Some(sp)) => format!("{}:{}-{}", sp.file, sp.start_line, sp.end_line),
657            _ => format!("{}:{}", s.file, s.line),
658        })
659        .collect();
660    format!("def: {}", rendered.join("  "))
661}
662
663/// Default (non-compact) `read` rendering: the declaration span and its
664///
665/// verbatim source, then exactly the inbound answer `refs` gives -- same
666/// tables, same cap trailer, same ambiguous section and manifest-gap note.
667pub fn render_read_text(model: &query::ReadModel) -> String {
668    let rendered = render_refs_text(&model.refs);
669    let mut out: Vec<String> = rendered.split('\n').map(str::to_string).collect();
670    if out.len() > 1 {
671        out[1] = read_def_line(model);
672    }
673    if let Some(sp) = &model.span {
674        out.splice(2..2, sp.source.split('\n').map(str::to_string));
675    }
676    out.join("\n")
677}
678
679/// `--compact` `read` rendering: the span on the def line, no source block,
680///
681/// then the compact inbound blocks and summary. Everything `refs --compact`
682/// prints minus the outbound side, which `read` never computes.
683pub fn render_read_compact(model: &query::ReadModel) -> String {
684    let rendered = render_refs_compact(&model.refs);
685    let mut out: Vec<String> = rendered.split('\n').map(str::to_string).collect();
686    if out.len() > 1 {
687        out[1] = read_def_line(model);
688    }
689    out.join("\n")
690}
691
692/// `--compact` `impact` rendering.
693pub fn render_impact_compact(query: &str, model: &query::ImpactModel) -> String {
694    let mut out: Vec<String> = Vec::new();
695    let joined = model.seed_files.join(", ");
696    let seeds = if joined.is_empty() {
697        "-".to_string()
698    } else {
699        joined
700    };
701    out.push(format!(
702        "impact: {query}  ({}, seed: {seeds})  hops<={}",
703        seed_kind_str(model.kind),
704        model.hops
705    ));
706
707    // The hop grouping preserves nothing order-relevant (the hop keys are
708    // explicitly re-sorted right after), so a plain HashMap is fine. Row order
709    // WITHIN a hop bucket must stay in `model.rows`' original
710    // (score-desc/hop/file-sorted) order for `rle` to collapse the intended runs
711    // -- `Vec::push` in a single forward pass preserves that.
712    let mut by_hop: std::collections::HashMap<u32, Vec<&query::ImpactRow>> =
713        std::collections::HashMap::new();
714    for r in &model.rows {
715        by_hop.entry(r.hop).or_default().push(r);
716    }
717    let mut hops: Vec<u32> = by_hop.keys().copied().collect();
718    hops.sort_unstable();
719    for hop in hops {
720        let rows = &by_hop[&hop];
721        out.push(format!("hop {hop} ({}):", rows.len()));
722        let lines: Vec<String> = rows
723            .iter()
724            .map(|r| {
725                // A heuristic-only row has a viaCount of zero by definition, so
726                // printing `via=0` would say "reached by nothing". It reports
727                // the guess count it was actually reached by instead, marked
728                // with the same `h`.
729                if r.heuristic {
730                    return format!("{} via={}h", r.file, r.heuristic_count);
731                }
732                let via = if r.ambiguous_count != 0 {
733                    format!("{}(+{}amb)", r.via_count, r.ambiguous_count)
734                } else {
735                    r.via_count.to_string()
736                };
737                // Same conditional-suffix rule as the default renderer's `via`.
738                let iface_suffix = if r.iface_via.is_empty() {
739                    String::new()
740                } else {
741                    format!(" iface={}", r.iface_via.join(","))
742                };
743                // Same conditional-suffix rule as the default renderer's class.
744                let class_suffix = if r.infra { " class=infra" } else { "" };
745                format!("{} via={via}{iface_suffix}{class_suffix}", r.file)
746            })
747            .collect();
748        for line in rle(&lines) {
749            out.push(format!("  {line}"));
750        }
751    }
752
753    let ambiguous: u32 = model.rows.iter().map(|r| r.ambiguous_count).sum();
754    let gap = if model.manifest_gap != 0 {
755        format!(" gap={}", model.manifest_gap)
756    } else {
757        String::new()
758    };
759    // `affected` counts precisely-reached files; the heuristic term joins it
760    // only when there is one, and always BEFORE ` gap=`, so a graph with no
761    // heuristic edges renders the same summary line.
762    let heur = if model.heuristic_affected != 0 {
763        format!(" heuristic={}", model.heuristic_affected)
764    } else {
765        String::new()
766    };
767    let tests = if model.tests_affected != 0 {
768        format!(" tests={}", model.tests_affected)
769    } else {
770        String::new()
771    };
772    // The compact spelling of the default renderer's `braked:` trailer, appended
773    // LAST so every summary line the brake never touched is unchanged. One
774    // `braked=` term still, file entries after interface ones, each spelled
775    // `name:number` exactly as an interface entry.
776    let braked = if model.braked.is_empty() && model.braked_files.is_empty() {
777        String::new()
778    } else {
779        let terms: Vec<String> = model
780            .braked
781            .iter()
782            .map(|b| format!("{}:{}", b.iface, b.fanin))
783            .chain(
784                model
785                    .braked_files
786                    .iter()
787                    .map(|b| format!("{}:{}", b.file, b.indegree)),
788            )
789            .collect();
790        format!(" braked={}", terms.join(","))
791    };
792    out.push(format!(
793        "summary: affected={} shown={} dropped={} ambiguous={ambiguous}{heur}{tests}{gap}{braked}",
794        model.total_affected,
795        model.rows.len(),
796        model.dropped
797    ));
798    out.join("\n")
799}
800
801/// Default `devscout tests` output. The zero case is a first-class answer, not
802/// an empty table: "nothing tests this" is exactly what the caller asked, and it
803/// is one line rather than a header over a void.
804///
805/// Heuristic file lines carry the SAME word-suffix the refs/impact renderers
806/// use, and the summary counts stay precise-only, so a file listed under a
807/// covered count is a file a test really references.
808pub fn render_tests_text(model: &query::TestsModel) -> String {
809    let mut out: Vec<String> = vec![format!("tests for {}", model.symbol)];
810    if model.rows.is_empty() {
811        out.push("no test references found".to_string());
812        return out.join("\n");
813    }
814    out.push(format!(
815        "covered by {} test file(s), {} reference(s)",
816        model.test_file_count, model.ref_count
817    ));
818    out.push(String::new());
819    for r in &model.rows {
820        out.push(format!("{}{}", r.file, heuristic_suffix(r.heuristic)));
821        let lines = r
822            .lines
823            .iter()
824            .map(|l| l.to_string())
825            .collect::<Vec<_>>()
826            .join(", ");
827        for def_id in &r.test_defs {
828            out.push(format!("  {def_id}  lines: {lines}"));
829        }
830    }
831    out.join("\n")
832}
833
834/// `--compact` `devscout tests` output. The same model with the def ids and the
835/// per-file indentation dropped: one header line carrying every count, then one
836/// line per file. The `h` line marker is the compact renderer's existing
837/// convention for a guess.
838pub fn render_tests_compact(model: &query::TestsModel) -> String {
839    let heur = if model.heuristic_file_count != 0 {
840        format!(" heuristic={}", model.heuristic_file_count)
841    } else {
842        String::new()
843    };
844    let mut out: Vec<String> = vec![format!(
845        "tests {} files={} refs={}{heur}",
846        model.symbol, model.test_file_count, model.ref_count
847    )];
848    for r in &model.rows {
849        let lines = r
850            .lines
851            .iter()
852            .map(|l| {
853                if r.heuristic {
854                    format!("{l}h")
855                } else {
856                    l.to_string()
857                }
858            })
859            .collect::<Vec<_>>()
860            .join(",");
861        out.push(format!("{} {lines}", r.file));
862    }
863    out.join("\n")
864}
865
866#[cfg(test)]
867mod tests {
868    use super::*;
869    use crate::query::{
870        AmbiguousRow, AmbiguousTables, DefSite, ImpactModel, ImpactRow, ImportRow, InboundRow,
871        InboundTables, OutboundRow, OutboundTables, RefsModel, SeedKind, Table, TestRow,
872        TestsModel,
873    };
874
875    fn table<R>(rows: Vec<R>, dropped: usize) -> Table<R> {
876        Table {
877            total: rows.len() + dropped,
878            dropped,
879            rows,
880        }
881    }
882
883    fn empty_inbound() -> InboundTables {
884        InboundTables {
885            inherits: table(vec![], 0),
886            uses_type: table(vec![], 0),
887            uses_member: table(vec![], 0),
888        }
889    }
890    fn empty_outbound() -> OutboundTables {
891        OutboundTables {
892            inherits: table(vec![], 0),
893            uses_type: table(vec![], 0),
894            uses_member: table(vec![], 0),
895            imports: table(vec![], 0),
896        }
897    }
898    fn empty_ambiguous() -> AmbiguousTables {
899        AmbiguousTables {
900            inbound: table(vec![], 0),
901            outbound: table(vec![], 0),
902        }
903    }
904
905    fn refs_model(
906        inbound: InboundTables,
907        outbound: OutboundTables,
908        ambiguous: AmbiguousTables,
909        manifest_gap: usize,
910    ) -> RefsModel {
911        RefsModel {
912            query: "IFoo".to_string(),
913            id: "App.IFoo".to_string(),
914            kind: "interface".to_string(),
915            sites: vec![DefSite {
916                file: "src/IFoo.cs".to_string(),
917                line: 3,
918            }],
919            inbound,
920            outbound: Some(outbound),
921            ambiguous,
922            manifest_gap,
923            member_refs: None,
924        }
925    }
926
927    // --- render_refs_compact fixtures --------------------------------------
928
929    #[test]
930    fn refs_compact_one_header_per_kind_empty_kinds_print_nothing() {
931        let mut inbound = empty_inbound();
932        inbound.inherits = table(
933            vec![InboundRow {
934                file: "src/Foo.cs".into(),
935                line: 5,
936                heuristic: false,
937                source: String::new(),
938            }],
939            0,
940        );
941        let mut outbound = empty_outbound();
942        outbound.uses_type = table(
943            vec![OutboundRow {
944                file: "src/IFoo.cs".into(),
945                line: 3,
946                to_file: "src/Bar.cs".into(),
947                to: "App.Bar".into(),
948                heuristic: false,
949                source: String::new(),
950            }],
951            0,
952        );
953        let model = refs_model(inbound, outbound, empty_ambiguous(), 0);
954        let out = render_refs_compact(&model);
955        assert!(out.contains("in:inherits (1):\n  src/Foo.cs:5"));
956        assert!(out.contains("out:uses-type (1):\n  src/IFoo.cs:3"));
957        assert!(
958            !out.contains("in:uses-type"),
959            "a kind with zero rows must not print a header"
960        );
961        assert!(!out.contains("out:inherits"));
962        assert!(!out.contains("out:imports"));
963        assert!(
964            !out.contains("Bar.cs"),
965            "outbound target file is dropped in compact mode -- only path:line survives"
966        );
967    }
968
969    #[test]
970    fn refs_compact_same_file_line_collapses_to_nxn() {
971        let mut outbound = empty_outbound();
972        outbound.inherits = table(
973            vec![
974                OutboundRow {
975                    file: "src/Widget.cs".into(),
976                    line: 5,
977                    to_file: "src/IWidget.cs".into(),
978                    to: "App.IWidget".into(),
979                    heuristic: false,
980                    source: String::new(),
981                },
982                OutboundRow {
983                    file: "src/Widget.cs".into(),
984                    line: 5,
985                    to_file: "src/IGadget.cs".into(),
986                    to: "App.IGadget".into(),
987                    heuristic: false,
988                    source: String::new(),
989                },
990            ],
991            0,
992        );
993        let model = refs_model(empty_inbound(), outbound, empty_ambiguous(), 0);
994        let out = render_refs_compact(&model);
995        assert!(out.contains("out:inherits (2):\n  src/Widget.cs:5x2"));
996    }
997
998    #[test]
999    fn refs_compact_groups_multiple_lines_under_one_file_mention() {
1000        let mut inbound = empty_inbound();
1001        inbound.uses_type = table(
1002            vec![
1003                InboundRow {
1004                    file: "src/Consumers/Big.cs".into(),
1005                    line: 12,
1006                    heuristic: false,
1007                    source: String::new(),
1008                },
1009                InboundRow {
1010                    file: "src/Consumers/Big.cs".into(),
1011                    line: 40,
1012                    heuristic: false,
1013                    source: String::new(),
1014                },
1015                InboundRow {
1016                    file: "src/Consumers/Big.cs".into(),
1017                    line: 40,
1018                    heuristic: false,
1019                    source: String::new(),
1020                },
1021                InboundRow {
1022                    file: "src/Consumers/Small.cs".into(),
1023                    line: 3,
1024                    heuristic: false,
1025                    source: String::new(),
1026                },
1027            ],
1028            0,
1029        );
1030        let model = refs_model(inbound, empty_outbound(), empty_ambiguous(), 0);
1031        let out = render_refs_compact(&model);
1032        assert!(out.contains(
1033            "in:uses-type (4):\n  src/Consumers/Big.cs:12,40x2\n  src/Consumers/Small.cs:3"
1034        ));
1035        assert_eq!(
1036            out.matches("src/Consumers/Big.cs").count(),
1037            1,
1038            "the repeated file path must appear exactly once"
1039        );
1040    }
1041
1042    #[test]
1043    fn refs_compact_summary_line_folds_all_counts() {
1044        let mut inbound = empty_inbound();
1045        inbound.inherits = table(
1046            vec![
1047                InboundRow {
1048                    file: "src/A.cs".into(),
1049                    line: 1,
1050                    heuristic: false,
1051                    source: String::new(),
1052                },
1053                InboundRow {
1054                    file: "src/B.cs".into(),
1055                    line: 2,
1056                    heuristic: false,
1057                    source: String::new(),
1058                },
1059            ],
1060            3,
1061        );
1062        let mut ambiguous = empty_ambiguous();
1063        ambiguous.inbound = table(
1064            vec![AmbiguousRow {
1065                file: "src/C.cs".into(),
1066                line: 9,
1067                origin: "uses-type".into(),
1068                raw: "Config".into(),
1069                candidate_count: 2,
1070            }],
1071            0,
1072        );
1073        let model = refs_model(inbound, empty_outbound(), ambiguous, 4);
1074        let out = render_refs_compact(&model);
1075        let last_line = out.lines().last().unwrap();
1076        assert_eq!(
1077            last_line,
1078            "summary: edges=5 shown=2 dropped=3 ambiguous=1 gap=4"
1079        );
1080        assert!(out.contains("amb:in (1):\n  src/C.cs:9(candidates=2)"));
1081        assert!(
1082            !out.contains("Config"),
1083            "the raw ambiguous token is not a minimal column and must not appear"
1084        );
1085    }
1086
1087    fn impact_model(
1088        rows: Vec<ImpactRow>,
1089        total_affected: usize,
1090        dropped: usize,
1091        manifest_gap: usize,
1092    ) -> ImpactModel {
1093        let heuristic_affected = rows.iter().filter(|r| r.heuristic).count();
1094        ImpactModel {
1095            kind: SeedKind::File,
1096            seed_files: vec!["src/IFoo.cs".to_string()],
1097            hops: 2,
1098            total_affected,
1099            rows,
1100            dropped,
1101            manifest_gap,
1102            heuristic_affected,
1103            tests_affected: 0,
1104            braked: vec![],
1105            braked_files: vec![],
1106        }
1107    }
1108
1109    /// A precisely-reached impact row (both heuristic flags off).
1110    fn impact_row(
1111        file: &str,
1112        hop: u32,
1113        via_count: u32,
1114        ambiguous_count: u32,
1115        top_symbols: &[&str],
1116        top_symbols_more: usize,
1117        score: f64,
1118    ) -> ImpactRow {
1119        ImpactRow {
1120            file: file.into(),
1121            hop,
1122            via_count,
1123            ambiguous_count,
1124            top_symbols: top_symbols.iter().map(|s| (*s).to_string()).collect(),
1125            top_symbols_more,
1126            score,
1127            heuristic_count: 0,
1128            heuristic: false,
1129            iface_via: vec![],
1130            from_lines: vec![],
1131            infra: false,
1132        }
1133    }
1134
1135    /// A heuristic-ONLY row: reached by `heuristic_count` guesses and nothing
1136    /// else, which is the only shape `buildImpactModel` ever flags.
1137    fn heuristic_impact_row(
1138        file: &str,
1139        hop: u32,
1140        heuristic_count: u32,
1141        top_symbols: &[&str],
1142        score: f64,
1143    ) -> ImpactRow {
1144        ImpactRow {
1145            heuristic_count,
1146            heuristic: true,
1147            ..impact_row(file, hop, 0, 0, top_symbols, 0, score)
1148        }
1149    }
1150
1151    #[test]
1152    fn impact_compact_groups_rows_into_ascending_hop_buckets() {
1153        let model = impact_model(
1154            vec![
1155                impact_row("src/Two.cs", 2, 1, 0, &["Foo"], 0, 0.1),
1156                impact_row("src/One.cs", 1, 2, 1, &["Foo", "Bar"], 5, 0.5),
1157            ],
1158            3,
1159            0,
1160            0,
1161        );
1162        let out = render_impact_compact("IFoo", &model);
1163        let hop1 = out.find("hop 1").unwrap();
1164        let hop2 = out.find("hop 2").unwrap();
1165        assert!(
1166            hop1 < hop2,
1167            "hop 1 bucket must precede hop 2 regardless of row order in the model"
1168        );
1169        assert!(out.contains("hop 1 (1):\n  src/One.cs via=2(+1amb)"));
1170        assert!(out.contains("hop 2 (1):\n  src/Two.cs via=1"));
1171        assert!(!out.contains("Bar"), "compact drops top-symbols entirely");
1172        assert!(out.contains("summary: affected=3 shown=2 dropped=0 ambiguous=1"));
1173    }
1174
1175    #[test]
1176    fn impact_compact_manifest_gap_folds_into_summary() {
1177        let model = impact_model(vec![], 0, 0, 2);
1178        let out = render_impact_compact("IFoo", &model);
1179        let last_line = out.lines().last().unwrap();
1180        assert_eq!(
1181            last_line,
1182            "summary: affected=0 shown=0 dropped=0 ambiguous=0 gap=2"
1183        );
1184    }
1185
1186    // --- Missing-table tolerance, tested directly on the helpers (see module
1187    // header: unreachable via the typed RefsModel/ImpactModel, but ported). ---
1188
1189    #[test]
1190    fn ref_kind_block_missing_table_prints_nothing() {
1191        let mut out: Vec<String> = vec!["before".to_string()];
1192        ref_kind_block::<InboundRow>(&mut out, "inherits", None, |r| {
1193            format!("{}:{}", r.file, r.line)
1194        });
1195        assert_eq!(out, vec!["before".to_string()]);
1196    }
1197
1198    #[test]
1199    fn compact_block_missing_table_prints_nothing() {
1200        let mut out: Vec<String> = vec!["before".to_string()];
1201        compact_block::<InboundRow>(
1202            &mut out,
1203            "in:inherits",
1204            None,
1205            |r| r.file.as_str(),
1206            |r| r.line.to_string(),
1207        );
1208        assert_eq!(out, vec!["before".to_string()]);
1209    }
1210
1211    #[test]
1212    fn ref_kind_block_present_but_empty_table_still_prints_header() {
1213        let mut out: Vec<String> = Vec::new();
1214        let t: Table<InboundRow> = table(vec![], 0);
1215        ref_kind_block(&mut out, "inherits", Some(&t), |r: &InboundRow| {
1216            format!("{}:{}", r.file, r.line)
1217        });
1218        assert_eq!(out, vec!["  inherits (0):".to_string()]);
1219    }
1220
1221    #[test]
1222    fn compact_block_present_but_empty_table_prints_nothing_unlike_ref_kind_block() {
1223        let mut out: Vec<String> = Vec::new();
1224        let t: Table<InboundRow> = table(vec![], 0);
1225        compact_block(
1226            &mut out,
1227            "in:inherits",
1228            Some(&t),
1229            |r: &InboundRow| r.file.as_str(),
1230            |r: &InboundRow| r.line.to_string(),
1231        );
1232        assert!(out.is_empty());
1233    }
1234
1235    #[test]
1236    fn ref_kind_block_dropped_note_only_appears_when_nonzero() {
1237        let mut out: Vec<String> = Vec::new();
1238        let t = table(
1239            vec![InboundRow {
1240                file: "a.cs".into(),
1241                line: 1,
1242                heuristic: false,
1243                source: String::new(),
1244            }],
1245            2,
1246        );
1247        ref_kind_block(&mut out, "inherits", Some(&t), |r: &InboundRow| {
1248            format!("{}:{}", r.file, r.line)
1249        });
1250        assert_eq!(out[0], "  inherits (3, 2 dropped):");
1251    }
1252
1253    #[test]
1254    fn rle_collapses_consecutive_equal_runs_only() {
1255        let values = vec![
1256            "5".to_string(),
1257            "5".to_string(),
1258            "3".to_string(),
1259            "3".to_string(),
1260            "3".to_string(),
1261            "1".to_string(),
1262        ];
1263        assert_eq!(
1264            rle(&values),
1265            vec!["5x2".to_string(), "3x3".to_string(), "1".to_string()]
1266        );
1267    }
1268
1269    #[test]
1270    fn seed_kind_str_matches_js_string_literals() {
1271        assert_eq!(seed_kind_str(SeedKind::File), "file");
1272        assert_eq!(seed_kind_str(SeedKind::Symbol), "symbol");
1273    }
1274
1275    // --- the enum member-count line ----------------------------------------
1276
1277    #[test]
1278    fn member_refs_line_renders_after_the_inbound_block_and_caps_the_named_members() {
1279        let mut model = refs_model(empty_inbound(), empty_outbound(), empty_ambiguous(), 0);
1280        model.member_refs = Some(query::MemberRefs {
1281            total: 3,
1282            member_count: 2,
1283            members: vec![
1284                query::MemberRefEntry {
1285                    name: "EnableX".into(),
1286                    count: 2,
1287                },
1288                query::MemberRefEntry {
1289                    name: "EnableY".into(),
1290                    count: 1,
1291                },
1292            ],
1293            dropped: 0,
1294        });
1295        let out = render_refs_text(&model);
1296        assert!(
1297            out.contains("\nmember refs: 3 across 2 member(s): EnableX 2, EnableY 1"),
1298            "{out}"
1299        );
1300        assert!(
1301            render_refs_compact(&model).contains("\nmem: EnableX=2,EnableY=1\nsummary:"),
1302            "{}",
1303            render_refs_compact(&model)
1304        );
1305
1306        model.member_refs = Some(query::MemberRefs {
1307            total: 9,
1308            member_count: 7,
1309            members: vec![query::MemberRefEntry {
1310                name: "A".into(),
1311                count: 9,
1312            }],
1313            dropped: 6,
1314        });
1315        assert!(
1316            render_refs_text(&model).contains("member refs: 9 across 7 member(s): A 9 +6 more"),
1317            "{}",
1318            render_refs_text(&model)
1319        );
1320        assert!(
1321            render_refs_compact(&model).contains("mem: A=9,+6"),
1322            "{}",
1323            render_refs_compact(&model)
1324        );
1325    }
1326
1327    #[test]
1328    fn a_model_with_no_member_refs_renders_exactly_as_before() {
1329        let out = render_refs_text(&refs_model(
1330            empty_inbound(),
1331            empty_outbound(),
1332            empty_ambiguous(),
1333            0,
1334        ));
1335        assert!(!out.contains("member refs:"), "{out}");
1336        assert!(!render_refs_compact(&refs_model(
1337            empty_inbound(),
1338            empty_outbound(),
1339            empty_ambiguous(),
1340            0
1341        ))
1342        .contains("mem: "));
1343    }
1344
1345    // --- heuristic markers: the render literals ----------------------------
1346
1347    #[test]
1348    fn refs_text_suffixes_only_tagged_rows_and_never_an_imports_row() {
1349        let mut inbound = empty_inbound();
1350        inbound.uses_member = table(
1351            vec![
1352                InboundRow {
1353                    file: "src/Fact.cs".into(),
1354                    line: 4,
1355                    heuristic: false,
1356                    source: String::new(),
1357                },
1358                InboundRow {
1359                    file: "src/Guess.cs".into(),
1360                    line: 9,
1361                    heuristic: true,
1362                    source: String::new(),
1363                },
1364            ],
1365            0,
1366        );
1367        let mut outbound = empty_outbound();
1368        outbound.uses_member = table(
1369            vec![OutboundRow {
1370                file: "src/Guess.cs".into(),
1371                line: 9,
1372                to_file: "src/Widget.cs".into(),
1373                to: "App.Widget".into(),
1374                heuristic: true,
1375                source: String::new(),
1376            }],
1377            0,
1378        );
1379        // An imports row carries no flag at all -- JS reads `r.heuristic` as
1380        // `undefined` there, which renders no suffix; this side has no field
1381        // to read, which is the same thing.
1382        outbound.imports = table(
1383            vec![ImportRow {
1384                file: "src/Guess.cs".into(),
1385                line: 1,
1386                target: "App.Core".into(),
1387                source: String::new(),
1388            }],
1389            0,
1390        );
1391
1392        let out = render_refs_text(&refs_model(inbound, outbound, empty_ambiguous(), 0));
1393        assert!(out.contains("    src/Fact.cs:4  uses-member\n"), "{out}");
1394        assert!(
1395            out.contains("    src/Guess.cs:9  uses-member (heuristic)"),
1396            "{out}"
1397        );
1398        assert!(
1399            out.contains("    src/Guess.cs:9  uses-member  -> src/Widget.cs (heuristic)"),
1400            "{out}"
1401        );
1402        assert!(
1403            out.ends_with("    src/Guess.cs:1  imports  -> App.Core"),
1404            "an imports row is never suffixed\n{out}"
1405        );
1406        assert_eq!(out.matches("(heuristic)").count(), 2);
1407    }
1408
1409    #[test]
1410    fn refs_compact_marks_a_heuristic_row_with_a_trailing_h_and_rle_keeps_them_distinct() {
1411        let mut inbound = empty_inbound();
1412        inbound.uses_member = table(
1413            vec![
1414                InboundRow {
1415                    file: "src/A.cs".into(),
1416                    line: 5,
1417                    heuristic: false,
1418                    source: String::new(),
1419                },
1420                InboundRow {
1421                    file: "src/A.cs".into(),
1422                    line: 5,
1423                    heuristic: true,
1424                    source: String::new(),
1425                },
1426                InboundRow {
1427                    file: "src/A.cs".into(),
1428                    line: 5,
1429                    heuristic: true,
1430                    source: String::new(),
1431                },
1432            ],
1433            0,
1434        );
1435        let out = render_refs_compact(&refs_model(inbound, empty_outbound(), empty_ambiguous(), 0));
1436        assert!(
1437            out.contains("in:uses-member (3):\n  src/A.cs:5,5hx2"),
1438            "`5` and `5h` are distinct RLE entries\n{out}"
1439        );
1440    }
1441
1442    #[test]
1443    fn impact_text_declares_heuristic_reached_files_beside_the_count_only_when_there_are_some() {
1444        let with_guess = impact_model(
1445            vec![
1446                impact_row("src/Direct.cs", 1, 1, 0, &["Widget"], 0, 0.5),
1447                heuristic_impact_row("src/Guessed.cs", 1, 2, &["Widget"], 0.0),
1448            ],
1449            1,
1450            0,
1451            0,
1452        );
1453        let out = render_impact_text("Widget", &with_guess);
1454        assert!(
1455            out.contains("affected files: 1 (+1 heuristic)  shown: 2  dropped: 0"),
1456            "{out}"
1457        );
1458        assert!(out.contains("src/Direct.cs  1  1  Widget\n"), "{out}");
1459        assert!(
1460            out.contains("src/Guessed.cs  1  2  Widget (heuristic)"),
1461            "the via column reports the GUESS count, never the zero viaCount\n{out}"
1462        );
1463
1464        let without = impact_model(
1465            vec![impact_row("src/Direct.cs", 1, 1, 0, &["Widget"], 0, 0.5)],
1466            1,
1467            0,
1468            0,
1469        );
1470        let out = render_impact_text("Widget", &without);
1471        assert!(
1472            out.contains("affected files: 1  shown: 1  dropped: 0"),
1473            "byte-unchanged when there is nothing to declare\n{out}"
1474        );
1475        assert!(!out.contains("heuristic"), "{out}");
1476    }
1477
1478    #[test]
1479    fn impact_compact_summary_inserts_heuristic_before_gap() {
1480        let model = impact_model(
1481            vec![heuristic_impact_row(
1482                "src/Guessed.cs",
1483                1,
1484                3,
1485                &["Widget"],
1486                0.0,
1487            )],
1488            0,
1489            0,
1490            2,
1491        );
1492        let out = render_impact_compact("Widget", &model);
1493        assert!(out.contains("hop 1 (1):\n  src/Guessed.cs via=3h"), "{out}");
1494        assert_eq!(
1495            out.lines().last().unwrap(),
1496            "summary: affected=0 shown=1 dropped=0 ambiguous=0 heuristic=1 gap=2"
1497        );
1498
1499        // And it disappears entirely at zero.
1500        let none = impact_model(
1501            vec![impact_row("src/Direct.cs", 1, 1, 0, &["Widget"], 0, 0.5)],
1502            1,
1503            0,
1504            2,
1505        );
1506        assert_eq!(
1507            render_impact_compact("Widget", &none)
1508                .lines()
1509                .last()
1510                .unwrap(),
1511            "summary: affected=1 shown=1 dropped=0 ambiguous=0 gap=2"
1512        );
1513    }
1514
1515    // --- test-coverage stage: `devscout tests` bytes --------------------------
1516
1517    fn tests_model(rows: Vec<TestRow>) -> TestsModel {
1518        let precise: Vec<&TestRow> = rows.iter().filter(|r| !r.heuristic).collect();
1519        let guessed: Vec<&TestRow> = rows.iter().filter(|r| r.heuristic).collect();
1520        TestsModel {
1521            query: "OrderService".to_string(),
1522            symbol: "App.Orders.OrderService".to_string(),
1523            def_files: vec!["src/OrderService.cs".to_string()],
1524            test_file_count: precise.len(),
1525            ref_count: precise.iter().map(|r| r.ref_count).sum(),
1526            heuristic_file_count: guessed.len(),
1527            heuristic_ref_count: guessed.iter().map(|r| r.ref_count).sum(),
1528            rows,
1529        }
1530    }
1531
1532    fn test_row(file: &str, test_defs: &[&str], lines: &[usize], heuristic: bool) -> TestRow {
1533        TestRow {
1534            file: file.into(),
1535            test_defs: test_defs.iter().map(|s| (*s).to_string()).collect(),
1536            lines: lines.to_vec(),
1537            ref_count: lines.len(),
1538            heuristic,
1539        }
1540    }
1541
1542    #[test]
1543    fn tests_text_heads_the_file_and_indents_one_line_per_test_def() {
1544        let model = tests_model(vec![test_row(
1545            "tests/OrderServiceTests.cs",
1546            &["App.Orders.Tests.OrderServiceTests"],
1547            &[10, 11],
1548            false,
1549        )]);
1550        assert_eq!(
1551            render_tests_text(&model),
1552            "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"
1553        );
1554    }
1555
1556    #[test]
1557    fn tests_text_answers_the_zero_case_in_one_line_instead_of_a_header_over_a_void() {
1558        let mut model = tests_model(vec![]);
1559        model.symbol = "App.Orders.Untested".to_string();
1560        assert_eq!(
1561            render_tests_text(&model),
1562            "tests for App.Orders.Untested\nno test references found"
1563        );
1564    }
1565
1566    #[test]
1567    fn tests_text_marks_a_guessed_file_with_the_shared_heuristic_suffix() {
1568        let model = tests_model(vec![
1569            test_row(
1570                "tests/OrderServiceTests.cs",
1571                &["App.Orders.Tests.OrderServiceTests"],
1572                &[10],
1573                false,
1574            ),
1575            test_row(
1576                "tests/Partial.Extra.cs",
1577                &["App.Orders.Tests.PartialTests"],
1578                &[9],
1579                true,
1580            ),
1581        ]);
1582        let out = render_tests_text(&model);
1583        assert!(
1584            out.contains("covered by 1 test file(s), 1 reference(s)"),
1585            "counts stay precise-only\n{out}"
1586        );
1587        assert!(
1588            out.ends_with(
1589                "tests/Partial.Extra.cs (heuristic)\n  App.Orders.Tests.PartialTests  lines: 9"
1590            ),
1591            "{out}"
1592        );
1593    }
1594
1595    #[test]
1596    fn tests_compact_folds_the_counts_into_the_header_and_drops_the_def_ids() {
1597        let model = tests_model(vec![test_row(
1598            "tests/OrderServiceTests.cs",
1599            &["App.Orders.Tests.OrderServiceTests"],
1600            &[10, 11],
1601            false,
1602        )]);
1603        assert_eq!(
1604            render_tests_compact(&model),
1605            "tests App.Orders.OrderService files=1 refs=2\ntests/OrderServiceTests.cs 10,11"
1606        );
1607
1608        let mut empty = tests_model(vec![]);
1609        empty.symbol = "App.Orders.Untested".to_string();
1610        assert_eq!(
1611            render_tests_compact(&empty),
1612            "tests App.Orders.Untested files=0 refs=0"
1613        );
1614    }
1615
1616    #[test]
1617    fn tests_compact_declares_heuristic_files_in_the_header_and_marks_their_lines() {
1618        let model = tests_model(vec![
1619            test_row(
1620                "tests/OrderServiceTests.cs",
1621                &["App.Orders.Tests.OrderServiceTests"],
1622                &[10],
1623                false,
1624            ),
1625            test_row(
1626                "tests/Partial.Extra.cs",
1627                &["App.Orders.Tests.PartialTests"],
1628                &[9, 12],
1629                true,
1630            ),
1631        ]);
1632        assert_eq!(
1633            render_tests_compact(&model),
1634            "tests App.Orders.OrderService files=1 refs=1 heuristic=1\ntests/OrderServiceTests.cs 10\ntests/Partial.Extra.cs 9h,12h"
1635        );
1636    }
1637
1638    #[test]
1639    fn impact_summaries_append_tests_only_when_the_blast_radius_reaches_one() {
1640        let reached = ImpactModel {
1641            tests_affected: 1,
1642            ..impact_model(
1643                vec![impact_row(
1644                    "tests/OrderServiceTests.cs",
1645                    1,
1646                    1,
1647                    0,
1648                    &["OrderService"],
1649                    0,
1650                    0.5,
1651                )],
1652                1,
1653                0,
1654                0,
1655            )
1656        };
1657        assert!(
1658            render_impact_text("OrderService", &reached)
1659                .contains("affected files: 1  shown: 1  dropped: 0 tests=1"),
1660            "{}",
1661            render_impact_text("OrderService", &reached)
1662        );
1663        assert_eq!(
1664            render_impact_compact("OrderService", &reached)
1665                .lines()
1666                .last()
1667                .unwrap(),
1668            "summary: affected=1 shown=1 dropped=0 ambiguous=0 tests=1"
1669        );
1670
1671        let none = impact_model(
1672            vec![impact_row(
1673                "src/Other.cs",
1674                1,
1675                1,
1676                0,
1677                &["OrderService"],
1678                0,
1679                0.5,
1680            )],
1681            1,
1682            0,
1683            0,
1684        );
1685        assert!(
1686            render_impact_text("OrderService", &none)
1687                .contains("affected files: 1  shown: 1  dropped: 0\n"),
1688            "its ABSENCE is the gap signal -- never printed as tests=0"
1689        );
1690        assert_eq!(
1691            render_impact_compact("OrderService", &none)
1692                .lines()
1693                .last()
1694                .unwrap(),
1695            "summary: affected=1 shown=1 dropped=0 ambiguous=0"
1696        );
1697    }
1698
1699    #[test]
1700    fn impact_compact_orders_tests_after_heuristic_and_before_gap() {
1701        let model = ImpactModel {
1702            tests_affected: 1,
1703            ..impact_model(
1704                vec![
1705                    impact_row(
1706                        "tests/OrderServiceTests.cs",
1707                        1,
1708                        1,
1709                        0,
1710                        &["OrderService"],
1711                        0,
1712                        0.5,
1713                    ),
1714                    heuristic_impact_row("src/Guessed.cs", 1, 3, &["OrderService"], 0.0),
1715                ],
1716                1,
1717                0,
1718                2,
1719            )
1720        };
1721        assert_eq!(
1722            render_impact_compact("OrderService", &model)
1723                .lines()
1724                .last()
1725                .unwrap(),
1726            "summary: affected=1 shown=2 dropped=0 ambiguous=0 heuristic=1 tests=1 gap=2"
1727        );
1728    }
1729}