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::context::{ContextReport, Target};
9use crate::repograph::impact::{FileImpact, ImpactReport, Partner};
10use crate::repograph::map::RepoMap;
11use crate::repograph::owners::OwnersReport;
12use crate::repograph::why::{WhyLink, WhyReport};
13use std::fmt::Write as _;
14
15/// Longest digest any `repograph` tool may print, in lines.
16pub const MAX_MAP_LINES: usize = 40;
17/// Longest [`render_context`] digest, in lines. Wider than the others because
18/// it quotes source.
19pub const MAX_CONTEXT_LINES: usize = 60;
20/// Longest digest every other tool here prints, in lines.
21pub const MAX_TOOL_LINES: usize = 25;
22
23/// Separator between the items of a one-line list.
24pub const SEP: &str = " · ";
25
26/// Replace every ASCII control character (`0x00-0x1f` and `0x7f`, tabs and
27/// newlines included) with a space, so a value read out of the graph cannot
28/// forge a line break, a section header, or a terminal escape sequence.
29///
30/// One byte in, one byte out, so a caller's size budget is unaffected.
31#[must_use]
32pub fn sanitize(s: &str) -> String {
33    s.chars()
34        .map(|c| if c.is_ascii_control() { ' ' } else { c })
35        .collect()
36}
37
38/// `1204` → `1,204`. Groups of three, ASCII digits only.
39#[must_use]
40pub fn thousands(n: usize) -> String {
41    let digits = n.to_string();
42    let mut out = String::with_capacity(digits.len() + digits.len() / 3);
43    for (i, c) in digits.chars().enumerate() {
44        if i > 0 && (digits.len() - i).is_multiple_of(3) {
45            out.push(',');
46        }
47        out.push(c);
48    }
49    out
50}
51
52/// `n` of `word`, pluralised by adding an `s`. `1 file`, `2 files`.
53#[must_use]
54pub fn plural(n: usize, word: &str) -> String {
55    if n == 1 {
56        format!("{n} {word}")
57    } else {
58        format!("{} {word}s", thousands(n))
59    }
60}
61
62/// A duration in seconds as one coarse unit: `45s`, `12m`, `3h`, `20d`.
63/// Negative input — a clock that ran backwards — reads as `0s`.
64#[must_use]
65pub fn age(secs: i64) -> String {
66    let s = secs.max(0);
67    if s < 60 {
68        format!("{s}s")
69    } else if s < 3_600 {
70        format!("{}m", s / 60)
71    } else if s < 86_400 {
72        format!("{}h", s / 3_600)
73    } else {
74        format!("{}d", s / 86_400)
75    }
76}
77
78/// Seconds in a day.
79const DAY: i64 = 86_400;
80
81/// The civil `(year, month, day)` a count of days since 1970-01-01 falls on,
82/// proleptic Gregorian. Days before the epoch are negative and convert the
83/// same way.
84///
85/// This is the days-to-civil algorithm every calendar library implements; it
86/// is here rather than behind a dependency because two dozen lines of integer
87/// arithmetic is the whole of what these digests need a calendar for.
88fn civil_from_days(days: i64) -> (i64, u32, u32) {
89    // Shift the epoch to 0000-03-01, so a leap day is always the last day of
90    // the (shifted) year and the month arithmetic below needs no special case.
91    let z = days + 719_468;
92    let era = z.div_euclid(146_097);
93    let doe = z.rem_euclid(146_097); // day of era, 0..=146_096
94    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // 0..=399
95    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // day of shifted year
96    let mp = (5 * doy + 2) / 153; // shifted month, 0..=11 with March = 0
97    let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
98    let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
99    let year = yoe + era * 400 + i64::from(month <= 2);
100    (year, month, day)
101}
102
103/// A Unix timestamp as a calendar date in UTC: `2026-09-04`.
104#[must_use]
105pub fn ymd(ts: i64) -> String {
106    let (y, m, d) = civil_from_days(ts.div_euclid(DAY));
107    format!("{y:04}-{m:02}-{d:02}")
108}
109
110/// The quarter a timestamp falls in, counted from year 0 so that subtracting
111/// one index from another gives a number of quarters.
112#[must_use]
113pub fn quarter_index(ts: i64) -> i64 {
114    let (y, m, _) = civil_from_days(ts.div_euclid(DAY));
115    y * 4 + i64::from((m - 1) / 3)
116}
117
118/// A quarter index as its label: `2026Q3`.
119#[must_use]
120pub fn quarter_label(index: i64) -> String {
121    format!("{}Q{}", index.div_euclid(4), index.rem_euclid(4) + 1)
122}
123
124/// The last `/`-separated segment of a key: `src/core/db.rs` → `db.rs`.
125#[must_use]
126pub fn basename(key: &str) -> &str {
127    key.rsplit_once('/').map_or(key, |(_, base)| base)
128}
129
130/// The directory segments of a key: `src/core/db.rs` → `["src", "core"]`.
131/// A key with no `/` has none.
132#[must_use]
133pub fn dir_components(key: &str) -> Vec<&str> {
134    let mut parts: Vec<&str> = key.split('/').collect();
135    parts.pop();
136    parts
137}
138
139/// The longest directory prefix every key shares, `/`-joined. Empty when the
140/// keys share no leading directory at all.
141#[must_use]
142pub fn common_dir_prefix(keys: &[String]) -> String {
143    let mut iter = keys.iter().map(|k| dir_components(k));
144    let Some(mut prefix) = iter.next() else {
145        return String::new();
146    };
147    for comps in iter {
148        let shared = prefix
149            .iter()
150            .zip(comps.iter())
151            .take_while(|(a, b)| a == b)
152            .count();
153        prefix.truncate(shared);
154        if prefix.is_empty() {
155            break;
156        }
157    }
158    prefix.join("/")
159}
160
161/// The `n` path segments most keys carry, ignoring `prefix`.
162///
163/// A segment is counted once per key, so a directory that appears in twenty
164/// keys beats a filename that appears in one. Ties go to the segment that
165/// sorts first, which is what makes the answer stable. With `dirs_only` the
166/// basename is skipped, leaving the segments that say where a file lives.
167#[must_use]
168pub fn top_tokens(keys: &[String], prefix: &str, n: usize, dirs_only: bool) -> Vec<String> {
169    let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
170    for key in keys {
171        let rest = match prefix.is_empty() {
172            true => key.as_str(),
173            false => key
174                .strip_prefix(prefix)
175                .unwrap_or(key)
176                .trim_start_matches('/'),
177        };
178        let mut seen: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect();
179        if dirs_only {
180            seen.pop();
181        }
182        seen.sort_unstable();
183        seen.dedup();
184        for token in seen {
185            *counts.entry(token).or_default() += 1;
186        }
187    }
188    let mut ranked: Vec<(&str, usize)> = counts.into_iter().collect();
189    ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
190    ranked
191        .into_iter()
192        .take(n)
193        .map(|(t, _)| t.to_string())
194        .collect()
195}
196
197/// What a set of files with no shared directory is called.
198pub const MIXED: &str = "<mixed>";
199
200/// What to call a set of files.
201///
202/// The directory they all sit under, when there is one — that is the name a
203/// person would use — followed by the two subdirectories most of them sit in,
204/// which is what tells two clusters under the same root apart. Files that
205/// share no directory get [`MIXED`] in the prefix's place.
206///
207/// Files sitting directly in the shared directory add nothing to it, so a
208/// cluster that is exactly one directory deep is named by that directory
209/// alone.
210#[must_use]
211pub fn cluster_name(keys: &[String]) -> String {
212    let prefix = common_dir_prefix(keys);
213    let head = if prefix.is_empty() {
214        MIXED.to_string()
215    } else {
216        prefix.clone()
217    };
218    let mut tokens = top_tokens(keys, &prefix, 2, true);
219    if tokens.is_empty() && prefix.is_empty() {
220        // Everything is at the root: the filenames are all there is to say.
221        tokens = top_tokens(keys, &prefix, 2, false);
222    }
223    if tokens.is_empty() {
224        head
225    } else {
226        format!("{head} {}", tokens.join(", "))
227    }
228}
229
230/// Shorten keys to their filenames, keeping the full path for any filename
231/// that would otherwise appear twice.
232///
233/// `mod.rs, mod.rs` names nothing; `src/net/mod.rs, src/io/mod.rs` names two
234/// files. Sanitized, since the result is printed.
235#[must_use]
236pub fn short_names(keys: &[String]) -> Vec<String> {
237    let mut seen: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
238    for key in keys {
239        *seen.entry(basename(key)).or_default() += 1;
240    }
241    keys.iter()
242        .map(|k| match seen.get(basename(k)) {
243            Some(1) => sanitize(basename(k)),
244            _ => sanitize(k),
245        })
246        .collect()
247}
248
249/// Keep at most `max` lines, dropping the rest.
250#[must_use]
251pub fn cap_lines(text: &str, max: usize) -> String {
252    let mut out = String::with_capacity(text.len());
253    for line in text.lines().take(max) {
254        out.push_str(line);
255        out.push('\n');
256    }
257    out
258}
259
260/// The one line a store with nothing in it gets: what is missing, and the
261/// command that fixes it.
262pub const EMPTY_MAP: &str =
263    "mushroomdb map — empty store; run: mushroomdb ingest-git <db> <repo>\n";
264
265/// Render a [`RepoMap`] as the digest an assistant reads: at most
266/// [`MAX_MAP_LINES`] lines, byte-identical for the same map.
267///
268/// Every value that came out of the graph is sanitized again here, so the
269/// output is safe whether or not the map was built by
270/// [`repo_map`](crate::repograph::repo_map).
271#[must_use]
272pub fn render_map(m: &RepoMap) -> String {
273    if m.files == 0 {
274        return EMPTY_MAP.to_string();
275    }
276    let mut out = String::new();
277
278    // Header: the size of the graph, and how current it is.
279    let sync = match &m.last_sync {
280        None => "not synced".to_string(),
281        Some(s) => {
282            let sha = sanitize(&s.sha);
283            let short: String = sha.chars().take(7).collect();
284            match s.age_secs {
285                Some(secs) => format!("synced {} ago at {short}", age(secs)),
286                None => format!("synced at {short}"),
287            }
288        }
289    };
290    let _ = writeln!(
291        out,
292        "mushroomdb map — {}, {}, {}, {} · {sync}{}",
293        plural(m.files, "file"),
294        plural(m.symbols, "symbol"),
295        plural(m.commits, "commit"),
296        plural(m.authors, "author"),
297        if m.truncated { " (truncated)" } else { "" }
298    );
299
300    if !m.communities.is_empty() {
301        out.push_str("clusters (co-change + imports)\n");
302        for (i, c) in m.communities.iter().enumerate() {
303            let samples = short_names(&c.samples);
304            let _ = writeln!(
305                out,
306                "  {}. {}  ({}, cohesion {:.2}){}{}",
307                i + 1,
308                sanitize(&c.name),
309                plural(c.size, "file"),
310                c.cohesion,
311                if samples.is_empty() { "" } else { "  " },
312                samples.join(", ")
313            );
314        }
315    }
316
317    if !m.key_files.is_empty() {
318        out.push_str("key files (most depended-on)\n");
319        // Two decimals, like every other float here. A PageRank score is a
320        // ranking, and the order it is printed in already carries that; the
321        // number is there for the gap between one file and the next.
322        let items: Vec<String> = m
323            .key_files
324            .iter()
325            .map(|(k, s)| format!("{} {s:.2}", sanitize(k)))
326            .collect();
327        let _ = writeln!(out, "  {}", items.join(SEP));
328    }
329
330    if !m.owners.is_empty() {
331        out.push_str("owners\n");
332        let items: Vec<String> = m
333            .owners
334            .iter()
335            .enumerate()
336            .map(|(i, (name, n))| match i {
337                // The unit is stated once, on the first entry.
338                0 => format!("{} {}", sanitize(name), plural(*n, "file")),
339                _ => format!("{} {n}", sanitize(name)),
340            })
341            .collect();
342        let _ = writeln!(out, "  {}", items.join(SEP));
343    }
344
345    if !m.hot_files.is_empty() {
346        let _ = writeln!(out, "hot (last {} days)", m.hot_days);
347        let items: Vec<String> = m
348            .hot_files
349            .iter()
350            .map(|(k, n)| format!("{} {n}", sanitize(k)))
351            .collect();
352        let _ = writeln!(out, "  {}", items.join(SEP));
353    }
354
355    if m.stale_concepts > 0 {
356        let (noun, verb) = if m.stale_concepts == 1 {
357            ("concept", "needs")
358        } else {
359            ("concepts", "need")
360        };
361        let _ = writeln!(
362            out,
363            "notes: {} {noun} {verb} re-learning (source changed)",
364            m.stale_concepts
365        );
366    }
367
368    if !m.questions.is_empty() {
369        let asks: Vec<String> = m.questions.iter().map(|q| sanitize(q)).collect();
370        let _ = writeln!(out, "ask me: {}", asks.join(SEP));
371    }
372
373    cap_lines(&out, MAX_MAP_LINES)
374}
375
376// ── the four per-node digests ───────────────────────────────────────────────
377
378/// Source lines [`render_context`] prints before it says how many are left.
379/// The report keeps up to
380/// [`MAX_SOURCE_LINES`](crate::repograph::MAX_SOURCE_LINES); a digest that
381/// quoted all of them would have room for nothing else.
382const MAX_SOURCE_PRINTED: usize = 40;
383/// Candidates [`render_context`] lists for an ambiguous name. Past this many
384/// the list is not a choice anyone can make from a digest, and the caller wants
385/// a longer key rather than a longer list.
386const MAX_CANDIDATES: usize = 20;
387/// Files [`render_impact`] prints in full.
388const MAX_IMPACT_FILES: usize = 5;
389/// Paths [`render_impact`] names on an `unknown:` line before counting the
390/// rest.
391///
392/// One line per unknown path, written before [`cap_lines`] runs, means a
393/// repository with untracked build or result artefacts spends its whole
394/// budget on them: a default `impact` here rendered 27 lines of which 20 were
395/// `unknown:`, evicting the analysis it was asked for. The defaults
396/// (`target/`, `node_modules/`, `dist/`, …) do not and should not cover every
397/// output directory anyone might have, so the render caps instead.
398const MAX_IMPACT_UNKNOWN: usize = 3;
399/// Links [`render_why`] prints in full.
400const MAX_WHY_LINKS: usize = 5;
401
402/// Write a `name  a · b · c` section, or nothing when there is nothing to say.
403fn section(out: &mut String, name: &str, items: &[String]) {
404    if !items.is_empty() {
405        let _ = writeln!(out, "{name}  {}", items.join(SEP));
406    }
407}
408
409/// `(sha, ts, subject)` as one line of a digest.
410fn commit_line(sha: &str, ts: i64, subject: &str) -> String {
411    let short: String = sanitize(sha).chars().take(7).collect();
412    format!("{short} {} {}", ymd(ts), sanitize(subject))
413}
414
415/// Render a [`ContextReport`] as the digest an assistant reads: at most
416/// [`MAX_CONTEXT_LINES`] lines, byte-identical for the same report.
417#[must_use]
418pub fn render_context(c: &ContextReport) -> String {
419    let mut out = String::new();
420    match &c.target {
421        Target::Unknown { target } if c.candidates.is_empty() => {
422            let _ = writeln!(out, "mushroomdb context — unknown: {}", sanitize(target));
423            return out;
424        }
425        Target::Unknown { target } => {
426            let _ = writeln!(
427                out,
428                "mushroomdb context — {} is ambiguous: {}",
429                sanitize(target),
430                plural(c.candidates.len(), "symbol")
431            );
432            for key in c.candidates.iter().take(MAX_CANDIDATES) {
433                let _ = writeln!(out, "  {}", sanitize(key));
434            }
435            if c.candidates.len() > MAX_CANDIDATES {
436                let _ = writeln!(
437                    out,
438                    "  … {} not shown",
439                    plural(c.candidates.len() - MAX_CANDIDATES, "symbol")
440                );
441            }
442            return cap_lines(&out, MAX_CONTEXT_LINES);
443        }
444        Target::File { path } => {
445            let _ = writeln!(out, "mushroomdb context — file {}", sanitize(path));
446        }
447        Target::Symbol { key } => {
448            let _ = writeln!(
449                out,
450                "mushroomdb context — symbol {} in {}",
451                sanitize(key),
452                sanitize(&c.file)
453            );
454        }
455    }
456
457    if let Some(sig) = &c.signature {
458        let _ = writeln!(out, "signature  {}", sanitize(sig));
459    }
460    if let Some(doc) = &c.doc {
461        let _ = writeln!(out, "doc  {}", sanitize(doc));
462    }
463    let mut about: Vec<String> = Vec::new();
464    if let Some((first, last)) = c.lines {
465        about.push(format!("lines {first}-{last}"));
466    }
467    if let Some(owner) = &c.owner {
468        about.push(format!("owner {}", sanitize(owner)));
469    }
470    section(&mut out, "where", &about);
471
472    if let Some(source) = &c.source {
473        let first = c.lines.map_or(1, |(first, _)| first);
474        let total = source.lines().count();
475        let _ = writeln!(out, "source");
476        for (i, line) in source.lines().take(MAX_SOURCE_PRINTED).enumerate() {
477            let n = first as usize + i;
478            let _ = writeln!(out, "  {n:>5} | {}", sanitize(line));
479        }
480        if total > MAX_SOURCE_PRINTED {
481            let _ = writeln!(
482                out,
483                "  … {} more",
484                plural(total - MAX_SOURCE_PRINTED, "line")
485            );
486        }
487    }
488
489    // Callers read as `<file>: <line>, <line>`: every site a signature change
490    // would have to visit, and the file to open to visit them.
491    let mut callers: Vec<String> = c
492        .callers
493        .iter()
494        .map(|s| {
495            let lines: Vec<String> = s
496                .lines
497                .iter()
498                .filter(|n| **n > 0)
499                .map(u32::to_string)
500                .collect();
501            let more = s.sites.saturating_sub(s.lines.len());
502            let mut item = match lines.is_empty() {
503                true => sanitize(&s.file),
504                false => format!("{}: {}", sanitize(&s.file), lines.join(", ")),
505            };
506            if more > 0 {
507                let _ = write!(item, " …(+{more})");
508            }
509            item
510        })
511        .collect();
512    if c.callers_not_shown > 0 {
513        callers.push(format!(
514            "… {} not shown",
515            plural(c.callers_not_shown, "file")
516        ));
517    }
518    section(&mut out, "callers", &callers);
519    let callees: Vec<String> = c
520        .callees
521        .iter()
522        .map(|(key, line)| match line {
523            0 => sanitize(key),
524            n => format!("{} line {n}", sanitize(key)),
525        })
526        .collect();
527    section(&mut out, "callees", &callees);
528    section(
529        &mut out,
530        "imports",
531        &c.imports.iter().map(|k| sanitize(k)).collect::<Vec<_>>(),
532    );
533    section(
534        &mut out,
535        "importers",
536        &c.importers.iter().map(|k| sanitize(k)).collect::<Vec<_>>(),
537    );
538    section(
539        &mut out,
540        "co-change",
541        &c.partners
542            .iter()
543            .map(|(k, s)| format!("{} {s:.2}", sanitize(k)))
544            .collect::<Vec<_>>(),
545    );
546    section(
547        &mut out,
548        "commits",
549        &c.recent_commits
550            .iter()
551            .map(|(sha, ts, subject)| commit_line(sha, *ts, subject))
552            .collect::<Vec<_>>(),
553    );
554    for (key, text) in &c.notes {
555        let _ = writeln!(out, "note  {} {}", sanitize(key), sanitize(text));
556    }
557    for (key, name) in &c.concepts {
558        let _ = writeln!(out, "concept  {} {}", sanitize(key), sanitize(name));
559    }
560    cap_lines(&out, MAX_CONTEXT_LINES)
561}
562
563/// One partner or importer as `path score modified`, with the parts that say
564/// nothing left off.
565fn partner_item(p: &Partner, with_score: bool) -> String {
566    let mut item = sanitize(&p.path);
567    // A partner found by how often the two change together carries a count, not
568    // a similarity, and saying so is the point: the two do not compare, and a
569    // reader who sees `0.10` beside `0.78` draws the wrong conclusion.
570    match p.shared_commits {
571        Some(n) => {
572            let _ = write!(item, " ({})", plural(n, "shared commit"));
573        }
574        None if with_score => {
575            let _ = write!(item, " {:.2}", p.score);
576        }
577        None => {}
578    }
579    if p.modified {
580        item.push_str(" modified");
581    }
582    item
583}
584
585/// Render an [`ImpactReport`]: at most [`MAX_TOOL_LINES`] lines.
586#[must_use]
587pub fn render_impact(r: &ImpactReport) -> String {
588    let mut out = String::new();
589    let _ = writeln!(
590        out,
591        "mushroomdb impact — {}",
592        plural(r.files.len(), "changed file")
593    );
594    for f in r.files.iter().take(MAX_IMPACT_FILES) {
595        render_file_impact(&mut out, f);
596    }
597    if r.files.len() > MAX_IMPACT_FILES {
598        let _ = writeln!(
599            out,
600            "… {} not shown",
601            plural(r.files.len() - MAX_IMPACT_FILES, "file")
602        );
603    }
604    for path in r.unknown.iter().take(MAX_IMPACT_UNKNOWN) {
605        let _ = writeln!(out, "unknown: {}", sanitize(path));
606    }
607    if r.unknown.len() > MAX_IMPACT_UNKNOWN {
608        let _ = writeln!(
609            out,
610            "…and {} more unknown",
611            r.unknown.len() - MAX_IMPACT_UNKNOWN
612        );
613    }
614    cap_lines(&out, MAX_TOOL_LINES)
615}
616
617fn render_file_impact(out: &mut String, f: &FileImpact) {
618    match &f.owner {
619        Some(owner) => {
620            let _ = writeln!(out, "{} ({})", sanitize(&f.path), sanitize(owner));
621        }
622        None => {
623            let _ = writeln!(out, "{}", sanitize(&f.path));
624        }
625    }
626    section(
627        out,
628        "  partners ",
629        &f.partners
630            .iter()
631            .map(|p| partner_item(p, true))
632            .collect::<Vec<_>>(),
633    );
634    section(
635        out,
636        "  importers",
637        &f.importers
638            .iter()
639            .map(|p| partner_item(p, false))
640            .collect::<Vec<_>>(),
641    );
642    section(
643        out,
644        "  used by  ",
645        &f.symbols_used_elsewhere
646            .iter()
647            .map(|(key, n)| format!("{} {}", sanitize(key), plural(*n, "caller")))
648            .collect::<Vec<_>>(),
649    );
650}
651
652/// Render an [`OwnersReport`]: at most [`MAX_TOOL_LINES`] lines.
653///
654/// The author key is printed once, on the `top` line and in parentheses, so a
655/// reader can address the person the graph means without every other line
656/// carrying a mail address.
657#[must_use]
658pub fn render_owners(o: &OwnersReport) -> String {
659    let mut out = String::new();
660    let _ = writeln!(out, "mushroomdb owners — {}", sanitize(&o.path));
661    if let Some((name, key, share)) = &o.top {
662        let _ = writeln!(
663            out,
664            "top  {} ({}) {share:.2} of the file's commits",
665            sanitize(name),
666            sanitize(key)
667        );
668    }
669    section(
670        &mut out,
671        "knows",
672        &o.knows
673            .iter()
674            .map(|(name, score)| format!("{} {score:.2}", sanitize(name)))
675            .collect::<Vec<_>>(),
676    );
677    if let Some((sha, ts, subject)) = &o.last_touch {
678        let _ = writeln!(out, "last touch  {}", commit_line(sha, *ts, subject));
679    }
680    section(
681        &mut out,
682        "by quarter",
683        &o.by_quarter
684            .iter()
685            .map(|(q, name, n)| format!("{} {} {n}", sanitize(q), sanitize(name)))
686            .collect::<Vec<_>>(),
687    );
688    cap_lines(&out, MAX_TOOL_LINES)
689}
690
691/// Render a [`WhyReport`]: at most [`MAX_TOOL_LINES`] lines.
692#[must_use]
693pub fn render_why(w: &WhyReport) -> String {
694    let mut out = String::new();
695    let _ = writeln!(
696        out,
697        "mushroomdb why — {} ↔ {}",
698        sanitize(&w.a),
699        sanitize(&w.b)
700    );
701    for key in &w.unknown {
702        let _ = writeln!(out, "unknown: {}", sanitize(key));
703    }
704    if !w.unknown.is_empty() {
705        return cap_lines(&out, MAX_TOOL_LINES);
706    }
707    let links = pair_up(&w.links);
708    for (link, both_ways) in links.iter().take(MAX_WHY_LINKS) {
709        render_link(&mut out, link, *both_ways);
710    }
711    if links.len() > MAX_WHY_LINKS {
712        let _ = writeln!(
713            out,
714            "… {} not shown",
715            plural(links.len() - MAX_WHY_LINKS, "link")
716        );
717    }
718    if let Some(shared) = &w.shared {
719        let _ = writeln!(
720            out,
721            "co-change  {}, below the co_changed rule's similarity floor so no edge was written",
722            plural(shared.count, "shared commit")
723        );
724        for line in &shared.evidence {
725            let _ = writeln!(out, "  {}", sanitize(line));
726        }
727    }
728    if !w.path.is_empty() {
729        let mut walk = sanitize(&w.a);
730        for (edge_type, node) in &w.path {
731            let _ = write!(walk, " -[{}]-> {}", sanitize(edge_type), sanitize(node));
732        }
733        let _ = writeln!(out, "path  {walk}");
734    }
735    if w.links.is_empty() && w.path.is_empty() && w.shared.is_none() {
736        let _ = writeln!(out, "no link");
737    }
738    cap_lines(&out, MAX_TOOL_LINES)
739}
740
741/// Pair off two edges that say the same thing in opposite directions.
742///
743/// A rule such as `co_changed` matches both ways round and the engine reports
744/// an edge each way, scored the same and evidenced by the same commits.
745/// Printing those commits twice says nothing the first printing did not, so the
746/// second is folded into the first, which then reads `a↔b`.
747///
748/// The fold requires the score **and** the evidence to be equal, which is what
749/// makes it safe: two files that import each other, or two documents that
750/// mention each other, also have an edge each way, but each carries its own
751/// line of a different file, and each of those lines is printed. The report
752/// itself always keeps both edges — they are what the graph holds.
753fn pair_up(links: &[WhyLink]) -> Vec<(&WhyLink, bool)> {
754    let mut out: Vec<(&WhyLink, bool)> = Vec::new();
755    let mut folded: Vec<bool> = vec![false; links.len()];
756    for (i, link) in links.iter().enumerate() {
757        if folded[i] {
758            continue;
759        }
760        let mut both_ways = false;
761        for (j, other) in links.iter().enumerate().skip(i + 1) {
762            if !folded[j]
763                && other.rule == link.rule
764                && other.edge_type == link.edge_type
765                && other.direction != link.direction
766                && other.score == link.score
767                && other.evidence == link.evidence
768            {
769                folded[j] = true;
770                both_ways = true;
771                break;
772            }
773        }
774        out.push((link, both_ways));
775    }
776    out
777}
778
779fn render_link(out: &mut String, link: &WhyLink, both_ways: bool) {
780    let mut head = format!(
781        "{} {}  {}",
782        sanitize(&link.edge_type),
783        if both_ways {
784            "a↔b".to_string()
785        } else {
786            sanitize(&link.direction)
787        },
788        sanitize(&link.rule)
789    );
790    if let Some(score) = link.score {
791        let _ = write!(head, " {score:.2}");
792    }
793    if let Some(via) = &link.via {
794        let _ = write!(head, " via {}", sanitize(via));
795    }
796    let _ = writeln!(out, "{head}");
797    for line in &link.evidence {
798        let _ = writeln!(out, "  {}", sanitize(line));
799    }
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805
806    #[test]
807    fn a_timestamp_reads_as_a_utc_date_and_a_quarter() {
808        // Epoch, a leap day, the end of a century that is not a leap year, and
809        // a date before the epoch.
810        for (ts, date, quarter) in [
811            (0_i64, "1970-01-01", "1970Q1"),
812            (1_582_934_400, "2020-02-29", "2020Q1"),
813            (951_782_400, "2000-02-29", "2000Q1"),
814            (1_600_000_000, "2020-09-13", "2020Q3"),
815            (1_609_459_199, "2020-12-31", "2020Q4"),
816            (1_609_459_200, "2021-01-01", "2021Q1"),
817            (-1, "1969-12-31", "1969Q4"),
818        ] {
819            assert_eq!(ymd(ts), date, "{ts}");
820            assert_eq!(quarter_label(quarter_index(ts)), quarter, "{ts}");
821        }
822    }
823
824    #[test]
825    fn quarter_indices_are_a_count_a_window_can_be_measured_in() {
826        let q3 = quarter_index(1_600_000_000); // 2020Q3
827        assert_eq!(quarter_label(q3 - 3), "2019Q4");
828        assert_eq!(quarter_label(q3 + 1), "2020Q4");
829        assert_eq!(quarter_label(q3 + 2), "2021Q1");
830    }
831
832    #[test]
833    fn sanitize_replaces_every_control_character_one_for_one() {
834        let forged = "Ada\nmushroomdb map\t— 9 files\u{7f}\u{1b}[31m";
835        let clean = sanitize(forged);
836        assert_eq!(clean.len(), forged.len(), "one byte in, one byte out");
837        assert!(!clean.contains('\n') && !clean.contains('\t') && !clean.contains('\u{1b}'));
838        assert_eq!(clean, "Ada mushroomdb map — 9 files  [31m");
839    }
840
841    #[test]
842    fn thousands_groups_from_the_right() {
843        for (n, want) in [
844            (0, "0"),
845            (7, "7"),
846            (999, "999"),
847            (1_000, "1,000"),
848            (1_204, "1,204"),
849            (999_999, "999,999"),
850            (1_830_412, "1,830,412"),
851        ] {
852            assert_eq!(thousands(n), want, "{n}");
853        }
854    }
855
856    #[test]
857    fn plural_says_one_file_and_two_files() {
858        assert_eq!(plural(1, "file"), "1 file");
859        assert_eq!(plural(0, "file"), "0 files");
860        assert_eq!(plural(1_204, "commit"), "1,204 commits");
861    }
862
863    #[test]
864    fn age_picks_one_coarse_unit() {
865        for (secs, want) in [
866            (-5, "0s"),
867            (0, "0s"),
868            (59, "59s"),
869            (60, "1m"),
870            (720, "12m"),
871            (3_600, "1h"),
872            (86_399, "23h"),
873            (86_400, "1d"),
874            (20 * 86_400, "20d"),
875        ] {
876            assert_eq!(age(secs), want, "{secs}");
877        }
878    }
879
880    #[test]
881    fn paths_split_into_a_base_and_its_directories() {
882        assert_eq!(basename("src/core/db.rs"), "db.rs");
883        assert_eq!(basename("README.md"), "README.md");
884        assert_eq!(dir_components("src/core/db.rs"), vec!["src", "core"]);
885        assert!(dir_components("README.md").is_empty());
886    }
887
888    #[test]
889    fn a_cluster_is_named_by_the_directory_its_files_share() {
890        // One directory deep: the directory is the whole name.
891        let same = vec![
892            "crates/core-api/src/db.rs".to_string(),
893            "crates/core-api/src/algo.rs".to_string(),
894        ];
895        assert_eq!(cluster_name(&same), "crates/core-api/src");
896        // Split across subdirectories: they are what tells this cluster from
897        // another one under the same root.
898        let partial = vec![
899            "crates/core-api/src/db.rs".to_string(),
900            "crates/core-api/tests/algo.rs".to_string(),
901        ];
902        assert_eq!(cluster_name(&partial), "crates/core-api src, tests");
903    }
904
905    #[test]
906    fn files_sharing_no_directory_are_named_by_their_commonest_segments() {
907        let mixed = vec![
908            "docs/site/algorithms.md".to_string(),
909            "docs/site/install.md".to_string(),
910            "site/index.html".to_string(),
911            "README.md".to_string(),
912        ];
913        // Nothing is shared at the root, so the name falls back to segments:
914        // `site` appears in three keys, and `docs` in two.
915        assert_eq!(cluster_name(&mixed), "<mixed> site, docs");
916        assert_eq!(cluster_name(&["a.rs".to_string()]), "<mixed> a.rs");
917        assert_eq!(cluster_name(&[]), "<mixed>");
918    }
919
920    #[test]
921    fn a_segment_counts_once_per_key_however_often_it_repeats() {
922        let keys = vec!["a/a/a/a.rs".to_string(), "b/x.rs".to_string()];
923        assert_eq!(top_tokens(&keys, "", 1, true), vec!["a".to_string()]);
924        // Without `dirs_only` the filenames join the count and `a` still wins.
925        assert_eq!(top_tokens(&keys, "", 1, false), vec!["a".to_string()]);
926    }
927
928    #[test]
929    fn short_names_keep_the_path_only_where_a_filename_repeats() {
930        let keys = vec![
931            "src/net/mod.rs".to_string(),
932            "src/io/mod.rs".to_string(),
933            "src/db.rs".to_string(),
934        ];
935        assert_eq!(
936            short_names(&keys),
937            vec!["src/net/mod.rs", "src/io/mod.rs", "db.rs"]
938        );
939    }
940
941    #[test]
942    fn cap_lines_keeps_the_first_lines_and_a_trailing_newline() {
943        assert_eq!(cap_lines("a\nb\nc\n", 2), "a\nb\n");
944        assert_eq!(cap_lines("a\nb", 9), "a\nb\n");
945        assert_eq!(cap_lines("", 9), "");
946    }
947}