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