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]
46pub fn sanitize(s: &str) -> String {
47 s.chars()
48 .map(|c| if is_shape_forging(c) { ' ' } else { c })
49 .collect()
50}
51
52fn is_shape_forging(c: char) -> bool {
54 c.is_ascii_control()
55 || matches!(c,
56 '\u{0085}' | '\u{200b}'..='\u{200f}' | '\u{2028}' | '\u{2029}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' | '\u{feff}' )
63}
64
65#[must_use]
67pub fn thousands(n: usize) -> String {
68 let digits = n.to_string();
69 let mut out = String::with_capacity(digits.len() + digits.len() / 3);
70 for (i, c) in digits.chars().enumerate() {
71 if i > 0 && (digits.len() - i).is_multiple_of(3) {
72 out.push(',');
73 }
74 out.push(c);
75 }
76 out
77}
78
79#[must_use]
81pub fn plural(n: usize, word: &str) -> String {
82 if n == 1 {
83 format!("{n} {word}")
84 } else {
85 format!("{} {word}s", thousands(n))
86 }
87}
88
89#[must_use]
92pub fn age(secs: i64) -> String {
93 let s = secs.max(0);
94 if s < 60 {
95 format!("{s}s")
96 } else if s < 3_600 {
97 format!("{}m", s / 60)
98 } else if s < 86_400 {
99 format!("{}h", s / 3_600)
100 } else {
101 format!("{}d", s / 86_400)
102 }
103}
104
105const DAY: i64 = 86_400;
107
108fn civil_from_days(days: i64) -> (i64, u32, u32) {
116 let z = days + 719_468;
119 let era = z.div_euclid(146_097);
120 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;
125 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
126 let year = yoe + era * 400 + i64::from(month <= 2);
127 (year, month, day)
128}
129
130#[must_use]
132pub fn ymd(ts: i64) -> String {
133 let (y, m, d) = civil_from_days(ts.div_euclid(DAY));
134 format!("{y:04}-{m:02}-{d:02}")
135}
136
137#[must_use]
140pub fn quarter_index(ts: i64) -> i64 {
141 let (y, m, _) = civil_from_days(ts.div_euclid(DAY));
142 y * 4 + i64::from((m - 1) / 3)
143}
144
145#[must_use]
147pub fn quarter_label(index: i64) -> String {
148 format!("{}Q{}", index.div_euclid(4), index.rem_euclid(4) + 1)
149}
150
151#[must_use]
153pub fn basename(key: &str) -> &str {
154 key.rsplit_once('/').map_or(key, |(_, base)| base)
155}
156
157#[must_use]
160pub fn dir_components(key: &str) -> Vec<&str> {
161 let mut parts: Vec<&str> = key.split('/').collect();
162 parts.pop();
163 parts
164}
165
166#[must_use]
169pub fn common_dir_prefix(keys: &[String]) -> String {
170 let mut iter = keys.iter().map(|k| dir_components(k));
171 let Some(mut prefix) = iter.next() else {
172 return String::new();
173 };
174 for comps in iter {
175 let shared = prefix
176 .iter()
177 .zip(comps.iter())
178 .take_while(|(a, b)| a == b)
179 .count();
180 prefix.truncate(shared);
181 if prefix.is_empty() {
182 break;
183 }
184 }
185 prefix.join("/")
186}
187
188#[must_use]
195pub fn top_tokens(keys: &[String], prefix: &str, n: usize, dirs_only: bool) -> Vec<String> {
196 let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
197 for key in keys {
198 let rest = match prefix.is_empty() {
199 true => key.as_str(),
200 false => key
201 .strip_prefix(prefix)
202 .unwrap_or(key)
203 .trim_start_matches('/'),
204 };
205 let mut seen: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect();
206 if dirs_only {
207 seen.pop();
208 }
209 seen.sort_unstable();
210 seen.dedup();
211 for token in seen {
212 *counts.entry(token).or_default() += 1;
213 }
214 }
215 let mut ranked: Vec<(&str, usize)> = counts.into_iter().collect();
216 ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
217 ranked
218 .into_iter()
219 .take(n)
220 .map(|(t, _)| t.to_string())
221 .collect()
222}
223
224pub const MIXED: &str = "<mixed>";
226
227#[must_use]
238pub fn cluster_name(keys: &[String]) -> String {
239 let prefix = common_dir_prefix(keys);
240 let head = if prefix.is_empty() {
241 MIXED.to_string()
242 } else {
243 prefix.clone()
244 };
245 let mut tokens = top_tokens(keys, &prefix, 2, true);
246 if tokens.is_empty() && prefix.is_empty() {
247 tokens = top_tokens(keys, &prefix, 2, false);
249 }
250 if tokens.is_empty() {
251 head
252 } else {
253 format!("{head} {}", tokens.join(", "))
254 }
255}
256
257#[must_use]
263pub fn short_names(keys: &[String]) -> Vec<String> {
264 let mut seen: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
265 for key in keys {
266 *seen.entry(basename(key)).or_default() += 1;
267 }
268 keys.iter()
269 .map(|k| match seen.get(basename(k)) {
270 Some(1) => sanitize(basename(k)),
271 _ => sanitize(k),
272 })
273 .collect()
274}
275
276#[must_use]
278pub fn cap_lines(text: &str, max: usize) -> String {
279 let mut out = String::with_capacity(text.len());
280 for line in text.lines().take(max) {
281 out.push_str(line);
282 out.push('\n');
283 }
284 out
285}
286
287#[must_use]
294pub fn cap_bytes(text: &str, max: usize) -> String {
295 let mut out = String::with_capacity(text.len().min(max));
296 for line in text.lines() {
297 if out.len() + line.len() + 1 > max {
298 break;
299 }
300 out.push_str(line);
301 out.push('\n');
302 }
303 out
304}
305
306pub const EMPTY_MAP: &str =
309 "mushroomdb map — empty store; run: mushroomdb ingest-git <db> <repo>\n";
310
311#[must_use]
318pub fn render_map(m: &RepoMap) -> String {
319 if m.files == 0 {
320 return EMPTY_MAP.to_string();
321 }
322 let mut out = String::new();
323
324 let sync = match &m.last_sync {
326 None => "not synced".to_string(),
327 Some(s) => {
328 let sha = sanitize(&s.sha);
329 let short: String = sha.chars().take(7).collect();
330 match s.age_secs {
331 Some(secs) => format!("synced {} ago at {short}", age(secs)),
332 None => format!("synced at {short}"),
333 }
334 }
335 };
336 let _ = writeln!(
337 out,
338 "mushroomdb map — {}, {}, {}, {} · {sync}{}",
339 plural(m.files, "file"),
340 plural(m.symbols, "symbol"),
341 plural(m.commits, "commit"),
342 plural(m.authors, "author"),
343 if m.truncated { " (truncated)" } else { "" }
344 );
345
346 if !m.communities.is_empty() {
347 out.push_str("clusters (co-change + imports)\n");
348 for (i, c) in m.communities.iter().enumerate() {
349 let samples = short_names(&c.samples);
350 let _ = writeln!(
351 out,
352 " {}. {} ({}, cohesion {:.2}){}{}",
353 i + 1,
354 sanitize(&c.name),
355 plural(c.size, "file"),
356 c.cohesion,
357 if samples.is_empty() { "" } else { " " },
358 samples.join(", ")
359 );
360 }
361 }
362
363 if !m.key_files.is_empty() {
364 out.push_str("key files (most depended-on)\n");
365 let items: Vec<String> = m
369 .key_files
370 .iter()
371 .map(|(k, s)| format!("{} {s:.2}", sanitize(k)))
372 .collect();
373 let _ = writeln!(out, " {}", items.join(SEP));
374 }
375
376 if !m.owners.is_empty() {
377 out.push_str("owners\n");
378 let items: Vec<String> = m
379 .owners
380 .iter()
381 .enumerate()
382 .map(|(i, (name, n))| match i {
383 0 => format!("{} {}", sanitize(name), plural(*n, "file")),
385 _ => format!("{} {n}", sanitize(name)),
386 })
387 .collect();
388 let _ = writeln!(out, " {}", items.join(SEP));
389 }
390
391 if !m.hot_files.is_empty() {
392 let _ = writeln!(out, "hot (last {} days)", m.hot_days);
393 let items: Vec<String> = m
394 .hot_files
395 .iter()
396 .map(|(k, n)| format!("{} {n}", sanitize(k)))
397 .collect();
398 let _ = writeln!(out, " {}", items.join(SEP));
399 }
400
401 if m.stale_concepts > 0 {
402 let (noun, verb) = if m.stale_concepts == 1 {
403 ("concept", "needs")
404 } else {
405 ("concepts", "need")
406 };
407 let _ = writeln!(
408 out,
409 "notes: {} {noun} {verb} re-learning (source changed)",
410 m.stale_concepts
411 );
412 }
413
414 if !m.questions.is_empty() {
415 let asks: Vec<String> = m.questions.iter().map(|q| sanitize(q)).collect();
416 let _ = writeln!(out, "ask me: {}", asks.join(SEP));
417 }
418
419 cap_lines(&out, MAX_MAP_LINES)
420}
421
422pub const MAX_BRIEF_BYTES: usize = 4_000;
430
431pub const EMPTY_BRIEF: &str =
440 "mushroomdb brief — empty store; run: mushroomdb ingest-git <db> <repo>\n";
441
442const BRIEF_FILES_HEADING: &str = "key files (by centrality):\n";
444const BRIEF_SYMBOLS_HEADING: &str = "key symbols (most called):\n";
445const BRIEF_LABELS_HEADING: &str = "labels:\n";
447const BRIEF_EDGE_TYPES_HEADING: &str = "edge types:\n";
448const BRIEF_RECIPES_HEADING: &str = "ask in one call:\n";
452
453#[must_use]
477pub fn render_brief(b: &BriefReport, reach: &str) -> String {
478 let nodes = b.schema.as_ref().map_or(b.files + b.symbols, |s| s.nodes);
479 if nodes == 0 && b.edges == 0 {
480 return EMPTY_BRIEF.to_string();
481 }
482 let tail = format!("reach the graph: {}\n", sanitize(reach));
483 let budget = MAX_BRIEF_BYTES.saturating_sub(tail.len());
484 if let Some(schema) = &b.schema {
485 return render_memory_brief(b, schema, budget) + &tail;
486 }
487
488 let mut head: Vec<String> = Vec::new();
492 if !b.repo.is_empty() {
493 head.push(sanitize(&b.repo));
494 }
495 head.push(plural(b.files, "file"));
496 head.push(plural(b.symbols, "symbol"));
497 head.push(plural(b.edges, "edge"));
498 if let Some(sha) = &b.last_sync {
499 head.push(format!("synced {}", sanitize(sha)));
500 }
501 let header = format!("{UNTRUSTED_FRAMING}mushroomdb brief — {}\n", head.join(SEP));
502
503 let mut files: Vec<String> = b
504 .key_files
505 .iter()
506 .map(|(path, role)| format!(" {}{}\n", sanitize(path), suffix(role)))
507 .collect();
508 let mut symbols: Vec<String> = b
509 .key_symbols
510 .iter()
511 .map(|(key, sig)| format!(" {}{}\n", sanitize(key), suffix(sig)))
512 .collect();
513
514 let mut dropped = 0;
519 loop {
520 let body = brief_body(&header, &files, &symbols, dropped);
521 if body.len() <= budget || (symbols.is_empty() && files.is_empty()) {
522 return body + &tail;
523 }
524 if symbols.pop().is_none() {
525 files.pop();
526 }
527 dropped += 1;
528 }
529}
530
531fn brief_body(header: &str, files: &[String], symbols: &[String], dropped: usize) -> String {
533 let mut out = String::from(header);
534 if !files.is_empty() {
535 out.push_str(BRIEF_FILES_HEADING);
536 out.extend(files.iter().map(String::as_str));
537 }
538 if !symbols.is_empty() {
539 out.push_str(BRIEF_SYMBOLS_HEADING);
540 out.extend(symbols.iter().map(String::as_str));
541 }
542 if dropped > 0 {
543 let _ = writeln!(out, " … and {dropped} more");
544 }
545 out
546}
547
548fn render_memory_brief(b: &BriefReport, s: &SchemaBrief, budget: usize) -> String {
586 let at_least = |n: usize| {
591 if s.partial {
592 format!("≥ {}", thousands(n))
593 } else {
594 thousands(n)
595 }
596 };
597 let header = format!(
598 "{UNTRUSTED_FRAMING}mushroomdb brief — {}{}\n",
599 [
600 if s.partial {
601 format!("≥ {}", plural(s.nodes, "node"))
602 } else {
603 plural(s.nodes, "node")
604 },
605 plural(b.edges, "edge"),
606 plural(s.labels.len(), "label"),
607 ]
608 .join(SEP),
609 if s.partial { " (partial)" } else { "" }
610 );
611
612 let label_lines = |cap: usize| -> Vec<String> {
613 s.labels
614 .iter()
615 .map(|l| {
616 let mut line = format!(
617 " {} ({})",
618 cap_name(&sanitize(&l.label), cap),
619 at_least(l.nodes)
620 );
621 if !l.props.is_empty() {
622 let props: Vec<String> = l.props.iter().map(|p| cap_name(p, cap)).collect();
623 let _ = write!(line, " — {}", props.join(", "));
624 }
625 if l.hidden_props > 0 {
626 let _ = write!(line, ", … +{}", l.hidden_props);
627 }
628 line.push('\n');
629 line
630 })
631 .collect()
632 };
633 let edge_type_lines = |cap: usize| -> Vec<String> {
634 s.edge_types
635 .iter()
636 .map(|t| {
637 let mut line = format!(
638 " {} ({})",
639 cap_name(&sanitize(&t.edge_type), cap),
640 at_least(t.edges)
641 );
642 if let Some(rule) = &t.rule {
643 let _ = write!(line, " — rule {}", cap_name(&sanitize(rule), cap));
644 if t.hidden_rules > 0 {
645 let _ = write!(line, " +{}", t.hidden_rules);
646 }
647 }
648 let _ = writeln!(line, " — {} → {}", ends(&t.src, cap), ends(&t.dst, cap));
649 line
650 })
651 .collect()
652 };
653
654 let mut prefix = match s.commits {
659 Some(n) => format!("history: {n} commits\n"),
660 None => "history: unknown\n".to_string(),
661 };
662 if !s.roles.is_empty() {
663 let roles: Vec<String> = s
664 .roles
665 .iter()
666 .map(|(name, labels)| {
667 if labels.is_empty() {
668 sanitize(name)
669 } else {
670 format!("{} ({})", sanitize(name), labels.join(", "))
671 }
672 })
673 .collect();
674 let _ = writeln!(prefix, "roles: {}", roles.join(SEP));
675 }
676 let recipes: Vec<String> = s
677 .recipes
678 .iter()
679 .map(|r| format!(" {}: {}\n", sanitize(&r.question), sanitize(&r.call)))
680 .collect();
681 let with_recipes = |kept: usize| -> String {
682 let mut fixed = prefix.clone();
683 if kept > 0 {
684 fixed.push_str(BRIEF_RECIPES_HEADING);
685 fixed.extend(recipes[..kept].iter().map(String::as_str));
686 }
687 fixed
688 };
689 let fixed = with_recipes(recipes.len());
690
691 let whole = memory_body(
695 &header,
696 &label_lines(usize::MAX),
697 &edge_type_lines(usize::MAX),
698 &fixed,
699 0,
700 );
701 if whole.len() <= budget {
702 return whole;
703 }
704
705 let mut labels = label_lines(BRIEF_NAME_CAP);
713 let mut edge_types = edge_type_lines(BRIEF_NAME_CAP);
714 let mut dropped = 0;
715 loop {
716 let body = memory_body(&header, &labels, &edge_types, &fixed, dropped);
717 if body.len() <= budget {
718 return body;
719 }
720 if edge_types.pop().is_none() && labels.pop().is_none() {
721 break;
722 }
723 dropped += 1;
724 }
725
726 let truncated = format!(
730 "(brief truncated at {} bytes)\n",
731 thousands(MAX_BRIEF_BYTES)
732 );
733 let mut kept = recipes.len();
734 loop {
735 let body = memory_body(&header, &[], &[], &with_recipes(kept), dropped) + &truncated;
736 if body.len() <= budget {
737 return body;
738 }
739 if kept == 0 {
740 return cap_bytes(&body, budget);
744 }
745 kept -= 1;
746 }
747}
748
749const BRIEF_NAME_CAP: usize = 60;
757
758fn cap_name(name: &str, cap: usize) -> String {
765 if cap == 0 || name.chars().count() <= cap {
766 return name.to_string();
767 }
768 let end = name
769 .char_indices()
770 .nth(cap - 1)
771 .map_or(name.len(), |(i, _)| i);
772 format!("{}…", &name[..end])
773}
774
775fn memory_body(
777 header: &str,
778 labels: &[String],
779 edge_types: &[String],
780 fixed: &str,
781 dropped: usize,
782) -> String {
783 let mut out = String::from(header);
784 if !labels.is_empty() {
785 out.push_str(BRIEF_LABELS_HEADING);
786 out.extend(labels.iter().map(String::as_str));
787 }
788 if !edge_types.is_empty() {
789 out.push_str(BRIEF_EDGE_TYPES_HEADING);
790 out.extend(edge_types.iter().map(String::as_str));
791 }
792 if dropped > 0 {
793 let _ = writeln!(out, " … and {dropped} more");
794 }
795 out.push_str(fixed);
796 out
797}
798
799fn ends(labels: &[String], cap: usize) -> String {
804 if labels.is_empty() {
805 "?".to_string()
806 } else {
807 labels
808 .iter()
809 .map(|l| cap_name(&sanitize(l), cap))
810 .collect::<Vec<_>>()
811 .join("|")
812 }
813}
814
815fn suffix(detail: &str) -> String {
817 if detail.is_empty() {
818 String::new()
819 } else {
820 format!(" — {}", sanitize(detail))
821 }
822}
823
824const MAX_SOURCE_PRINTED: usize = 40;
831const MAX_CANDIDATES: usize = 20;
835const MAX_IMPACT_FILES: usize = 5;
837const MAX_IMPACT_UNKNOWN: usize = 3;
847const MAX_WHY_LINKS: usize = 5;
849
850fn section(out: &mut String, name: &str, items: &[String]) {
852 if !items.is_empty() {
853 let _ = writeln!(out, "{name} {}", items.join(SEP));
854 }
855}
856
857fn commit_line(sha: &str, ts: i64, subject: &str) -> String {
859 let short: String = sanitize(sha).chars().take(7).collect();
860 format!("{short} {} {}", ymd(ts), sanitize(subject))
861}
862
863#[must_use]
872pub fn render_context(c: &ContextReport) -> String {
873 let mut out = String::new();
874 match &c.target {
875 Target::Unknown { target } if c.candidates.is_empty() => {
876 let _ = writeln!(out, "mushroomdb context — unknown: {}", sanitize(target));
877 return out;
878 }
879 Target::Unknown { target } => {
880 let _ = writeln!(
881 out,
882 "mushroomdb context — {} is ambiguous: {}",
883 sanitize(target),
884 plural(c.candidates.len(), "symbol")
885 );
886 for key in c.candidates.iter().take(MAX_CANDIDATES) {
887 let _ = writeln!(out, " {}", sanitize(key));
888 }
889 if c.candidates.len() > MAX_CANDIDATES {
890 let _ = writeln!(
891 out,
892 " … {} not shown",
893 plural(c.candidates.len() - MAX_CANDIDATES, "symbol")
894 );
895 }
896 return cap_lines(&out, MAX_CONTEXT_LINES);
897 }
898 Target::File { path } => {
899 let _ = writeln!(out, "mushroomdb context — file {}", sanitize(path));
900 }
901 Target::Symbol { key } => {
902 let _ = writeln!(
903 out,
904 "mushroomdb context — symbol {} in {}",
905 sanitize(key),
906 sanitize(&c.file)
907 );
908 }
909 }
910
911 if let Some((first, last)) = c.lines.filter(|_| c.source.is_none() && !c.file.is_empty()) {
915 let _ = writeln!(out, " at {}:{first}-{last}", sanitize(&c.file));
916 }
917 if let Some(sig) = &c.signature {
918 let _ = writeln!(out, "signature {}", sanitize(sig));
919 }
920 if let Some(doc) = &c.doc {
921 let _ = writeln!(out, "doc {}", sanitize(doc));
922 }
923 let mut about: Vec<String> = Vec::new();
924 if let Some((first, last)) = c.lines.filter(|_| c.source.is_some()) {
925 about.push(format!("lines {first}-{last}"));
926 }
927 if let Some(owner) = &c.owner {
928 about.push(format!("owner {}", sanitize(owner)));
929 }
930 section(&mut out, "where", &about);
931
932 if let Some(source) = &c.source {
933 let first = c.lines.map_or(1, |(first, _)| first);
934 let total = source.lines().count();
935 let _ = writeln!(out, "source");
936 for (i, line) in source.lines().take(MAX_SOURCE_PRINTED).enumerate() {
937 let n = first as usize + i;
938 let _ = writeln!(out, " {n:>5} | {}", sanitize(line));
939 }
940 if total > MAX_SOURCE_PRINTED {
941 let _ = writeln!(
942 out,
943 " … {} more",
944 plural(total - MAX_SOURCE_PRINTED, "line")
945 );
946 }
947 }
948
949 let mut callers: Vec<String> = c
952 .callers
953 .iter()
954 .map(|s| {
955 let lines: Vec<String> = s
956 .lines
957 .iter()
958 .filter(|n| **n > 0)
959 .map(u32::to_string)
960 .collect();
961 let more = s.sites.saturating_sub(s.lines.len());
962 let mut item = match lines.is_empty() {
963 true => sanitize(&s.file),
964 false => format!("{}: {}", sanitize(&s.file), lines.join(", ")),
965 };
966 if more > 0 {
967 let _ = write!(item, " …(+{more})");
968 }
969 item
970 })
971 .collect();
972 if c.callers_not_shown > 0 {
973 callers.push(format!(
974 "… {} not shown",
975 plural(c.callers_not_shown, "file")
976 ));
977 }
978 section(&mut out, "callers", &callers);
979 let callees: Vec<String> = c
980 .callees
981 .iter()
982 .map(|(key, line)| match line {
983 0 => sanitize(key),
984 n => format!("{} line {n}", sanitize(key)),
985 })
986 .collect();
987 section(&mut out, "callees", &callees);
988 section(
989 &mut out,
990 "imports",
991 &c.imports.iter().map(|k| sanitize(k)).collect::<Vec<_>>(),
992 );
993 section(
994 &mut out,
995 "importers",
996 &c.importers.iter().map(|k| sanitize(k)).collect::<Vec<_>>(),
997 );
998 section(
999 &mut out,
1000 "co-change",
1001 &c.partners
1002 .iter()
1003 .map(|(k, s)| format!("{} {s:.2}", sanitize(k)))
1004 .collect::<Vec<_>>(),
1005 );
1006 section(
1007 &mut out,
1008 "commits",
1009 &c.recent_commits
1010 .iter()
1011 .map(|(sha, ts, subject)| commit_line(sha, *ts, subject))
1012 .collect::<Vec<_>>(),
1013 );
1014 for (key, text) in &c.notes {
1015 let _ = writeln!(out, "note {} {}", sanitize(key), sanitize(text));
1016 }
1017 for (key, name) in &c.concepts {
1018 let _ = writeln!(out, "concept {} {}", sanitize(key), sanitize(name));
1019 }
1020 cap_lines(&out, MAX_CONTEXT_LINES)
1021}
1022
1023fn partner_item(p: &Partner, with_score: bool) -> String {
1026 let mut item = sanitize(&p.path);
1027 match p.shared_commits {
1031 Some(n) => {
1032 let _ = write!(item, " ({})", plural(n, "shared commit"));
1033 }
1034 None if with_score => {
1035 let _ = write!(item, " {:.2}", p.score);
1036 }
1037 None => {}
1038 }
1039 if p.modified {
1040 item.push_str(" modified");
1041 }
1042 item
1043}
1044
1045#[must_use]
1047pub fn render_impact(r: &ImpactReport) -> String {
1048 let mut out = String::new();
1049 let _ = writeln!(
1050 out,
1051 "mushroomdb impact — {}",
1052 plural(r.files.len(), "changed file")
1053 );
1054 for f in r.files.iter().take(MAX_IMPACT_FILES) {
1055 render_file_impact(&mut out, f);
1056 }
1057 if r.files.len() > MAX_IMPACT_FILES {
1058 let _ = writeln!(
1059 out,
1060 "… {} not shown",
1061 plural(r.files.len() - MAX_IMPACT_FILES, "file")
1062 );
1063 }
1064 for path in r.unknown.iter().take(MAX_IMPACT_UNKNOWN) {
1065 let _ = writeln!(out, "unknown: {}", sanitize(path));
1066 }
1067 if r.unknown.len() > MAX_IMPACT_UNKNOWN {
1068 let _ = writeln!(
1069 out,
1070 "…and {} more unknown",
1071 r.unknown.len() - MAX_IMPACT_UNKNOWN
1072 );
1073 }
1074 cap_lines(&out, MAX_TOOL_LINES)
1075}
1076
1077fn render_file_impact(out: &mut String, f: &FileImpact) {
1078 match &f.owner {
1079 Some(owner) => {
1080 let _ = writeln!(out, "{} ({})", sanitize(&f.path), sanitize(owner));
1081 }
1082 None => {
1083 let _ = writeln!(out, "{}", sanitize(&f.path));
1084 }
1085 }
1086 section(
1087 out,
1088 " partners ",
1089 &f.partners
1090 .iter()
1091 .map(|p| partner_item(p, true))
1092 .collect::<Vec<_>>(),
1093 );
1094 section(
1095 out,
1096 " importers",
1097 &f.importers
1098 .iter()
1099 .map(|p| partner_item(p, false))
1100 .collect::<Vec<_>>(),
1101 );
1102 section(
1103 out,
1104 " used by ",
1105 &f.symbols_used_elsewhere
1106 .iter()
1107 .map(|(key, n)| format!("{} {}", sanitize(key), plural(*n, "caller")))
1108 .collect::<Vec<_>>(),
1109 );
1110}
1111
1112pub const DEFAULT_EXPLORE_BYTES: usize = 4_800;
1119
1120#[must_use]
1136pub fn render_explore(r: &ExploreReport, budget_bytes: usize) -> String {
1137 let mut out = render_context(&r.context);
1138
1139 if let Some(imp) = &r.impact {
1140 let rendered = render_impact(imp);
1143 let mut body = rendered.lines().skip(1).peekable();
1144 if body.peek().is_some() {
1145 out.push_str("impact:\n");
1146 for line in body {
1147 let _ = writeln!(out, " {line}");
1148 }
1149 }
1150 }
1151
1152 if let Some((name, key, share)) = r.owners.as_ref().and_then(|o| o.top.as_ref()) {
1153 let _ = writeln!(
1154 out,
1155 "owner: {} ({}) {share:.2} of the file's commits",
1156 sanitize(name),
1157 sanitize(key)
1158 );
1159 }
1160
1161 let capped = cap_bytes(&out, budget_bytes);
1162 if !capped.is_empty() || out.is_empty() || budget_bytes == 0 {
1163 return capped;
1164 }
1165 let head = out.lines().next().unwrap_or_default();
1170 let mut end = budget_bytes - 1;
1171 while end > 0 && !head.is_char_boundary(end) {
1172 end -= 1;
1173 }
1174 format!("{}\n", &head[..end])
1175}
1176
1177#[must_use]
1183pub fn render_owners(o: &OwnersReport) -> String {
1184 let mut out = String::new();
1185 let _ = writeln!(out, "mushroomdb owners — {}", sanitize(&o.path));
1186 if let Some((name, key, share)) = &o.top {
1187 let _ = writeln!(
1188 out,
1189 "top {} ({}) {share:.2} of the file's commits",
1190 sanitize(name),
1191 sanitize(key)
1192 );
1193 }
1194 section(
1195 &mut out,
1196 "knows",
1197 &o.knows
1198 .iter()
1199 .map(|(name, score)| format!("{} {score:.2}", sanitize(name)))
1200 .collect::<Vec<_>>(),
1201 );
1202 if let Some((sha, ts, subject)) = &o.last_touch {
1203 let _ = writeln!(out, "last touch {}", commit_line(sha, *ts, subject));
1204 }
1205 section(
1206 &mut out,
1207 "by quarter",
1208 &o.by_quarter
1209 .iter()
1210 .map(|(q, name, n)| format!("{} {} {n}", sanitize(q), sanitize(name)))
1211 .collect::<Vec<_>>(),
1212 );
1213 cap_lines(&out, MAX_TOOL_LINES)
1214}
1215
1216#[must_use]
1218pub fn render_why(w: &WhyReport) -> String {
1219 let mut out = String::new();
1220 let _ = writeln!(
1221 out,
1222 "mushroomdb why — {} ↔ {}",
1223 sanitize(&w.a),
1224 sanitize(&w.b)
1225 );
1226 for key in &w.unknown {
1227 let _ = writeln!(out, "unknown: {}", sanitize(key));
1228 }
1229 if !w.unknown.is_empty() {
1230 return cap_lines(&out, MAX_TOOL_LINES);
1231 }
1232 let links = pair_up(&w.links);
1233 for (link, both_ways) in links.iter().take(MAX_WHY_LINKS) {
1234 render_link(&mut out, link, *both_ways);
1235 }
1236 if links.len() > MAX_WHY_LINKS {
1237 let _ = writeln!(
1238 out,
1239 "… {} not shown",
1240 plural(links.len() - MAX_WHY_LINKS, "link")
1241 );
1242 }
1243 if let Some(shared) = &w.shared {
1244 let _ = writeln!(
1245 out,
1246 "co-change {}, below the co_changed rule's similarity floor so no edge was written",
1247 plural(shared.count, "shared commit")
1248 );
1249 for line in &shared.evidence {
1250 let _ = writeln!(out, " {}", sanitize(line));
1251 }
1252 }
1253 if !w.path.is_empty() {
1254 let mut walk = sanitize(&w.a);
1255 for (edge_type, node) in &w.path {
1256 let _ = write!(walk, " -[{}]-> {}", sanitize(edge_type), sanitize(node));
1257 }
1258 let _ = writeln!(out, "path {walk}");
1259 }
1260 if w.links.is_empty() && w.path.is_empty() && w.shared.is_none() {
1261 let _ = writeln!(out, "no link");
1262 }
1263 cap_lines(&out, MAX_TOOL_LINES)
1264}
1265
1266fn pair_up(links: &[WhyLink]) -> Vec<(&WhyLink, bool)> {
1279 let mut out: Vec<(&WhyLink, bool)> = Vec::new();
1280 let mut folded: Vec<bool> = vec![false; links.len()];
1281 for (i, link) in links.iter().enumerate() {
1282 if folded[i] {
1283 continue;
1284 }
1285 let mut both_ways = false;
1286 for (j, other) in links.iter().enumerate().skip(i + 1) {
1287 if !folded[j]
1288 && other.rule == link.rule
1289 && other.edge_type == link.edge_type
1290 && other.direction != link.direction
1291 && other.score == link.score
1292 && other.evidence == link.evidence
1293 {
1294 folded[j] = true;
1295 both_ways = true;
1296 break;
1297 }
1298 }
1299 out.push((link, both_ways));
1300 }
1301 out
1302}
1303
1304fn render_link(out: &mut String, link: &WhyLink, both_ways: bool) {
1305 let mut head = format!(
1306 "{} {} {}",
1307 sanitize(&link.edge_type),
1308 if both_ways {
1309 "a↔b".to_string()
1310 } else {
1311 sanitize(&link.direction)
1312 },
1313 sanitize(&link.rule)
1314 );
1315 if let Some(score) = link.score {
1316 let _ = write!(head, " {score:.2}");
1317 }
1318 if let Some(via) = &link.via {
1319 let _ = write!(head, " via {}", sanitize(via));
1320 }
1321 let _ = writeln!(out, "{head}");
1322 for line in &link.evidence {
1323 let _ = writeln!(out, " {}", sanitize(line));
1324 }
1325}
1326
1327#[cfg(test)]
1328mod tests {
1329 use super::*;
1330
1331 #[test]
1332 fn cap_bytes_keeps_whole_lines_and_never_half_of_one() {
1333 let text = "aaaa\nbbbb\ncccc\n"; assert_eq!(cap_bytes(text, 15), text, "the whole text fits exactly");
1335 assert_eq!(
1336 cap_bytes(text, 14),
1337 "aaaa\nbbbb\n",
1338 "the last line is whole"
1339 );
1340 assert_eq!(cap_bytes(text, 10), "aaaa\nbbbb\n");
1341 assert_eq!(cap_bytes(text, 9), "aaaa\n");
1342 assert_eq!(
1343 cap_bytes(text, 4),
1344 "",
1345 "a first line too long yields nothing, never a fragment"
1346 );
1347 assert_eq!(cap_bytes(text, 0), "");
1348 assert_eq!(cap_bytes("abc", 4), "abc\n");
1350 assert_eq!(cap_bytes("abc", 3), "");
1351 }
1352
1353 #[test]
1354 fn a_timestamp_reads_as_a_utc_date_and_a_quarter() {
1355 for (ts, date, quarter) in [
1358 (0_i64, "1970-01-01", "1970Q1"),
1359 (1_582_934_400, "2020-02-29", "2020Q1"),
1360 (951_782_400, "2000-02-29", "2000Q1"),
1361 (1_600_000_000, "2020-09-13", "2020Q3"),
1362 (1_609_459_199, "2020-12-31", "2020Q4"),
1363 (1_609_459_200, "2021-01-01", "2021Q1"),
1364 (-1, "1969-12-31", "1969Q4"),
1365 ] {
1366 assert_eq!(ymd(ts), date, "{ts}");
1367 assert_eq!(quarter_label(quarter_index(ts)), quarter, "{ts}");
1368 }
1369 }
1370
1371 #[test]
1372 fn quarter_indices_are_a_count_a_window_can_be_measured_in() {
1373 let q3 = quarter_index(1_600_000_000); assert_eq!(quarter_label(q3 - 3), "2019Q4");
1375 assert_eq!(quarter_label(q3 + 1), "2020Q4");
1376 assert_eq!(quarter_label(q3 + 2), "2021Q1");
1377 }
1378
1379 #[test]
1380 fn sanitize_replaces_every_control_character_one_for_one() {
1381 let forged = "Ada\nmushroomdb map\t— 9 files\u{7f}\u{1b}[31m";
1382 let clean = sanitize(forged);
1383 assert_eq!(clean.len(), forged.len(), "one byte in, one byte out");
1384 assert!(!clean.contains('\n') && !clean.contains('\t') && !clean.contains('\u{1b}'));
1385 assert_eq!(clean, "Ada mushroomdb map — 9 files [31m");
1386 }
1387
1388 #[test]
1390 fn sanitize_neutralizes_bidi_zero_width_and_separators() {
1391 for (cp, name) in [
1392 ('\u{202e}', "U+202E RIGHT-TO-LEFT OVERRIDE"),
1393 ('\u{200b}', "U+200B ZERO WIDTH SPACE"),
1394 ('\u{2028}', "U+2028 LINE SEPARATOR"),
1395 ('\u{2029}', "U+2029 PARAGRAPH SEPARATOR"),
1396 ] {
1397 let forged = format!("safe{cp}tail");
1398 let clean = sanitize(&forged);
1399 assert_eq!(clean, "safe tail", "{name} must render as one space");
1400 assert_eq!(
1401 clean.chars().count(),
1402 forged.chars().count(),
1403 "{name}: one char in, one char out"
1404 );
1405 }
1406 }
1407
1408 #[test]
1413 fn sanitize_covers_the_whole_class_not_just_the_named_four() {
1414 for cp in [
1415 '\u{202a}', '\u{202b}', '\u{202c}', '\u{202d}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', '\u{200c}', '\u{200d}', '\u{200e}', '\u{200f}', '\u{feff}', '\u{0085}', ] {
1421 let clean = sanitize(&format!("a{cp}b"));
1422 assert_eq!(
1423 clean, "a b",
1424 "U+{:04X} is the same class as the four §5.12 names",
1425 cp as u32
1426 );
1427 }
1428 }
1429
1430 #[test]
1433 fn sanitize_never_grows_a_string() {
1434 let forged = "subject\u{202e}\u{200b}\u{2028}\u{2029}tail";
1435 let clean = sanitize(forged);
1436 assert!(
1437 clean.len() <= forged.len(),
1438 "bytes must not grow: {} -> {}",
1439 forged.len(),
1440 clean.len()
1441 );
1442 assert_eq!(
1443 clean.chars().count(),
1444 forged.chars().count(),
1445 "characters are one for one"
1446 );
1447 }
1448
1449 #[test]
1450 fn thousands_groups_from_the_right() {
1451 for (n, want) in [
1452 (0, "0"),
1453 (7, "7"),
1454 (999, "999"),
1455 (1_000, "1,000"),
1456 (1_204, "1,204"),
1457 (999_999, "999,999"),
1458 (1_830_412, "1,830,412"),
1459 ] {
1460 assert_eq!(thousands(n), want, "{n}");
1461 }
1462 }
1463
1464 #[test]
1465 fn plural_says_one_file_and_two_files() {
1466 assert_eq!(plural(1, "file"), "1 file");
1467 assert_eq!(plural(0, "file"), "0 files");
1468 assert_eq!(plural(1_204, "commit"), "1,204 commits");
1469 }
1470
1471 #[test]
1472 fn age_picks_one_coarse_unit() {
1473 for (secs, want) in [
1474 (-5, "0s"),
1475 (0, "0s"),
1476 (59, "59s"),
1477 (60, "1m"),
1478 (720, "12m"),
1479 (3_600, "1h"),
1480 (86_399, "23h"),
1481 (86_400, "1d"),
1482 (20 * 86_400, "20d"),
1483 ] {
1484 assert_eq!(age(secs), want, "{secs}");
1485 }
1486 }
1487
1488 #[test]
1489 fn paths_split_into_a_base_and_its_directories() {
1490 assert_eq!(basename("src/core/db.rs"), "db.rs");
1491 assert_eq!(basename("README.md"), "README.md");
1492 assert_eq!(dir_components("src/core/db.rs"), vec!["src", "core"]);
1493 assert!(dir_components("README.md").is_empty());
1494 }
1495
1496 #[test]
1497 fn a_cluster_is_named_by_the_directory_its_files_share() {
1498 let same = vec![
1500 "crates/core-api/src/db.rs".to_string(),
1501 "crates/core-api/src/algo.rs".to_string(),
1502 ];
1503 assert_eq!(cluster_name(&same), "crates/core-api/src");
1504 let partial = vec![
1507 "crates/core-api/src/db.rs".to_string(),
1508 "crates/core-api/tests/algo.rs".to_string(),
1509 ];
1510 assert_eq!(cluster_name(&partial), "crates/core-api src, tests");
1511 }
1512
1513 #[test]
1514 fn files_sharing_no_directory_are_named_by_their_commonest_segments() {
1515 let mixed = vec![
1516 "docs/site/algorithms.md".to_string(),
1517 "docs/site/install.md".to_string(),
1518 "site/index.html".to_string(),
1519 "README.md".to_string(),
1520 ];
1521 assert_eq!(cluster_name(&mixed), "<mixed> site, docs");
1524 assert_eq!(cluster_name(&["a.rs".to_string()]), "<mixed> a.rs");
1525 assert_eq!(cluster_name(&[]), "<mixed>");
1526 }
1527
1528 #[test]
1529 fn a_segment_counts_once_per_key_however_often_it_repeats() {
1530 let keys = vec!["a/a/a/a.rs".to_string(), "b/x.rs".to_string()];
1531 assert_eq!(top_tokens(&keys, "", 1, true), vec!["a".to_string()]);
1532 assert_eq!(top_tokens(&keys, "", 1, false), vec!["a".to_string()]);
1534 }
1535
1536 #[test]
1537 fn short_names_keep_the_path_only_where_a_filename_repeats() {
1538 let keys = vec![
1539 "src/net/mod.rs".to_string(),
1540 "src/io/mod.rs".to_string(),
1541 "src/db.rs".to_string(),
1542 ];
1543 assert_eq!(
1544 short_names(&keys),
1545 vec!["src/net/mod.rs", "src/io/mod.rs", "db.rs"]
1546 );
1547 }
1548
1549 #[test]
1550 fn cap_lines_keeps_the_first_lines_and_a_trailing_newline() {
1551 assert_eq!(cap_lines("a\nb\nc\n", 2), "a\nb\n");
1552 assert_eq!(cap_lines("a\nb", 9), "a\nb\n");
1553 assert_eq!(cap_lines("", 9), "");
1554 }
1555}