Skip to main content

core_api/repograph/
render.rs

1//! Turning graph facts into lines an assistant reads.
2//!
3//! Everything here is generic over what is being rendered: the digests in this
4//! module's siblings share the line budget, the number formatting, the path
5//! shortening, and — above all — [`sanitize`], which every string that came
6//! out of the graph must pass through before it reaches a rendered line.
7
8use crate::repograph::brief::{BriefReport, SchemaBrief};
9use crate::repograph::context::{ContextReport, Target};
10use crate::repograph::explore::ExploreReport;
11use crate::repograph::impact::{FileImpact, ImpactReport, Partner};
12use crate::repograph::map::RepoMap;
13use crate::repograph::owners::OwnersReport;
14use crate::repograph::recall::UNTRUSTED_FRAMING;
15use crate::repograph::why::{WhyLink, WhyReport};
16use std::fmt::Write as _;
17
18/// Longest digest any `repograph` tool may print, in lines.
19pub const MAX_MAP_LINES: usize = 40;
20/// Longest [`render_context`] digest, in lines. Wider than the others because
21/// it quotes source.
22pub const MAX_CONTEXT_LINES: usize = 60;
23/// Longest digest every other tool here prints, in lines.
24pub const MAX_TOOL_LINES: usize = 25;
25
26/// Separator between the items of a one-line list.
27pub const SEP: &str = " · ";
28
29/// Replace every character that could forge the shape of a digest with a
30/// space, so a value read out of the graph cannot fake a line break, a section
31/// header, or a terminal escape sequence — and cannot reorder or hide what it
32/// sits next to when rendered.
33///
34/// Three classes, and each is the class rather than the examples: neutralising
35/// only U+202E would leave U+202D, and only U+2028 would leave U+0085.
36///
37/// - **ASCII controls** `0x00-0x1f` and `0x7f`, tabs and newlines included.
38/// - **Line and paragraph separators** outside ASCII: U+0085, U+2028, U+2029.
39/// - **Bidi controls and zero-width characters**: U+200B-U+200F, U+202A-U+202E,
40///   U+2066-U+2069, U+FEFF. These reorder or conceal rendered text without
41///   changing the bytes a reader would diff.
42///
43/// One char in, one char out, so a caller's character budget is unaffected and
44/// the byte length can only shrink — never grow.
45#[must_use]
46pub fn sanitize(s: &str) -> String {
47    s.chars()
48        .map(|c| if is_shape_forging(c) { ' ' } else { c })
49        .collect()
50}
51
52/// Whether `c` belongs to one of the three classes [`sanitize`] neutralizes.
53/// The ASCII test comes first and returns, so a plain byte — which is almost
54/// every byte of almost every digest — answers in one comparison plus
55/// `is_ascii_control`'s two, instead of falling through six `matches!` arms that
56/// cannot possibly hit.
57///
58/// 0.6.9 widened this from a bare `is_ascii_control()` to the full class and
59/// paid for it, and `touch` renders digests. Measured by
60/// `cargo run --release -p mushroomdb --example sanitize_bench` over ~400 KB of
61/// representative digest text, median of three on an Apple Silicon laptop:
62///
63/// | form | per pass | vs 0.6.8 |
64/// |---|---|---|
65/// | 0.6.8 `is_ascii_control()` only | ~572 µs | — |
66/// | 0.6.9 full class, no fast path | ~775 µs | **+36%** |
67/// | this, ASCII answered first | ~585 µs | +2% |
68///
69/// So the reorder **removes the 0.6.9 regression**; it does not beat 0.6.8. An
70/// earlier standalone micro-benchmark suggested it did, by a wide margin — that
71/// harness inlined differently from the real crate and flattered the result,
72/// which is why the benchmark now lives in the tree and the numbers above come
73/// from it.
74///
75/// No behavioural test can distinguish the two forms: the `matches!` set's
76/// smallest member is U+0085, so it is disjoint from ASCII, and
77/// `is_ascii_control` is false above U+007F — the reorder is equivalent over
78/// every code point, which `sanitize_bench` asserts before it times anything.
79/// `sanitize_classifies_every_ascii_byte` pins the branch this reordering moves.
80#[inline]
81fn is_shape_forging(c: char) -> bool {
82    if c.is_ascii() {
83        return c.is_ascii_control();
84    }
85    matches!(c,
86        '\u{0085}'                      // NEL
87        | '\u{200b}'..='\u{200f}'       // ZWSP, ZWNJ, ZWJ, LRM, RLM
88        | '\u{2028}' | '\u{2029}'       // line / paragraph separator
89        | '\u{202a}'..='\u{202e}'       // bidi embeddings and overrides
90        | '\u{2066}'..='\u{2069}'       // bidi isolates
91        | '\u{feff}'                    // zero-width no-break space / BOM
92    )
93}
94
95/// `1204` → `1,204`. Groups of three, ASCII digits only.
96#[must_use]
97pub fn thousands(n: usize) -> String {
98    let digits = n.to_string();
99    let mut out = String::with_capacity(digits.len() + digits.len() / 3);
100    for (i, c) in digits.chars().enumerate() {
101        if i > 0 && (digits.len() - i).is_multiple_of(3) {
102            out.push(',');
103        }
104        out.push(c);
105    }
106    out
107}
108
109/// `n` of `word`, pluralised by adding an `s`. `1 file`, `2 files`.
110#[must_use]
111pub fn plural(n: usize, word: &str) -> String {
112    if n == 1 {
113        format!("{n} {word}")
114    } else {
115        format!("{} {word}s", thousands(n))
116    }
117}
118
119/// A duration in seconds as one coarse unit: `45s`, `12m`, `3h`, `20d`.
120/// Negative input — a clock that ran backwards — reads as `0s`.
121#[must_use]
122pub fn age(secs: i64) -> String {
123    let s = secs.max(0);
124    if s < 60 {
125        format!("{s}s")
126    } else if s < 3_600 {
127        format!("{}m", s / 60)
128    } else if s < 86_400 {
129        format!("{}h", s / 3_600)
130    } else {
131        format!("{}d", s / 86_400)
132    }
133}
134
135/// Seconds in a day.
136const DAY: i64 = 86_400;
137
138/// The civil `(year, month, day)` a count of days since 1970-01-01 falls on,
139/// proleptic Gregorian. Days before the epoch are negative and convert the
140/// same way.
141///
142/// This is the days-to-civil algorithm every calendar library implements; it
143/// is here rather than behind a dependency because two dozen lines of integer
144/// arithmetic is the whole of what these digests need a calendar for.
145fn civil_from_days(days: i64) -> (i64, u32, u32) {
146    // Shift the epoch to 0000-03-01, so a leap day is always the last day of
147    // the (shifted) year and the month arithmetic below needs no special case.
148    let z = days + 719_468;
149    let era = z.div_euclid(146_097);
150    let doe = z.rem_euclid(146_097); // day of era, 0..=146_096
151    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // 0..=399
152    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of shifted year
153    let mp = (5 * doy + 2) / 153; // shifted month, 0..=11 with March = 0
154    let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
155    let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
156    let year = yoe + era * 400 + i64::from(month <= 2);
157    (year, month, day)
158}
159
160/// A Unix timestamp as a calendar date in UTC: `2026-09-04`.
161#[must_use]
162pub fn ymd(ts: i64) -> String {
163    let (y, m, d) = civil_from_days(ts.div_euclid(DAY));
164    format!("{y:04}-{m:02}-{d:02}")
165}
166
167/// The quarter a timestamp falls in, counted from year 0 so that subtracting
168/// one index from another gives a number of quarters.
169#[must_use]
170pub fn quarter_index(ts: i64) -> i64 {
171    let (y, m, _) = civil_from_days(ts.div_euclid(DAY));
172    y * 4 + i64::from((m - 1) / 3)
173}
174
175/// A quarter index as its label: `2026Q3`.
176#[must_use]
177pub fn quarter_label(index: i64) -> String {
178    format!("{}Q{}", index.div_euclid(4), index.rem_euclid(4) + 1)
179}
180
181/// The last `/`-separated segment of a key: `src/core/db.rs` → `db.rs`.
182#[must_use]
183pub fn basename(key: &str) -> &str {
184    key.rsplit_once('/').map_or(key, |(_, base)| base)
185}
186
187/// The directory segments of a key: `src/core/db.rs` → `["src", "core"]`.
188/// A key with no `/` has none.
189#[must_use]
190pub fn dir_components(key: &str) -> Vec<&str> {
191    let mut parts: Vec<&str> = key.split('/').collect();
192    parts.pop();
193    parts
194}
195
196/// The longest directory prefix every key shares, `/`-joined. Empty when the
197/// keys share no leading directory at all.
198#[must_use]
199pub fn common_dir_prefix(keys: &[String]) -> String {
200    let mut iter = keys.iter().map(|k| dir_components(k));
201    let Some(mut prefix) = iter.next() else {
202        return String::new();
203    };
204    for comps in iter {
205        let shared = prefix
206            .iter()
207            .zip(comps.iter())
208            .take_while(|(a, b)| a == b)
209            .count();
210        prefix.truncate(shared);
211        if prefix.is_empty() {
212            break;
213        }
214    }
215    prefix.join("/")
216}
217
218/// The `n` path segments most keys carry, ignoring `prefix`.
219///
220/// A segment is counted once per key, so a directory that appears in twenty
221/// keys beats a filename that appears in one. Ties go to the segment that
222/// sorts first, which is what makes the answer stable. With `dirs_only` the
223/// basename is skipped, leaving the segments that say where a file lives.
224#[must_use]
225pub fn top_tokens(keys: &[String], prefix: &str, n: usize, dirs_only: bool) -> Vec<String> {
226    let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
227    for key in keys {
228        let rest = match prefix.is_empty() {
229            true => key.as_str(),
230            false => key
231                .strip_prefix(prefix)
232                .unwrap_or(key)
233                .trim_start_matches('/'),
234        };
235        let mut seen: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect();
236        if dirs_only {
237            seen.pop();
238        }
239        seen.sort_unstable();
240        seen.dedup();
241        for token in seen {
242            *counts.entry(token).or_default() += 1;
243        }
244    }
245    let mut ranked: Vec<(&str, usize)> = counts.into_iter().collect();
246    ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
247    ranked
248        .into_iter()
249        .take(n)
250        .map(|(t, _)| t.to_string())
251        .collect()
252}
253
254/// What a set of files with no shared directory is called.
255pub const MIXED: &str = "<mixed>";
256
257/// What to call a set of files.
258///
259/// The directory they all sit under, when there is one — that is the name a
260/// person would use — followed by the two subdirectories most of them sit in,
261/// which is what tells two clusters under the same root apart. Files that
262/// share no directory get [`MIXED`] in the prefix's place.
263///
264/// Files sitting directly in the shared directory add nothing to it, so a
265/// cluster that is exactly one directory deep is named by that directory
266/// alone.
267#[must_use]
268pub fn cluster_name(keys: &[String]) -> String {
269    let prefix = common_dir_prefix(keys);
270    let head = if prefix.is_empty() {
271        MIXED.to_string()
272    } else {
273        prefix.clone()
274    };
275    let mut tokens = top_tokens(keys, &prefix, 2, true);
276    if tokens.is_empty() && prefix.is_empty() {
277        // Everything is at the root: the filenames are all there is to say.
278        tokens = top_tokens(keys, &prefix, 2, false);
279    }
280    if tokens.is_empty() {
281        head
282    } else {
283        format!("{head} {}", tokens.join(", "))
284    }
285}
286
287/// Shorten keys to their filenames, keeping the full path for any filename
288/// that would otherwise appear twice.
289///
290/// `mod.rs, mod.rs` names nothing; `src/net/mod.rs, src/io/mod.rs` names two
291/// files. Sanitized, since the result is printed.
292#[must_use]
293pub fn short_names(keys: &[String]) -> Vec<String> {
294    let mut seen: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
295    for key in keys {
296        *seen.entry(basename(key)).or_default() += 1;
297    }
298    keys.iter()
299        .map(|k| match seen.get(basename(k)) {
300            Some(1) => sanitize(basename(k)),
301            _ => sanitize(k),
302        })
303        .collect()
304}
305
306/// Keep at most `max` lines, dropping the rest.
307#[must_use]
308pub fn cap_lines(text: &str, max: usize) -> String {
309    let mut out = String::with_capacity(text.len());
310    for line in text.lines().take(max) {
311        out.push_str(line);
312        out.push('\n');
313    }
314    out
315}
316
317/// Keep whole lines while they fit in `max` bytes, dropping the rest.
318///
319/// A budget in bytes, unlike one in lines, can fall in the middle of a line —
320/// and half a line is worse than no line: a path cut short still reads as a
321/// path, and a caller acts on it. So the cut is always at a line ending, and
322/// a first line too long to fit yields nothing rather than a fragment.
323#[must_use]
324pub fn cap_bytes(text: &str, max: usize) -> String {
325    let mut out = String::with_capacity(text.len().min(max));
326    for line in text.lines() {
327        if out.len() + line.len() + 1 > max {
328            break;
329        }
330        out.push_str(line);
331        out.push('\n');
332    }
333    out
334}
335
336/// The one line a store with nothing in it gets: what is missing, and the
337/// command that fixes it.
338pub const EMPTY_MAP: &str =
339    "mushroomdb map — empty store; run: mushroomdb ingest-git <db> <repo>\n";
340
341/// Render a [`RepoMap`] as the digest an assistant reads: at most
342/// [`MAX_MAP_LINES`] lines, byte-identical for the same map.
343///
344/// Every value that came out of the graph is sanitized again here, so the
345/// output is safe whether or not the map was built by
346/// [`repo_map`](crate::repograph::repo_map).
347#[must_use]
348pub fn render_map(m: &RepoMap) -> String {
349    if m.files == 0 {
350        return EMPTY_MAP.to_string();
351    }
352    let mut out = String::new();
353
354    // Header: the size of the graph, and how current it is.
355    let sync = match &m.last_sync {
356        None => "not synced".to_string(),
357        Some(s) => {
358            let sha = sanitize(&s.sha);
359            let short: String = sha.chars().take(7).collect();
360            match s.age_secs {
361                Some(secs) => format!("synced {} ago at {short}", age(secs)),
362                None => format!("synced at {short}"),
363            }
364        }
365    };
366    let _ = writeln!(
367        out,
368        "mushroomdb map — {}, {}, {}, {} · {sync}{}",
369        plural(m.files, "file"),
370        plural(m.symbols, "symbol"),
371        plural(m.commits, "commit"),
372        plural(m.authors, "author"),
373        if m.truncated { " (truncated)" } else { "" }
374    );
375
376    if !m.communities.is_empty() {
377        out.push_str("clusters (co-change + imports)\n");
378        for (i, c) in m.communities.iter().enumerate() {
379            let samples = short_names(&c.samples);
380            let _ = writeln!(
381                out,
382                "  {}. {}  ({}, cohesion {:.2}){}{}",
383                i + 1,
384                sanitize(&c.name),
385                plural(c.size, "file"),
386                c.cohesion,
387                if samples.is_empty() { "" } else { "  " },
388                samples.join(", ")
389            );
390        }
391    }
392
393    if !m.key_files.is_empty() {
394        out.push_str("key files (most depended-on)\n");
395        // Two decimals, like every other float here. A PageRank score is a
396        // ranking, and the order it is printed in already carries that; the
397        // number is there for the gap between one file and the next.
398        let items: Vec<String> = m
399            .key_files
400            .iter()
401            .map(|(k, s)| format!("{} {s:.2}", sanitize(k)))
402            .collect();
403        let _ = writeln!(out, "  {}", items.join(SEP));
404    }
405
406    if !m.owners.is_empty() {
407        out.push_str("owners\n");
408        let items: Vec<String> = m
409            .owners
410            .iter()
411            .enumerate()
412            .map(|(i, (name, n))| match i {
413                // The unit is stated once, on the first entry.
414                0 => format!("{} {}", sanitize(name), plural(*n, "file")),
415                _ => format!("{} {n}", sanitize(name)),
416            })
417            .collect();
418        let _ = writeln!(out, "  {}", items.join(SEP));
419    }
420
421    if !m.hot_files.is_empty() {
422        let _ = writeln!(out, "hot (last {} days)", m.hot_days);
423        let items: Vec<String> = m
424            .hot_files
425            .iter()
426            .map(|(k, n)| format!("{} {n}", sanitize(k)))
427            .collect();
428        let _ = writeln!(out, "  {}", items.join(SEP));
429    }
430
431    if m.stale_concepts > 0 {
432        let (noun, verb) = if m.stale_concepts == 1 {
433            ("concept", "needs")
434        } else {
435            ("concepts", "need")
436        };
437        let _ = writeln!(
438            out,
439            "notes: {} {noun} {verb} re-learning (source changed)",
440            m.stale_concepts
441        );
442    }
443
444    if !m.questions.is_empty() {
445        let asks: Vec<String> = m.questions.iter().map(|q| sanitize(q)).collect();
446        let _ = writeln!(out, "ask me: {}", asks.join(SEP));
447    }
448
449    cap_lines(&out, MAX_MAP_LINES)
450}
451
452/// Longest session brief, in bytes.
453///
454/// A `SessionStart` hook's output is prepended to a session and cached for the
455/// whole of it, so it is paid for once but carried by every turn. Four
456/// thousand bytes is roughly a thousand tokens: enough for two rankings deep
457/// enough to be worth having, short enough that a session that never asks the
458/// graph anything has lost almost nothing.
459pub const MAX_BRIEF_BYTES: usize = 4_000;
460
461/// The one line a store with nothing in it at all gets as a session opens:
462/// what is missing, and the command that fixes it. The same answer
463/// [`EMPTY_MAP`] gives, for the same reason — there is nothing to be central
464/// *in*, and no point naming a way to reach an empty graph.
465///
466/// Not marked with [`UNTRUSTED_FRAMING`], unlike every brief with a graph
467/// behind it: not one byte of this line came out of a store, so there is
468/// nothing here to mark as data.
469pub const EMPTY_BRIEF: &str =
470    "mushroomdb brief — empty store; run: mushroomdb ingest-git <db> <repo>\n";
471
472/// Headings the two listings sit under.
473const BRIEF_FILES_HEADING: &str = "key files (by centrality):\n";
474const BRIEF_SYMBOLS_HEADING: &str = "key symbols (most called):\n";
475/// Headings a memory store's schema sits under.
476const BRIEF_LABELS_HEADING: &str = "labels:\n";
477const BRIEF_EDGE_TYPES_HEADING: &str = "edge types:\n";
478/// The heading over the worked calls. Named for what a reader wants out of
479/// it — one call, not a search — because the failure it exists to stop is a
480/// session probing the store for its schema before asking anything.
481const BRIEF_RECIPES_HEADING: &str = "ask in one call:\n";
482
483/// Render a [`BriefReport`] as the block a session opens with: at most
484/// [`MAX_BRIEF_BYTES`] bytes, byte-identical for the same report.
485///
486/// The first line is [`UNTRUSTED_FRAMING`], as it is on every other digest
487/// rendered out of a store: a brief is repository-controlled text — paths,
488/// signatures, a branch name — placed in a session's context before its first
489/// turn, and the one digest a session never asked for is the last one that
490/// should reach it unmarked. Its bytes are charged to the budget like any
491/// other line, so a marked brief is not a longer one.
492///
493/// `reach` is one line naming how to reach the graph from this session, which
494/// only the caller knows — a tool name on the MCP arm, a command on the CLI
495/// arm. It is fitted first and appended last, so the listings above it give way
496/// to it rather than the other way round: a brief that named central files but
497/// not how to ask about them would be a dead end. It is therefore the one part
498/// exempt from the budget, and a caller handing it a `reach` longer than the
499/// whole budget gets the header and that line.
500///
501/// **Nothing is dropped silently.** When the budget cannot hold both listings
502/// in full, entries come off the end — symbols first, since a file path is the
503/// coarser handle and the one a reader can act on without the graph — and the
504/// listing closes with `  … and N more`, counted. A reader who cannot see that
505/// a list was cut reads a partial ranking as a complete one.
506#[must_use]
507pub fn render_brief(b: &BriefReport, reach: &str) -> String {
508    let nodes = b.schema.as_ref().map_or(b.files + b.symbols, |s| s.nodes);
509    if nodes == 0 && b.edges == 0 {
510        return EMPTY_BRIEF.to_string();
511    }
512    let tail = format!("reach the graph: {}\n", sanitize(reach));
513    let budget = MAX_BRIEF_BYTES.saturating_sub(tail.len());
514    if let Some(schema) = &b.schema {
515        return render_memory_brief(b, schema, budget) + &tail;
516    }
517
518    // The header: what this repository is, how big, and which commit it is at.
519    // No age — see [`BriefReport::last_sync`]. A store no repository was
520    // ingested into has neither a name nor a sha, and says neither.
521    let mut head: Vec<String> = Vec::new();
522    if !b.repo.is_empty() {
523        head.push(sanitize(&b.repo));
524    }
525    head.push(plural(b.files, "file"));
526    head.push(plural(b.symbols, "symbol"));
527    head.push(plural(b.edges, "edge"));
528    if let Some(sha) = &b.last_sync {
529        head.push(format!("synced {}", sanitize(sha)));
530    }
531    let header = format!("{UNTRUSTED_FRAMING}mushroomdb brief — {}\n", head.join(SEP));
532
533    let mut files: Vec<String> = b
534        .key_files
535        .iter()
536        .map(|(path, role)| format!("  {}{}\n", sanitize(path), suffix(role)))
537        .collect();
538    let mut symbols: Vec<String> = b
539        .key_symbols
540        .iter()
541        .map(|(key, sig)| format!("  {}{}\n", sanitize(key), suffix(sig)))
542        .collect();
543
544    // Drop one entry at a time until what is left — the marker line included,
545    // since it grows a digit of its own — fits. Re-measured each round rather
546    // than solved for, because `… and 9 more` and `… and 10 more` are not the
547    // same length and a budget that is off by one byte is not a budget.
548    let mut dropped = 0;
549    loop {
550        let body = brief_body(&header, &files, &symbols, dropped);
551        if body.len() <= budget || (symbols.is_empty() && files.is_empty()) {
552            return body + &tail;
553        }
554        if symbols.pop().is_none() {
555            files.pop();
556        }
557        dropped += 1;
558    }
559}
560
561/// The brief above its `reach` line, for one candidate set of entries.
562fn brief_body(header: &str, files: &[String], symbols: &[String], dropped: usize) -> String {
563    let mut out = String::from(header);
564    if !files.is_empty() {
565        out.push_str(BRIEF_FILES_HEADING);
566        out.extend(files.iter().map(String::as_str));
567    }
568    if !symbols.is_empty() {
569        out.push_str(BRIEF_SYMBOLS_HEADING);
570        out.extend(symbols.iter().map(String::as_str));
571    }
572    if dropped > 0 {
573        let _ = writeln!(out, "  … and {dropped} more");
574    }
575    out
576}
577
578/// A memory store's brief, above its `reach` line: the schema, then one
579/// worked call per question kind.
580///
581/// The order is the argument. A session that has just been handed the
582/// association surface and an unfamiliar store asks two questions before its
583/// own — *what is in here* and *how do I ask* — and the first association run
584/// showed it answering both by probing Cypher, one guess at a time. So the
585/// labels and the edge types come first, complete enough to write a query
586/// against, and the worked calls come last, where a reader who skimmed the
587/// schema still lands on them.
588///
589/// **The calls come off last, and only when nothing else is left.** When the
590/// budget is short, entries drop from the listings above — edge types first,
591/// then labels, since a label with no edge type is still a thing to query and
592/// an edge type with no labels is not — and the cut is counted in the same
593/// `… and N more` every other digest uses. Dropping a recipe first would save
594/// a line and cost the session the round trip the whole section exists to
595/// remove.
596///
597/// # The cap is hard
598///
599/// Dropping lines alone is not a ceiling: a store whose names are themselves
600/// hundreds of bytes long spends the budget inside the lines that remain — a
601/// schema of 250-character edge types rendered 5,986 bytes against a 4,000
602/// byte cap, because the loop stopped when it ran out of *lines* rather than
603/// when it fit. So three measures run in order, each only when the one before
604/// it was not enough:
605///
606/// 1. the listings render whole, which is what every ordinary store gets;
607/// 2. every name is cut to [`BRIEF_NAME_CAP`] characters, and entries then
608///    drop from the listings against the shorter lines, counted;
609/// 3. the worked calls come off from the end, and the brief says so on a
610///    final `(brief truncated at 4,000 bytes)` line.
611///
612/// A brief whose header, history and roles alone overrun the budget — nothing
613/// left to drop — is cut on whole lines by [`cap_bytes`], so the returned
614/// string is never longer than the budget whatever the store holds.
615fn render_memory_brief(b: &BriefReport, s: &SchemaBrief, budget: usize) -> String {
616    // A partial schema counts what it reached, so every count it produced is
617    // a lower bound. Marked once in the header rather than on each line — the
618    // budget the marker is charged against is the same one the counts came
619    // short of.
620    let at_least = |n: usize| {
621        if s.partial {
622            format!("≥ {}", thousands(n))
623        } else {
624            thousands(n)
625        }
626    };
627    let header = format!(
628        "{UNTRUSTED_FRAMING}mushroomdb brief — {}{}\n",
629        [
630            if s.partial {
631                format!("≥ {}", plural(s.nodes, "node"))
632            } else {
633                plural(s.nodes, "node")
634            },
635            plural(b.edges, "edge"),
636            plural(s.labels.len(), "label"),
637        ]
638        .join(SEP),
639        if s.partial { " (partial)" } else { "" }
640    );
641
642    let label_lines = |cap: usize| -> Vec<String> {
643        s.labels
644            .iter()
645            .map(|l| {
646                let mut line = format!(
647                    "  {} ({})",
648                    cap_name(&sanitize(&l.label), cap),
649                    at_least(l.nodes)
650                );
651                if !l.props.is_empty() {
652                    let props: Vec<String> = l.props.iter().map(|p| cap_name(p, cap)).collect();
653                    let _ = write!(line, " — {}", props.join(", "));
654                }
655                if l.hidden_props > 0 {
656                    let _ = write!(line, ", … +{}", l.hidden_props);
657                }
658                line.push('\n');
659                line
660            })
661            .collect()
662    };
663    let edge_type_lines = |cap: usize| -> Vec<String> {
664        s.edge_types
665            .iter()
666            .map(|t| {
667                let mut line = format!(
668                    "  {} ({})",
669                    cap_name(&sanitize(&t.edge_type), cap),
670                    at_least(t.edges)
671                );
672                if let Some(rule) = &t.rule {
673                    let _ = write!(line, " — rule {}", cap_name(&sanitize(rule), cap));
674                    if t.hidden_rules > 0 {
675                        let _ = write!(line, " +{}", t.hidden_rules);
676                    }
677                }
678                let _ = writeln!(line, " — {} → {}", ends(&t.src, cap), ends(&t.dst, cap));
679                line
680            })
681            .collect()
682    };
683
684    // The part that gives way last: how deep the history runs, who may read
685    // it, and the calls.
686    // `unknown`, not `0`: a history the budget never counted is not a history
687    // that is not there, and the two lead a reader to opposite conclusions.
688    let mut prefix = match s.commits {
689        Some(n) => format!("history: {n} commits\n"),
690        None => "history: unknown\n".to_string(),
691    };
692    if !s.roles.is_empty() {
693        let roles: Vec<String> = s
694            .roles
695            .iter()
696            .map(|(name, labels)| {
697                if labels.is_empty() {
698                    sanitize(name)
699                } else {
700                    format!("{} ({})", sanitize(name), labels.join(", "))
701                }
702            })
703            .collect();
704        let _ = writeln!(prefix, "roles: {}", roles.join(SEP));
705    }
706    let recipes: Vec<String> = s
707        .recipes
708        .iter()
709        .map(|r| format!("  {}: {}\n", sanitize(&r.question), sanitize(&r.call)))
710        .collect();
711    let with_recipes = |kept: usize| -> String {
712        let mut fixed = prefix.clone();
713        if kept > 0 {
714            fixed.push_str(BRIEF_RECIPES_HEADING);
715            fixed.extend(recipes[..kept].iter().map(String::as_str));
716        }
717        fixed
718    };
719    let fixed = with_recipes(recipes.len());
720
721    // Measure one: the listings whole. A store whose brief already fits — every
722    // ordinary one — renders exactly the bytes it always did, since nothing
723    // below runs.
724    let whole = memory_body(
725        &header,
726        &label_lines(usize::MAX),
727        &edge_type_lines(usize::MAX),
728        &fixed,
729        0,
730    );
731    if whole.len() <= budget {
732        return whole;
733    }
734
735    // Measure two: every name cut to [`BRIEF_NAME_CAP`], and *then* entries
736    // dropped against the shorter lines. Cutting before dropping rather than
737    // after is the order that does anything: a brief over budget because one
738    // name is 250 characters keeps its whole schema once the name is cut,
739    // where dropping first would throw away entries to pay for the names
740    // inside the few that remain — and by the time dropping alone has run out
741    // of entries there are no names left to cut.
742    let mut labels = label_lines(BRIEF_NAME_CAP);
743    let mut edge_types = edge_type_lines(BRIEF_NAME_CAP);
744    let mut dropped = 0;
745    loop {
746        let body = memory_body(&header, &labels, &edge_types, &fixed, dropped);
747        if body.len() <= budget {
748            return body;
749        }
750        if edge_types.pop().is_none() && labels.pop().is_none() {
751            break;
752        }
753        dropped += 1;
754    }
755
756    // Measure three: the worked calls, from the end, and a line that says the
757    // brief was cut — without it a session reads a truncated set of recipes as
758    // the whole set.
759    let truncated = format!(
760        "(brief truncated at {} bytes)\n",
761        thousands(MAX_BRIEF_BYTES)
762    );
763    let mut kept = recipes.len();
764    loop {
765        let body = memory_body(&header, &[], &[], &with_recipes(kept), dropped) + &truncated;
766        if body.len() <= budget {
767            return body;
768        }
769        if kept == 0 {
770            // Nothing droppable is left: the header, the history and the roles
771            // alone overrun the budget. Whole lines come off the end so the
772            // ceiling holds whatever the store is named.
773            return cap_bytes(&body, budget);
774        }
775        kept -= 1;
776    }
777}
778
779/// Longest a name may print in a memory brief that did not fit its budget
780/// with every droppable listing entry already gone.
781///
782/// Sixty characters is longer than any name written to be read and short
783/// enough that a line spends its budget on the schema rather than on one
784/// identifier. It applies only to the cut round: a store whose names are
785/// ordinary never reaches it, and renders exactly what it rendered before.
786const BRIEF_NAME_CAP: usize = 60;
787
788/// `name` cut to at most `cap` characters, the last of them `…` when anything
789/// came off.
790///
791/// Counted in characters and cut on a character boundary, so a name of runes
792/// is never halved mid-rune. `usize::MAX` is the uncut round and returns the
793/// name whole.
794fn cap_name(name: &str, cap: usize) -> String {
795    if cap == 0 || name.chars().count() <= cap {
796        return name.to_string();
797    }
798    let end = name
799        .char_indices()
800        .nth(cap - 1)
801        .map_or(name.len(), |(i, _)| i);
802    format!("{}…", &name[..end])
803}
804
805/// A memory store's brief above its `reach` line, for one candidate schema.
806fn memory_body(
807    header: &str,
808    labels: &[String],
809    edge_types: &[String],
810    fixed: &str,
811    dropped: usize,
812) -> String {
813    let mut out = String::from(header);
814    if !labels.is_empty() {
815        out.push_str(BRIEF_LABELS_HEADING);
816        out.extend(labels.iter().map(String::as_str));
817    }
818    if !edge_types.is_empty() {
819        out.push_str(BRIEF_EDGE_TYPES_HEADING);
820        out.extend(edge_types.iter().map(String::as_str));
821    }
822    if dropped > 0 {
823        let _ = writeln!(out, "  … and {dropped} more");
824    }
825    out.push_str(fixed);
826    out
827}
828
829/// The labels on one end of an edge type, as one phrase, each cut to `cap`
830/// characters. An edge type seen between nodes of no known label — every
831/// endpoint tombstoned — says `?` rather than leaving the arrow with nothing
832/// on one side.
833fn ends(labels: &[String], cap: usize) -> String {
834    if labels.is_empty() {
835        "?".to_string()
836    } else {
837        labels
838            .iter()
839            .map(|l| cap_name(&sanitize(l), cap))
840            .collect::<Vec<_>>()
841            .join("|")
842    }
843}
844
845/// What a listing line adds after its key, when the graph had anything to add.
846fn suffix(detail: &str) -> String {
847    if detail.is_empty() {
848        String::new()
849    } else {
850        format!(" — {}", sanitize(detail))
851    }
852}
853
854// ── the four per-node digests ───────────────────────────────────────────────
855
856/// Source lines [`render_context`] prints before it says how many are left.
857/// The report keeps up to
858/// [`MAX_SOURCE_LINES`](crate::repograph::MAX_SOURCE_LINES); a digest that
859/// quoted all of them would have room for nothing else.
860const MAX_SOURCE_PRINTED: usize = 40;
861/// Candidates [`render_context`] lists for an ambiguous name. Past this many
862/// the list is not a choice anyone can make from a digest, and the caller wants
863/// a longer key rather than a longer list.
864const MAX_CANDIDATES: usize = 20;
865/// Files [`render_impact`] prints in full.
866const MAX_IMPACT_FILES: usize = 5;
867/// Paths [`render_impact`] names on an `unknown:` line before counting the
868/// rest.
869///
870/// One line per unknown path, written before [`cap_lines`] runs, means a
871/// repository with untracked build or result artefacts spends its whole
872/// budget on them: a default `impact` here rendered 27 lines of which 20 were
873/// `unknown:`, evicting the analysis it was asked for. The defaults
874/// (`target/`, `node_modules/`, `dist/`, …) do not and should not cover every
875/// output directory anyone might have, so the render caps instead.
876const MAX_IMPACT_UNKNOWN: usize = 3;
877/// Links [`render_why`] prints in full.
878const MAX_WHY_LINKS: usize = 5;
879
880/// Write a `name  a · b · c` section, or nothing when there is nothing to say.
881fn section(out: &mut String, name: &str, items: &[String]) {
882    if !items.is_empty() {
883        let _ = writeln!(out, "{name}  {}", items.join(SEP));
884    }
885}
886
887/// `(sha, ts, subject)` as one line of a digest.
888fn commit_line(sha: &str, ts: i64, subject: &str) -> String {
889    let short: String = sanitize(sha).chars().take(7).collect();
890    format!("{short} {} {}", ymd(ts), sanitize(subject))
891}
892
893/// Render a [`ContextReport`] as the digest an assistant reads: at most
894/// [`MAX_CONTEXT_LINES`] lines, byte-identical for the same report.
895///
896/// A report carrying no `source` — what
897/// [`context_with`](crate::repograph::context_with) answers by default — is
898/// rendered as a pointer instead: `  at path:start-end`, the signature, and the
899/// graph's facts. Nothing stands in for the missing body, because a pointer is
900/// not a truncated body; it is the whole answer to where the body is.
901#[must_use]
902pub fn render_context(c: &ContextReport) -> String {
903    let mut out = String::new();
904    match &c.target {
905        Target::Unknown { target } if c.candidates.is_empty() => {
906            let _ = writeln!(out, "mushroomdb context — unknown: {}", sanitize(target));
907            return out;
908        }
909        Target::Unknown { target } => {
910            let _ = writeln!(
911                out,
912                "mushroomdb context — {} is ambiguous: {}",
913                sanitize(target),
914                plural(c.candidates.len(), "symbol")
915            );
916            for key in c.candidates.iter().take(MAX_CANDIDATES) {
917                let _ = writeln!(out, "  {}", sanitize(key));
918            }
919            if c.candidates.len() > MAX_CANDIDATES {
920                let _ = writeln!(
921                    out,
922                    "  … {} not shown",
923                    plural(c.candidates.len() - MAX_CANDIDATES, "symbol")
924                );
925            }
926            return cap_lines(&out, MAX_CONTEXT_LINES);
927        }
928        Target::File { path } => {
929            let _ = writeln!(out, "mushroomdb context — file {}", sanitize(path));
930        }
931        Target::Symbol { key } => {
932            let _ = writeln!(
933                out,
934                "mushroomdb context — symbol {} in {}",
935                sanitize(key),
936                sanitize(&c.file)
937            );
938        }
939    }
940
941    // Without a body below, the line range is the answer to "where is it", and
942    // it reads as a pointer a caller can open: `path:start-end`. With one it is
943    // the excerpt's own heading, and stays on the `where` line beside the owner.
944    if let Some((first, last)) = c.lines.filter(|_| c.source.is_none() && !c.file.is_empty()) {
945        let _ = writeln!(out, "  at {}:{first}-{last}", sanitize(&c.file));
946    }
947    if let Some(sig) = &c.signature {
948        let _ = writeln!(out, "signature  {}", sanitize(sig));
949    }
950    if let Some(doc) = &c.doc {
951        let _ = writeln!(out, "doc  {}", sanitize(doc));
952    }
953    let mut about: Vec<String> = Vec::new();
954    if let Some((first, last)) = c.lines.filter(|_| c.source.is_some()) {
955        about.push(format!("lines {first}-{last}"));
956    }
957    if let Some(owner) = &c.owner {
958        about.push(format!("owner {}", sanitize(owner)));
959    }
960    section(&mut out, "where", &about);
961
962    if let Some(source) = &c.source {
963        let first = c.lines.map_or(1, |(first, _)| first);
964        let total = source.lines().count();
965        let _ = writeln!(out, "source");
966        for (i, line) in source.lines().take(MAX_SOURCE_PRINTED).enumerate() {
967            let n = first as usize + i;
968            let _ = writeln!(out, "  {n:>5} | {}", sanitize(line));
969        }
970        if total > MAX_SOURCE_PRINTED {
971            let _ = writeln!(
972                out,
973                "  … {} more",
974                plural(total - MAX_SOURCE_PRINTED, "line")
975            );
976        }
977    }
978
979    // Callers read as `<file>: <line>, <line>`: every site a signature change
980    // would have to visit, and the file to open to visit them.
981    let mut callers: Vec<String> = c
982        .callers
983        .iter()
984        .map(|s| {
985            let lines: Vec<String> = s
986                .lines
987                .iter()
988                .filter(|n| **n > 0)
989                .map(u32::to_string)
990                .collect();
991            let more = s.sites.saturating_sub(s.lines.len());
992            let mut item = match lines.is_empty() {
993                true => sanitize(&s.file),
994                false => format!("{}: {}", sanitize(&s.file), lines.join(", ")),
995            };
996            if more > 0 {
997                let _ = write!(item, " …(+{more})");
998            }
999            item
1000        })
1001        .collect();
1002    if c.callers_not_shown > 0 {
1003        callers.push(format!(
1004            "… {} not shown",
1005            plural(c.callers_not_shown, "file")
1006        ));
1007    }
1008    section(&mut out, "callers", &callers);
1009    let callees: Vec<String> = c
1010        .callees
1011        .iter()
1012        .map(|(key, line)| match line {
1013            0 => sanitize(key),
1014            n => format!("{} line {n}", sanitize(key)),
1015        })
1016        .collect();
1017    section(&mut out, "callees", &callees);
1018    section(
1019        &mut out,
1020        "imports",
1021        &c.imports.iter().map(|k| sanitize(k)).collect::<Vec<_>>(),
1022    );
1023    section(
1024        &mut out,
1025        "importers",
1026        &c.importers.iter().map(|k| sanitize(k)).collect::<Vec<_>>(),
1027    );
1028    section(
1029        &mut out,
1030        "co-change",
1031        &c.partners
1032            .iter()
1033            .map(|(k, s)| format!("{} {s:.2}", sanitize(k)))
1034            .collect::<Vec<_>>(),
1035    );
1036    section(
1037        &mut out,
1038        "commits",
1039        &c.recent_commits
1040            .iter()
1041            .map(|(sha, ts, subject)| commit_line(sha, *ts, subject))
1042            .collect::<Vec<_>>(),
1043    );
1044    for (key, text) in &c.notes {
1045        let _ = writeln!(out, "note  {} {}", sanitize(key), sanitize(text));
1046    }
1047    for (key, name) in &c.concepts {
1048        let _ = writeln!(out, "concept  {} {}", sanitize(key), sanitize(name));
1049    }
1050    cap_lines(&out, MAX_CONTEXT_LINES)
1051}
1052
1053/// One partner or importer as `path score modified`, with the parts that say
1054/// nothing left off.
1055fn partner_item(p: &Partner, with_score: bool) -> String {
1056    let mut item = sanitize(&p.path);
1057    // A partner found by how often the two change together carries a count, not
1058    // a similarity, and saying so is the point: the two do not compare, and a
1059    // reader who sees `0.10` beside `0.78` draws the wrong conclusion.
1060    match p.shared_commits {
1061        Some(n) => {
1062            let _ = write!(item, " ({})", plural(n, "shared commit"));
1063        }
1064        None if with_score => {
1065            let _ = write!(item, " {:.2}", p.score);
1066        }
1067        None => {}
1068    }
1069    if p.modified {
1070        item.push_str(" modified");
1071    }
1072    item
1073}
1074
1075/// Render an [`ImpactReport`]: at most [`MAX_TOOL_LINES`] lines.
1076#[must_use]
1077pub fn render_impact(r: &ImpactReport) -> String {
1078    let mut out = String::new();
1079    let _ = writeln!(
1080        out,
1081        "mushroomdb impact — {}",
1082        plural(r.files.len(), "changed file")
1083    );
1084    for f in r.files.iter().take(MAX_IMPACT_FILES) {
1085        render_file_impact(&mut out, f);
1086    }
1087    if r.files.len() > MAX_IMPACT_FILES {
1088        let _ = writeln!(
1089            out,
1090            "… {} not shown",
1091            plural(r.files.len() - MAX_IMPACT_FILES, "file")
1092        );
1093    }
1094    for path in r.unknown.iter().take(MAX_IMPACT_UNKNOWN) {
1095        let _ = writeln!(out, "unknown: {}", sanitize(path));
1096    }
1097    if r.unknown.len() > MAX_IMPACT_UNKNOWN {
1098        let _ = writeln!(
1099            out,
1100            "…and {} more unknown",
1101            r.unknown.len() - MAX_IMPACT_UNKNOWN
1102        );
1103    }
1104    cap_lines(&out, MAX_TOOL_LINES)
1105}
1106
1107fn render_file_impact(out: &mut String, f: &FileImpact) {
1108    match &f.owner {
1109        Some(owner) => {
1110            let _ = writeln!(out, "{} ({})", sanitize(&f.path), sanitize(owner));
1111        }
1112        None => {
1113            let _ = writeln!(out, "{}", sanitize(&f.path));
1114        }
1115    }
1116    section(
1117        out,
1118        "  partners ",
1119        &f.partners
1120            .iter()
1121            .map(|p| partner_item(p, true))
1122            .collect::<Vec<_>>(),
1123    );
1124    section(
1125        out,
1126        "  importers",
1127        &f.importers
1128            .iter()
1129            .map(|p| partner_item(p, false))
1130            .collect::<Vec<_>>(),
1131    );
1132    section(
1133        out,
1134        "  used by  ",
1135        &f.symbols_used_elsewhere
1136            .iter()
1137            .map(|(key, n)| format!("{} {}", sanitize(key), plural(*n, "caller")))
1138            .collect::<Vec<_>>(),
1139    );
1140}
1141
1142/// What a default `explore` reply may cost, in bytes.
1143///
1144/// 1,200 tokens at four bytes a token — the budget §4.3 set for a default
1145/// `context` reply, which is the largest part of what `explore` composes. A
1146/// caller that wants more says so; a caller that says nothing gets an answer it
1147/// can afford to have been wrong about.
1148pub const DEFAULT_EXPLORE_BYTES: usize = 4_800;
1149
1150/// Render an [`ExploreReport`] in **no more than** `budget_bytes`.
1151///
1152/// The context digest, then the blast radius under an `impact:` heading, then
1153/// the owner — in that order, because it is the order a reader stops at: what
1154/// this is, what it touches, who to ask. The co-change partners are not printed
1155/// again here: [`render_context`] has already listed them on its `co-change`
1156/// line, and [`ExploreReport::partners`] carries them for a caller reading the
1157/// report rather than the digest.
1158///
1159/// The budget is spent on whole lines ([`cap_bytes`]), so a path is never cut
1160/// in half — a half path still reads as a path, and a caller acts on it. The
1161/// header line is the one exception: rather than answer nothing at all, a
1162/// budget too small to hold it gets it cut to fit, on a character boundary.
1163/// Every budget the tool schema admits (200 tokens, 800 bytes) is many times a
1164/// real header, so that path is for a pathological target, not a small budget.
1165#[must_use]
1166pub fn render_explore(r: &ExploreReport, budget_bytes: usize) -> String {
1167    let mut out = render_context(&r.context);
1168
1169    if let Some(imp) = &r.impact {
1170        // `render_impact`'s own header counts the files it was given, which is
1171        // always the one file this target sits in — the heading says it better.
1172        let rendered = render_impact(imp);
1173        let mut body = rendered.lines().skip(1).peekable();
1174        if body.peek().is_some() {
1175            out.push_str("impact:\n");
1176            for line in body {
1177                let _ = writeln!(out, "  {line}");
1178            }
1179        }
1180    }
1181
1182    if let Some((name, key, share)) = r.owners.as_ref().and_then(|o| o.top.as_ref()) {
1183        let _ = writeln!(
1184            out,
1185            "owner: {} ({}) {share:.2} of the file's commits",
1186            sanitize(name),
1187            sanitize(key)
1188        );
1189    }
1190
1191    let capped = cap_bytes(&out, budget_bytes);
1192    if !capped.is_empty() || out.is_empty() || budget_bytes == 0 {
1193        return capped;
1194    }
1195    // The budget cannot hold the header whole — a target long enough to fill it
1196    // on its own. Cut it rather than answer nothing: the reply still names what
1197    // was looked up, and it still fits. One byte is reserved for the newline,
1198    // and the cut walks back to a character boundary so no line ends mid-rune.
1199    let head = out.lines().next().unwrap_or_default();
1200    let mut end = budget_bytes - 1;
1201    while end > 0 && !head.is_char_boundary(end) {
1202        end -= 1;
1203    }
1204    format!("{}\n", &head[..end])
1205}
1206
1207/// Render an [`OwnersReport`]: at most [`MAX_TOOL_LINES`] lines.
1208///
1209/// The author key is printed once, on the `top` line and in parentheses, so a
1210/// reader can address the person the graph means without every other line
1211/// carrying a mail address.
1212#[must_use]
1213pub fn render_owners(o: &OwnersReport) -> String {
1214    let mut out = String::new();
1215    let _ = writeln!(out, "mushroomdb owners — {}", sanitize(&o.path));
1216    if let Some((name, key, share)) = &o.top {
1217        let _ = writeln!(
1218            out,
1219            "top  {} ({}) {share:.2} of the file's commits",
1220            sanitize(name),
1221            sanitize(key)
1222        );
1223    }
1224    section(
1225        &mut out,
1226        "knows",
1227        &o.knows
1228            .iter()
1229            .map(|(name, score)| format!("{} {score:.2}", sanitize(name)))
1230            .collect::<Vec<_>>(),
1231    );
1232    if let Some((sha, ts, subject)) = &o.last_touch {
1233        let _ = writeln!(out, "last touch  {}", commit_line(sha, *ts, subject));
1234    }
1235    section(
1236        &mut out,
1237        "by quarter",
1238        &o.by_quarter
1239            .iter()
1240            .map(|(q, name, n)| format!("{} {} {n}", sanitize(q), sanitize(name)))
1241            .collect::<Vec<_>>(),
1242    );
1243    cap_lines(&out, MAX_TOOL_LINES)
1244}
1245
1246/// Render a [`WhyReport`]: at most [`MAX_TOOL_LINES`] lines.
1247#[must_use]
1248pub fn render_why(w: &WhyReport) -> String {
1249    let mut out = String::new();
1250    let _ = writeln!(
1251        out,
1252        "mushroomdb why — {} ↔ {}",
1253        sanitize(&w.a),
1254        sanitize(&w.b)
1255    );
1256    for key in &w.unknown {
1257        let _ = writeln!(out, "unknown: {}", sanitize(key));
1258    }
1259    if !w.unknown.is_empty() {
1260        return cap_lines(&out, MAX_TOOL_LINES);
1261    }
1262    let links = pair_up(&w.links);
1263    for (link, both_ways) in links.iter().take(MAX_WHY_LINKS) {
1264        render_link(&mut out, link, *both_ways);
1265    }
1266    if links.len() > MAX_WHY_LINKS {
1267        let _ = writeln!(
1268            out,
1269            "… {} not shown",
1270            plural(links.len() - MAX_WHY_LINKS, "link")
1271        );
1272    }
1273    if let Some(shared) = &w.shared {
1274        let _ = writeln!(
1275            out,
1276            "co-change  {}, below the co_changed rule's similarity floor so no edge was written",
1277            plural(shared.count, "shared commit")
1278        );
1279        for line in &shared.evidence {
1280            let _ = writeln!(out, "  {}", sanitize(line));
1281        }
1282    }
1283    if !w.path.is_empty() {
1284        let mut walk = sanitize(&w.a);
1285        for (edge_type, node) in &w.path {
1286            let _ = write!(walk, " -[{}]-> {}", sanitize(edge_type), sanitize(node));
1287        }
1288        let _ = writeln!(out, "path  {walk}");
1289    }
1290    if w.links.is_empty() && w.path.is_empty() && w.shared.is_none() {
1291        let _ = writeln!(out, "no link");
1292    }
1293    cap_lines(&out, MAX_TOOL_LINES)
1294}
1295
1296/// Pair off two edges that say the same thing in opposite directions.
1297///
1298/// A rule such as `co_changed` matches both ways round and the engine reports
1299/// an edge each way, scored the same and evidenced by the same commits.
1300/// Printing those commits twice says nothing the first printing did not, so the
1301/// second is folded into the first, which then reads `a↔b`.
1302///
1303/// The fold requires the score **and** the evidence to be equal, which is what
1304/// makes it safe: two files that import each other, or two documents that
1305/// mention each other, also have an edge each way, but each carries its own
1306/// line of a different file, and each of those lines is printed. The report
1307/// itself always keeps both edges — they are what the graph holds.
1308fn pair_up(links: &[WhyLink]) -> Vec<(&WhyLink, bool)> {
1309    let mut out: Vec<(&WhyLink, bool)> = Vec::new();
1310    let mut folded: Vec<bool> = vec![false; links.len()];
1311    for (i, link) in links.iter().enumerate() {
1312        if folded[i] {
1313            continue;
1314        }
1315        let mut both_ways = false;
1316        for (j, other) in links.iter().enumerate().skip(i + 1) {
1317            if !folded[j]
1318                && other.rule == link.rule
1319                && other.edge_type == link.edge_type
1320                && other.direction != link.direction
1321                && other.score == link.score
1322                && other.evidence == link.evidence
1323            {
1324                folded[j] = true;
1325                both_ways = true;
1326                break;
1327            }
1328        }
1329        out.push((link, both_ways));
1330    }
1331    out
1332}
1333
1334fn render_link(out: &mut String, link: &WhyLink, both_ways: bool) {
1335    let mut head = format!(
1336        "{} {}  {}",
1337        sanitize(&link.edge_type),
1338        if both_ways {
1339            "a↔b".to_string()
1340        } else {
1341            sanitize(&link.direction)
1342        },
1343        sanitize(&link.rule)
1344    );
1345    if let Some(score) = link.score {
1346        let _ = write!(head, " {score:.2}");
1347    }
1348    if let Some(via) = &link.via {
1349        let _ = write!(head, " via {}", sanitize(via));
1350    }
1351    let _ = writeln!(out, "{head}");
1352    for line in &link.evidence {
1353        let _ = writeln!(out, "  {}", sanitize(line));
1354    }
1355}
1356
1357#[cfg(test)]
1358mod tests {
1359    use super::*;
1360
1361    #[test]
1362    fn cap_bytes_keeps_whole_lines_and_never_half_of_one() {
1363        let text = "aaaa\nbbbb\ncccc\n"; // three five-byte lines
1364        assert_eq!(cap_bytes(text, 15), text, "the whole text fits exactly");
1365        assert_eq!(
1366            cap_bytes(text, 14),
1367            "aaaa\nbbbb\n",
1368            "the last line is whole"
1369        );
1370        assert_eq!(cap_bytes(text, 10), "aaaa\nbbbb\n");
1371        assert_eq!(cap_bytes(text, 9), "aaaa\n");
1372        assert_eq!(
1373            cap_bytes(text, 4),
1374            "",
1375            "a first line too long yields nothing, never a fragment"
1376        );
1377        assert_eq!(cap_bytes(text, 0), "");
1378        // A line with no trailing newline still costs the one it is given.
1379        assert_eq!(cap_bytes("abc", 4), "abc\n");
1380        assert_eq!(cap_bytes("abc", 3), "");
1381    }
1382
1383    #[test]
1384    fn a_timestamp_reads_as_a_utc_date_and_a_quarter() {
1385        // Epoch, a leap day, the end of a century that is not a leap year, and
1386        // a date before the epoch.
1387        for (ts, date, quarter) in [
1388            (0_i64, "1970-01-01", "1970Q1"),
1389            (1_582_934_400, "2020-02-29", "2020Q1"),
1390            (951_782_400, "2000-02-29", "2000Q1"),
1391            (1_600_000_000, "2020-09-13", "2020Q3"),
1392            (1_609_459_199, "2020-12-31", "2020Q4"),
1393            (1_609_459_200, "2021-01-01", "2021Q1"),
1394            (-1, "1969-12-31", "1969Q4"),
1395        ] {
1396            assert_eq!(ymd(ts), date, "{ts}");
1397            assert_eq!(quarter_label(quarter_index(ts)), quarter, "{ts}");
1398        }
1399    }
1400
1401    #[test]
1402    fn quarter_indices_are_a_count_a_window_can_be_measured_in() {
1403        let q3 = quarter_index(1_600_000_000); // 2020Q3
1404        assert_eq!(quarter_label(q3 - 3), "2019Q4");
1405        assert_eq!(quarter_label(q3 + 1), "2020Q4");
1406        assert_eq!(quarter_label(q3 + 2), "2021Q1");
1407    }
1408
1409    #[test]
1410    fn sanitize_replaces_every_control_character_one_for_one() {
1411        let forged = "Ada\nmushroomdb map\t— 9 files\u{7f}\u{1b}[31m";
1412        let clean = sanitize(forged);
1413        assert_eq!(clean.len(), forged.len(), "one byte in, one byte out");
1414        assert!(!clean.contains('\n') && !clean.contains('\t') && !clean.contains('\u{1b}'));
1415        assert_eq!(clean, "Ada mushroomdb map — 9 files  [31m");
1416    }
1417
1418    /// The four code points §5.12 names, each pinned on its own.
1419    #[test]
1420    fn sanitize_neutralizes_bidi_zero_width_and_separators() {
1421        for (cp, name) in [
1422            ('\u{202e}', "U+202E RIGHT-TO-LEFT OVERRIDE"),
1423            ('\u{200b}', "U+200B ZERO WIDTH SPACE"),
1424            ('\u{2028}', "U+2028 LINE SEPARATOR"),
1425            ('\u{2029}', "U+2029 PARAGRAPH SEPARATOR"),
1426        ] {
1427            let forged = format!("safe{cp}tail");
1428            let clean = sanitize(&forged);
1429            assert_eq!(clean, "safe tail", "{name} must render as one space");
1430            assert_eq!(
1431                clean.chars().count(),
1432                forged.chars().count(),
1433                "{name}: one char in, one char out"
1434            );
1435        }
1436    }
1437
1438    /// Neutralising only the four named code points leaves trivial bypasses:
1439    /// U+202D overrides just as U+202E does, U+2066-U+2069 are the isolate
1440    /// spelling of the same attack, and U+0085 forges a line break the way
1441    /// U+2028 does. The helper covers the class, not the examples.
1442    #[test]
1443    fn sanitize_covers_the_whole_class_not_just_the_named_four() {
1444        for cp in [
1445            '\u{202a}', '\u{202b}', '\u{202c}', '\u{202d}', // embeddings + LRO
1446            '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', // isolates
1447            '\u{200c}', '\u{200d}', '\u{200e}', '\u{200f}', // ZWNJ/ZWJ, LRM/RLM
1448            '\u{feff}', // BOM as zero-width no-break space
1449            '\u{0085}', // NEL — a line break outside ASCII
1450        ] {
1451            let clean = sanitize(&format!("a{cp}b"));
1452            assert_eq!(
1453                clean, "a b",
1454                "U+{:04X} is the same class as the four §5.12 names",
1455                cp as u32
1456            );
1457        }
1458    }
1459
1460    /// Every ASCII byte, exhaustively — the branch the fast path moved.
1461    ///
1462    /// `is_shape_forging` returns early for ASCII, so a mistake there would be
1463    /// invisible to the named-code-point tests above (all of which are
1464    /// non-ASCII) and would silently pass or drop control characters. 128
1465    /// assertions cost nothing and pin the whole branch rather than a sample.
1466    #[test]
1467    fn sanitize_classifies_every_ascii_byte() {
1468        for b in 0u8..128 {
1469            let c = b as char;
1470            let got = sanitize(&c.to_string());
1471            if c.is_ascii_control() {
1472                assert_eq!(got, " ", "U+{b:04X} is an ASCII control and must blank");
1473            } else {
1474                assert_eq!(
1475                    got,
1476                    c.to_string(),
1477                    "U+{b:04X} is printable ASCII and must survive untouched"
1478                );
1479            }
1480        }
1481    }
1482
1483    /// A caller's budget counts characters, so neutralising a 3-byte code
1484    /// point must not grow the string. Shrinking is fine; growing is not.
1485    #[test]
1486    fn sanitize_never_grows_a_string() {
1487        let forged = "subject\u{202e}\u{200b}\u{2028}\u{2029}tail";
1488        let clean = sanitize(forged);
1489        assert!(
1490            clean.len() <= forged.len(),
1491            "bytes must not grow: {} -> {}",
1492            forged.len(),
1493            clean.len()
1494        );
1495        assert_eq!(
1496            clean.chars().count(),
1497            forged.chars().count(),
1498            "characters are one for one"
1499        );
1500    }
1501
1502    #[test]
1503    fn thousands_groups_from_the_right() {
1504        for (n, want) in [
1505            (0, "0"),
1506            (7, "7"),
1507            (999, "999"),
1508            (1_000, "1,000"),
1509            (1_204, "1,204"),
1510            (999_999, "999,999"),
1511            (1_830_412, "1,830,412"),
1512        ] {
1513            assert_eq!(thousands(n), want, "{n}");
1514        }
1515    }
1516
1517    #[test]
1518    fn plural_says_one_file_and_two_files() {
1519        assert_eq!(plural(1, "file"), "1 file");
1520        assert_eq!(plural(0, "file"), "0 files");
1521        assert_eq!(plural(1_204, "commit"), "1,204 commits");
1522    }
1523
1524    #[test]
1525    fn age_picks_one_coarse_unit() {
1526        for (secs, want) in [
1527            (-5, "0s"),
1528            (0, "0s"),
1529            (59, "59s"),
1530            (60, "1m"),
1531            (720, "12m"),
1532            (3_600, "1h"),
1533            (86_399, "23h"),
1534            (86_400, "1d"),
1535            (20 * 86_400, "20d"),
1536        ] {
1537            assert_eq!(age(secs), want, "{secs}");
1538        }
1539    }
1540
1541    #[test]
1542    fn paths_split_into_a_base_and_its_directories() {
1543        assert_eq!(basename("src/core/db.rs"), "db.rs");
1544        assert_eq!(basename("README.md"), "README.md");
1545        assert_eq!(dir_components("src/core/db.rs"), vec!["src", "core"]);
1546        assert!(dir_components("README.md").is_empty());
1547    }
1548
1549    #[test]
1550    fn a_cluster_is_named_by_the_directory_its_files_share() {
1551        // One directory deep: the directory is the whole name.
1552        let same = vec![
1553            "crates/core-api/src/db.rs".to_string(),
1554            "crates/core-api/src/algo.rs".to_string(),
1555        ];
1556        assert_eq!(cluster_name(&same), "crates/core-api/src");
1557        // Split across subdirectories: they are what tells this cluster from
1558        // another one under the same root.
1559        let partial = vec![
1560            "crates/core-api/src/db.rs".to_string(),
1561            "crates/core-api/tests/algo.rs".to_string(),
1562        ];
1563        assert_eq!(cluster_name(&partial), "crates/core-api src, tests");
1564    }
1565
1566    #[test]
1567    fn files_sharing_no_directory_are_named_by_their_commonest_segments() {
1568        let mixed = vec![
1569            "docs/site/algorithms.md".to_string(),
1570            "docs/site/install.md".to_string(),
1571            "site/index.html".to_string(),
1572            "README.md".to_string(),
1573        ];
1574        // Nothing is shared at the root, so the name falls back to segments:
1575        // `site` appears in three keys, and `docs` in two.
1576        assert_eq!(cluster_name(&mixed), "<mixed> site, docs");
1577        assert_eq!(cluster_name(&["a.rs".to_string()]), "<mixed> a.rs");
1578        assert_eq!(cluster_name(&[]), "<mixed>");
1579    }
1580
1581    #[test]
1582    fn a_segment_counts_once_per_key_however_often_it_repeats() {
1583        let keys = vec!["a/a/a/a.rs".to_string(), "b/x.rs".to_string()];
1584        assert_eq!(top_tokens(&keys, "", 1, true), vec!["a".to_string()]);
1585        // Without `dirs_only` the filenames join the count and `a` still wins.
1586        assert_eq!(top_tokens(&keys, "", 1, false), vec!["a".to_string()]);
1587    }
1588
1589    #[test]
1590    fn short_names_keep_the_path_only_where_a_filename_repeats() {
1591        let keys = vec![
1592            "src/net/mod.rs".to_string(),
1593            "src/io/mod.rs".to_string(),
1594            "src/db.rs".to_string(),
1595        ];
1596        assert_eq!(
1597            short_names(&keys),
1598            vec!["src/net/mod.rs", "src/io/mod.rs", "db.rs"]
1599        );
1600    }
1601
1602    #[test]
1603    fn cap_lines_keeps_the_first_lines_and_a_trailing_newline() {
1604        assert_eq!(cap_lines("a\nb\nc\n", 2), "a\nb\n");
1605        assert_eq!(cap_lines("a\nb", 9), "a\nb\n");
1606        assert_eq!(cap_lines("", 9), "");
1607    }
1608}