1use 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
18pub const MAX_MAP_LINES: usize = 40;
20pub const MAX_CONTEXT_LINES: usize = 60;
23pub const MAX_TOOL_LINES: usize = 25;
25
26pub const SEP: &str = " · ";
28
29#[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#[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#[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#[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
81const DAY: i64 = 86_400;
83
84fn civil_from_days(days: i64) -> (i64, u32, u32) {
92 let z = days + 719_468;
95 let era = z.div_euclid(146_097);
96 let doe = z.rem_euclid(146_097); let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; 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#[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#[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#[must_use]
123pub fn quarter_label(index: i64) -> String {
124 format!("{}Q{}", index.div_euclid(4), index.rem_euclid(4) + 1)
125}
126
127#[must_use]
129pub fn basename(key: &str) -> &str {
130 key.rsplit_once('/').map_or(key, |(_, base)| base)
131}
132
133#[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#[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#[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
200pub const MIXED: &str = "<mixed>";
202
203#[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 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#[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#[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#[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
282pub const EMPTY_MAP: &str =
285 "mushroomdb map — empty store; run: mushroomdb ingest-git <db> <repo>\n";
286
287#[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 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 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 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
398pub const MAX_BRIEF_BYTES: usize = 4_000;
406
407pub const EMPTY_BRIEF: &str =
416 "mushroomdb brief — empty store; run: mushroomdb ingest-git <db> <repo>\n";
417
418const BRIEF_FILES_HEADING: &str = "key files (by centrality):\n";
420const BRIEF_SYMBOLS_HEADING: &str = "key symbols (most called):\n";
421const BRIEF_LABELS_HEADING: &str = "labels:\n";
423const BRIEF_EDGE_TYPES_HEADING: &str = "edge types:\n";
424const BRIEF_RECIPES_HEADING: &str = "ask in one call:\n";
428
429#[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 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 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
507fn 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
524fn render_memory_brief(b: &BriefReport, s: &SchemaBrief, budget: usize) -> String {
562 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 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 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 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 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 return cap_bytes(&body, budget);
720 }
721 kept -= 1;
722 }
723}
724
725const BRIEF_NAME_CAP: usize = 60;
733
734fn 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
751fn 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
775fn 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
791fn suffix(detail: &str) -> String {
793 if detail.is_empty() {
794 String::new()
795 } else {
796 format!(" — {}", sanitize(detail))
797 }
798}
799
800const MAX_SOURCE_PRINTED: usize = 40;
807const MAX_CANDIDATES: usize = 20;
811const MAX_IMPACT_FILES: usize = 5;
813const MAX_IMPACT_UNKNOWN: usize = 3;
823const MAX_WHY_LINKS: usize = 5;
825
826fn section(out: &mut String, name: &str, items: &[String]) {
828 if !items.is_empty() {
829 let _ = writeln!(out, "{name} {}", items.join(SEP));
830 }
831}
832
833fn 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#[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 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 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
999fn partner_item(p: &Partner, with_score: bool) -> String {
1002 let mut item = sanitize(&p.path);
1003 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#[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
1088pub const DEFAULT_EXPLORE_BYTES: usize = 4_800;
1095
1096#[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 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 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#[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#[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
1242fn 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"; 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 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 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); 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 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 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 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 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}