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