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
52#[inline]
81fn is_shape_forging(c: char) -> bool {
82 if c.is_ascii() {
83 return c.is_ascii_control();
84 }
85 matches!(c,
86 '\u{0085}' | '\u{200b}'..='\u{200f}' | '\u{2028}' | '\u{2029}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' | '\u{feff}' )
93}
94
95#[must_use]
97pub fn thousands(n: usize) -> String {
98 let digits = n.to_string();
99 let mut out = String::with_capacity(digits.len() + digits.len() / 3);
100 for (i, c) in digits.chars().enumerate() {
101 if i > 0 && (digits.len() - i).is_multiple_of(3) {
102 out.push(',');
103 }
104 out.push(c);
105 }
106 out
107}
108
109#[must_use]
111pub fn plural(n: usize, word: &str) -> String {
112 if n == 1 {
113 format!("{n} {word}")
114 } else {
115 format!("{} {word}s", thousands(n))
116 }
117}
118
119#[must_use]
122pub fn age(secs: i64) -> String {
123 let s = secs.max(0);
124 if s < 60 {
125 format!("{s}s")
126 } else if s < 3_600 {
127 format!("{}m", s / 60)
128 } else if s < 86_400 {
129 format!("{}h", s / 3_600)
130 } else {
131 format!("{}d", s / 86_400)
132 }
133}
134
135const DAY: i64 = 86_400;
137
138fn civil_from_days(days: i64) -> (i64, u32, u32) {
146 let z = days + 719_468;
149 let era = z.div_euclid(146_097);
150 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;
155 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
156 let year = yoe + era * 400 + i64::from(month <= 2);
157 (year, month, day)
158}
159
160#[must_use]
162pub fn ymd(ts: i64) -> String {
163 let (y, m, d) = civil_from_days(ts.div_euclid(DAY));
164 format!("{y:04}-{m:02}-{d:02}")
165}
166
167#[must_use]
170pub fn quarter_index(ts: i64) -> i64 {
171 let (y, m, _) = civil_from_days(ts.div_euclid(DAY));
172 y * 4 + i64::from((m - 1) / 3)
173}
174
175#[must_use]
177pub fn quarter_label(index: i64) -> String {
178 format!("{}Q{}", index.div_euclid(4), index.rem_euclid(4) + 1)
179}
180
181#[must_use]
183pub fn basename(key: &str) -> &str {
184 key.rsplit_once('/').map_or(key, |(_, base)| base)
185}
186
187#[must_use]
190pub fn dir_components(key: &str) -> Vec<&str> {
191 let mut parts: Vec<&str> = key.split('/').collect();
192 parts.pop();
193 parts
194}
195
196#[must_use]
199pub fn common_dir_prefix(keys: &[String]) -> String {
200 let mut iter = keys.iter().map(|k| dir_components(k));
201 let Some(mut prefix) = iter.next() else {
202 return String::new();
203 };
204 for comps in iter {
205 let shared = prefix
206 .iter()
207 .zip(comps.iter())
208 .take_while(|(a, b)| a == b)
209 .count();
210 prefix.truncate(shared);
211 if prefix.is_empty() {
212 break;
213 }
214 }
215 prefix.join("/")
216}
217
218#[must_use]
225pub fn top_tokens(keys: &[String], prefix: &str, n: usize, dirs_only: bool) -> Vec<String> {
226 let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
227 for key in keys {
228 let rest = match prefix.is_empty() {
229 true => key.as_str(),
230 false => key
231 .strip_prefix(prefix)
232 .unwrap_or(key)
233 .trim_start_matches('/'),
234 };
235 let mut seen: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect();
236 if dirs_only {
237 seen.pop();
238 }
239 seen.sort_unstable();
240 seen.dedup();
241 for token in seen {
242 *counts.entry(token).or_default() += 1;
243 }
244 }
245 let mut ranked: Vec<(&str, usize)> = counts.into_iter().collect();
246 ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
247 ranked
248 .into_iter()
249 .take(n)
250 .map(|(t, _)| t.to_string())
251 .collect()
252}
253
254pub const MIXED: &str = "<mixed>";
256
257#[must_use]
268pub fn cluster_name(keys: &[String]) -> String {
269 let prefix = common_dir_prefix(keys);
270 let head = if prefix.is_empty() {
271 MIXED.to_string()
272 } else {
273 prefix.clone()
274 };
275 let mut tokens = top_tokens(keys, &prefix, 2, true);
276 if tokens.is_empty() && prefix.is_empty() {
277 tokens = top_tokens(keys, &prefix, 2, false);
279 }
280 if tokens.is_empty() {
281 head
282 } else {
283 format!("{head} {}", tokens.join(", "))
284 }
285}
286
287#[must_use]
293pub fn short_names(keys: &[String]) -> Vec<String> {
294 let mut seen: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
295 for key in keys {
296 *seen.entry(basename(key)).or_default() += 1;
297 }
298 keys.iter()
299 .map(|k| match seen.get(basename(k)) {
300 Some(1) => sanitize(basename(k)),
301 _ => sanitize(k),
302 })
303 .collect()
304}
305
306#[must_use]
308pub fn cap_lines(text: &str, max: usize) -> String {
309 let mut out = String::with_capacity(text.len());
310 for line in text.lines().take(max) {
311 out.push_str(line);
312 out.push('\n');
313 }
314 out
315}
316
317#[must_use]
324pub fn cap_bytes(text: &str, max: usize) -> String {
325 let mut out = String::with_capacity(text.len().min(max));
326 for line in text.lines() {
327 if out.len() + line.len() + 1 > max {
328 break;
329 }
330 out.push_str(line);
331 out.push('\n');
332 }
333 out
334}
335
336pub const EMPTY_MAP: &str =
339 "mushroomdb map — empty store; run: mushroomdb ingest-git <db> <repo>\n";
340
341#[must_use]
348pub fn render_map(m: &RepoMap) -> String {
349 if m.files == 0 {
350 return EMPTY_MAP.to_string();
351 }
352 let mut out = String::new();
353
354 let sync = match &m.last_sync {
356 None => "not synced".to_string(),
357 Some(s) => {
358 let sha = sanitize(&s.sha);
359 let short: String = sha.chars().take(7).collect();
360 match s.age_secs {
361 Some(secs) => format!("synced {} ago at {short}", age(secs)),
362 None => format!("synced at {short}"),
363 }
364 }
365 };
366 let _ = writeln!(
367 out,
368 "mushroomdb map — {}, {}, {}, {} · {sync}{}",
369 plural(m.files, "file"),
370 plural(m.symbols, "symbol"),
371 plural(m.commits, "commit"),
372 plural(m.authors, "author"),
373 if m.truncated { " (truncated)" } else { "" }
374 );
375
376 if !m.communities.is_empty() {
377 out.push_str("clusters (co-change + imports)\n");
378 for (i, c) in m.communities.iter().enumerate() {
379 let samples = short_names(&c.samples);
380 let _ = writeln!(
381 out,
382 " {}. {} ({}, cohesion {:.2}){}{}",
383 i + 1,
384 sanitize(&c.name),
385 plural(c.size, "file"),
386 c.cohesion,
387 if samples.is_empty() { "" } else { " " },
388 samples.join(", ")
389 );
390 }
391 }
392
393 if !m.key_files.is_empty() {
394 out.push_str("key files (most depended-on)\n");
395 let items: Vec<String> = m
399 .key_files
400 .iter()
401 .map(|(k, s)| format!("{} {s:.2}", sanitize(k)))
402 .collect();
403 let _ = writeln!(out, " {}", items.join(SEP));
404 }
405
406 if !m.owners.is_empty() {
407 out.push_str("owners\n");
408 let items: Vec<String> = m
409 .owners
410 .iter()
411 .enumerate()
412 .map(|(i, (name, n))| match i {
413 0 => format!("{} {}", sanitize(name), plural(*n, "file")),
415 _ => format!("{} {n}", sanitize(name)),
416 })
417 .collect();
418 let _ = writeln!(out, " {}", items.join(SEP));
419 }
420
421 if !m.hot_files.is_empty() {
422 let _ = writeln!(out, "hot (last {} days)", m.hot_days);
423 let items: Vec<String> = m
424 .hot_files
425 .iter()
426 .map(|(k, n)| format!("{} {n}", sanitize(k)))
427 .collect();
428 let _ = writeln!(out, " {}", items.join(SEP));
429 }
430
431 if m.stale_concepts > 0 {
432 let (noun, verb) = if m.stale_concepts == 1 {
433 ("concept", "needs")
434 } else {
435 ("concepts", "need")
436 };
437 let _ = writeln!(
438 out,
439 "notes: {} {noun} {verb} re-learning (source changed)",
440 m.stale_concepts
441 );
442 }
443
444 if !m.questions.is_empty() {
445 let asks: Vec<String> = m.questions.iter().map(|q| sanitize(q)).collect();
446 let _ = writeln!(out, "ask me: {}", asks.join(SEP));
447 }
448
449 cap_lines(&out, MAX_MAP_LINES)
450}
451
452pub const MAX_BRIEF_BYTES: usize = 4_000;
460
461pub const EMPTY_BRIEF: &str =
470 "mushroomdb brief — empty store; run: mushroomdb ingest-git <db> <repo>\n";
471
472const BRIEF_FILES_HEADING: &str = "key files (by centrality):\n";
474const BRIEF_SYMBOLS_HEADING: &str = "key symbols (most called):\n";
475const BRIEF_LABELS_HEADING: &str = "labels:\n";
477const BRIEF_EDGE_TYPES_HEADING: &str = "edge types:\n";
478const BRIEF_RECIPES_HEADING: &str = "ask in one call:\n";
482
483#[must_use]
507pub fn render_brief(b: &BriefReport, reach: &str) -> String {
508 let nodes = b.schema.as_ref().map_or(b.files + b.symbols, |s| s.nodes);
509 if nodes == 0 && b.edges == 0 {
510 return EMPTY_BRIEF.to_string();
511 }
512 let tail = format!("reach the graph: {}\n", sanitize(reach));
513 let budget = MAX_BRIEF_BYTES.saturating_sub(tail.len());
514 if let Some(schema) = &b.schema {
515 return render_memory_brief(b, schema, budget) + &tail;
516 }
517
518 let mut head: Vec<String> = Vec::new();
522 if !b.repo.is_empty() {
523 head.push(sanitize(&b.repo));
524 }
525 head.push(plural(b.files, "file"));
526 head.push(plural(b.symbols, "symbol"));
527 head.push(plural(b.edges, "edge"));
528 if let Some(sha) = &b.last_sync {
529 head.push(format!("synced {}", sanitize(sha)));
530 }
531 let header = format!("{UNTRUSTED_FRAMING}mushroomdb brief — {}\n", head.join(SEP));
532
533 let mut files: Vec<String> = b
534 .key_files
535 .iter()
536 .map(|(path, role)| format!(" {}{}\n", sanitize(path), suffix(role)))
537 .collect();
538 let mut symbols: Vec<String> = b
539 .key_symbols
540 .iter()
541 .map(|(key, sig)| format!(" {}{}\n", sanitize(key), suffix(sig)))
542 .collect();
543
544 let mut dropped = 0;
549 loop {
550 let body = brief_body(&header, &files, &symbols, dropped);
551 if body.len() <= budget || (symbols.is_empty() && files.is_empty()) {
552 return body + &tail;
553 }
554 if symbols.pop().is_none() {
555 files.pop();
556 }
557 dropped += 1;
558 }
559}
560
561fn brief_body(header: &str, files: &[String], symbols: &[String], dropped: usize) -> String {
563 let mut out = String::from(header);
564 if !files.is_empty() {
565 out.push_str(BRIEF_FILES_HEADING);
566 out.extend(files.iter().map(String::as_str));
567 }
568 if !symbols.is_empty() {
569 out.push_str(BRIEF_SYMBOLS_HEADING);
570 out.extend(symbols.iter().map(String::as_str));
571 }
572 if dropped > 0 {
573 let _ = writeln!(out, " … and {dropped} more");
574 }
575 out
576}
577
578fn render_memory_brief(b: &BriefReport, s: &SchemaBrief, budget: usize) -> String {
616 let at_least = |n: usize| {
621 if s.partial {
622 format!("≥ {}", thousands(n))
623 } else {
624 thousands(n)
625 }
626 };
627 let header = format!(
628 "{UNTRUSTED_FRAMING}mushroomdb brief — {}{}\n",
629 [
630 if s.partial {
631 format!("≥ {}", plural(s.nodes, "node"))
632 } else {
633 plural(s.nodes, "node")
634 },
635 plural(b.edges, "edge"),
636 plural(s.labels.len(), "label"),
637 ]
638 .join(SEP),
639 if s.partial { " (partial)" } else { "" }
640 );
641
642 let label_lines = |cap: usize| -> Vec<String> {
643 s.labels
644 .iter()
645 .map(|l| {
646 let mut line = format!(
647 " {} ({})",
648 cap_name(&sanitize(&l.label), cap),
649 at_least(l.nodes)
650 );
651 if !l.props.is_empty() {
652 let props: Vec<String> = l.props.iter().map(|p| cap_name(p, cap)).collect();
653 let _ = write!(line, " — {}", props.join(", "));
654 }
655 if l.hidden_props > 0 {
656 let _ = write!(line, ", … +{}", l.hidden_props);
657 }
658 line.push('\n');
659 line
660 })
661 .collect()
662 };
663 let edge_type_lines = |cap: usize| -> Vec<String> {
664 s.edge_types
665 .iter()
666 .map(|t| {
667 let mut line = format!(
668 " {} ({})",
669 cap_name(&sanitize(&t.edge_type), cap),
670 at_least(t.edges)
671 );
672 if let Some(rule) = &t.rule {
673 let _ = write!(line, " — rule {}", cap_name(&sanitize(rule), cap));
674 if t.hidden_rules > 0 {
675 let _ = write!(line, " +{}", t.hidden_rules);
676 }
677 }
678 let _ = writeln!(line, " — {} → {}", ends(&t.src, cap), ends(&t.dst, cap));
679 line
680 })
681 .collect()
682 };
683
684 let mut prefix = match s.commits {
689 Some(n) => format!("history: {n} commits\n"),
690 None => "history: unknown\n".to_string(),
691 };
692 if !s.roles.is_empty() {
693 let roles: Vec<String> = s
694 .roles
695 .iter()
696 .map(|(name, labels)| {
697 if labels.is_empty() {
698 sanitize(name)
699 } else {
700 format!("{} ({})", sanitize(name), labels.join(", "))
701 }
702 })
703 .collect();
704 let _ = writeln!(prefix, "roles: {}", roles.join(SEP));
705 }
706 let recipes: Vec<String> = s
707 .recipes
708 .iter()
709 .map(|r| format!(" {}: {}\n", sanitize(&r.question), sanitize(&r.call)))
710 .collect();
711 let with_recipes = |kept: usize| -> String {
712 let mut fixed = prefix.clone();
713 if kept > 0 {
714 fixed.push_str(BRIEF_RECIPES_HEADING);
715 fixed.extend(recipes[..kept].iter().map(String::as_str));
716 }
717 fixed
718 };
719 let fixed = with_recipes(recipes.len());
720
721 let whole = memory_body(
725 &header,
726 &label_lines(usize::MAX),
727 &edge_type_lines(usize::MAX),
728 &fixed,
729 0,
730 );
731 if whole.len() <= budget {
732 return whole;
733 }
734
735 let mut labels = label_lines(BRIEF_NAME_CAP);
743 let mut edge_types = edge_type_lines(BRIEF_NAME_CAP);
744 let mut dropped = 0;
745 loop {
746 let body = memory_body(&header, &labels, &edge_types, &fixed, dropped);
747 if body.len() <= budget {
748 return body;
749 }
750 if edge_types.pop().is_none() && labels.pop().is_none() {
751 break;
752 }
753 dropped += 1;
754 }
755
756 let truncated = format!(
760 "(brief truncated at {} bytes)\n",
761 thousands(MAX_BRIEF_BYTES)
762 );
763 let mut kept = recipes.len();
764 loop {
765 let body = memory_body(&header, &[], &[], &with_recipes(kept), dropped) + &truncated;
766 if body.len() <= budget {
767 return body;
768 }
769 if kept == 0 {
770 return cap_bytes(&body, budget);
774 }
775 kept -= 1;
776 }
777}
778
779const BRIEF_NAME_CAP: usize = 60;
787
788fn cap_name(name: &str, cap: usize) -> String {
795 if cap == 0 || name.chars().count() <= cap {
796 return name.to_string();
797 }
798 let end = name
799 .char_indices()
800 .nth(cap - 1)
801 .map_or(name.len(), |(i, _)| i);
802 format!("{}…", &name[..end])
803}
804
805fn memory_body(
807 header: &str,
808 labels: &[String],
809 edge_types: &[String],
810 fixed: &str,
811 dropped: usize,
812) -> String {
813 let mut out = String::from(header);
814 if !labels.is_empty() {
815 out.push_str(BRIEF_LABELS_HEADING);
816 out.extend(labels.iter().map(String::as_str));
817 }
818 if !edge_types.is_empty() {
819 out.push_str(BRIEF_EDGE_TYPES_HEADING);
820 out.extend(edge_types.iter().map(String::as_str));
821 }
822 if dropped > 0 {
823 let _ = writeln!(out, " … and {dropped} more");
824 }
825 out.push_str(fixed);
826 out
827}
828
829fn ends(labels: &[String], cap: usize) -> String {
834 if labels.is_empty() {
835 "?".to_string()
836 } else {
837 labels
838 .iter()
839 .map(|l| cap_name(&sanitize(l), cap))
840 .collect::<Vec<_>>()
841 .join("|")
842 }
843}
844
845fn suffix(detail: &str) -> String {
847 if detail.is_empty() {
848 String::new()
849 } else {
850 format!(" — {}", sanitize(detail))
851 }
852}
853
854const MAX_SOURCE_PRINTED: usize = 40;
861const MAX_CANDIDATES: usize = 20;
865const MAX_IMPACT_FILES: usize = 5;
867const MAX_IMPACT_UNKNOWN: usize = 3;
877const MAX_WHY_LINKS: usize = 5;
879
880fn section(out: &mut String, name: &str, items: &[String]) {
882 if !items.is_empty() {
883 let _ = writeln!(out, "{name} {}", items.join(SEP));
884 }
885}
886
887fn commit_line(sha: &str, ts: i64, subject: &str) -> String {
889 let short: String = sanitize(sha).chars().take(7).collect();
890 format!("{short} {} {}", ymd(ts), sanitize(subject))
891}
892
893#[must_use]
902pub fn render_context(c: &ContextReport) -> String {
903 let mut out = String::new();
904 match &c.target {
905 Target::Unknown { target } if c.candidates.is_empty() => {
906 let _ = writeln!(out, "mushroomdb context — unknown: {}", sanitize(target));
907 return out;
908 }
909 Target::Unknown { target } => {
910 let _ = writeln!(
911 out,
912 "mushroomdb context — {} is ambiguous: {}",
913 sanitize(target),
914 plural(c.candidates.len(), "symbol")
915 );
916 for key in c.candidates.iter().take(MAX_CANDIDATES) {
917 let _ = writeln!(out, " {}", sanitize(key));
918 }
919 if c.candidates.len() > MAX_CANDIDATES {
920 let _ = writeln!(
921 out,
922 " … {} not shown",
923 plural(c.candidates.len() - MAX_CANDIDATES, "symbol")
924 );
925 }
926 return cap_lines(&out, MAX_CONTEXT_LINES);
927 }
928 Target::File { path } => {
929 let _ = writeln!(out, "mushroomdb context — file {}", sanitize(path));
930 }
931 Target::Symbol { key } => {
932 let _ = writeln!(
933 out,
934 "mushroomdb context — symbol {} in {}",
935 sanitize(key),
936 sanitize(&c.file)
937 );
938 }
939 }
940
941 if let Some((first, last)) = c.lines.filter(|_| c.source.is_none() && !c.file.is_empty()) {
945 let _ = writeln!(out, " at {}:{first}-{last}", sanitize(&c.file));
946 }
947 if let Some(sig) = &c.signature {
948 let _ = writeln!(out, "signature {}", sanitize(sig));
949 }
950 if let Some(doc) = &c.doc {
951 let _ = writeln!(out, "doc {}", sanitize(doc));
952 }
953 let mut about: Vec<String> = Vec::new();
954 if let Some((first, last)) = c.lines.filter(|_| c.source.is_some()) {
955 about.push(format!("lines {first}-{last}"));
956 }
957 if let Some(owner) = &c.owner {
958 about.push(format!("owner {}", sanitize(owner)));
959 }
960 section(&mut out, "where", &about);
961
962 if let Some(source) = &c.source {
963 let first = c.lines.map_or(1, |(first, _)| first);
964 let total = source.lines().count();
965 let _ = writeln!(out, "source");
966 for (i, line) in source.lines().take(MAX_SOURCE_PRINTED).enumerate() {
967 let n = first as usize + i;
968 let _ = writeln!(out, " {n:>5} | {}", sanitize(line));
969 }
970 if total > MAX_SOURCE_PRINTED {
971 let _ = writeln!(
972 out,
973 " … {} more",
974 plural(total - MAX_SOURCE_PRINTED, "line")
975 );
976 }
977 }
978
979 let mut callers: Vec<String> = c
982 .callers
983 .iter()
984 .map(|s| {
985 let lines: Vec<String> = s
986 .lines
987 .iter()
988 .filter(|n| **n > 0)
989 .map(u32::to_string)
990 .collect();
991 let more = s.sites.saturating_sub(s.lines.len());
992 let mut item = match lines.is_empty() {
993 true => sanitize(&s.file),
994 false => format!("{}: {}", sanitize(&s.file), lines.join(", ")),
995 };
996 if more > 0 {
997 let _ = write!(item, " …(+{more})");
998 }
999 item
1000 })
1001 .collect();
1002 if c.callers_not_shown > 0 {
1003 callers.push(format!(
1004 "… {} not shown",
1005 plural(c.callers_not_shown, "file")
1006 ));
1007 }
1008 section(&mut out, "callers", &callers);
1009 let callees: Vec<String> = c
1010 .callees
1011 .iter()
1012 .map(|(key, line)| match line {
1013 0 => sanitize(key),
1014 n => format!("{} line {n}", sanitize(key)),
1015 })
1016 .collect();
1017 section(&mut out, "callees", &callees);
1018 section(
1019 &mut out,
1020 "imports",
1021 &c.imports.iter().map(|k| sanitize(k)).collect::<Vec<_>>(),
1022 );
1023 section(
1024 &mut out,
1025 "importers",
1026 &c.importers.iter().map(|k| sanitize(k)).collect::<Vec<_>>(),
1027 );
1028 section(
1029 &mut out,
1030 "co-change",
1031 &c.partners
1032 .iter()
1033 .map(|(k, s)| format!("{} {s:.2}", sanitize(k)))
1034 .collect::<Vec<_>>(),
1035 );
1036 section(
1037 &mut out,
1038 "commits",
1039 &c.recent_commits
1040 .iter()
1041 .map(|(sha, ts, subject)| commit_line(sha, *ts, subject))
1042 .collect::<Vec<_>>(),
1043 );
1044 for (key, text) in &c.notes {
1045 let _ = writeln!(out, "note {} {}", sanitize(key), sanitize(text));
1046 }
1047 for (key, name) in &c.concepts {
1048 let _ = writeln!(out, "concept {} {}", sanitize(key), sanitize(name));
1049 }
1050 cap_lines(&out, MAX_CONTEXT_LINES)
1051}
1052
1053fn partner_item(p: &Partner, with_score: bool) -> String {
1056 let mut item = sanitize(&p.path);
1057 match p.shared_commits {
1061 Some(n) => {
1062 let _ = write!(item, " ({})", plural(n, "shared commit"));
1063 }
1064 None if with_score => {
1065 let _ = write!(item, " {:.2}", p.score);
1066 }
1067 None => {}
1068 }
1069 if p.modified {
1070 item.push_str(" modified");
1071 }
1072 item
1073}
1074
1075#[must_use]
1077pub fn render_impact(r: &ImpactReport) -> String {
1078 let mut out = String::new();
1079 let _ = writeln!(
1080 out,
1081 "mushroomdb impact — {}",
1082 plural(r.files.len(), "changed file")
1083 );
1084 for f in r.files.iter().take(MAX_IMPACT_FILES) {
1085 render_file_impact(&mut out, f);
1086 }
1087 if r.files.len() > MAX_IMPACT_FILES {
1088 let _ = writeln!(
1089 out,
1090 "… {} not shown",
1091 plural(r.files.len() - MAX_IMPACT_FILES, "file")
1092 );
1093 }
1094 for path in r.unknown.iter().take(MAX_IMPACT_UNKNOWN) {
1095 let _ = writeln!(out, "unknown: {}", sanitize(path));
1096 }
1097 if r.unknown.len() > MAX_IMPACT_UNKNOWN {
1098 let _ = writeln!(
1099 out,
1100 "…and {} more unknown",
1101 r.unknown.len() - MAX_IMPACT_UNKNOWN
1102 );
1103 }
1104 cap_lines(&out, MAX_TOOL_LINES)
1105}
1106
1107fn render_file_impact(out: &mut String, f: &FileImpact) {
1108 match &f.owner {
1109 Some(owner) => {
1110 let _ = writeln!(out, "{} ({})", sanitize(&f.path), sanitize(owner));
1111 }
1112 None => {
1113 let _ = writeln!(out, "{}", sanitize(&f.path));
1114 }
1115 }
1116 section(
1117 out,
1118 " partners ",
1119 &f.partners
1120 .iter()
1121 .map(|p| partner_item(p, true))
1122 .collect::<Vec<_>>(),
1123 );
1124 section(
1125 out,
1126 " importers",
1127 &f.importers
1128 .iter()
1129 .map(|p| partner_item(p, false))
1130 .collect::<Vec<_>>(),
1131 );
1132 section(
1133 out,
1134 " used by ",
1135 &f.symbols_used_elsewhere
1136 .iter()
1137 .map(|(key, n)| format!("{} {}", sanitize(key), plural(*n, "caller")))
1138 .collect::<Vec<_>>(),
1139 );
1140}
1141
1142pub const DEFAULT_EXPLORE_BYTES: usize = 4_800;
1149
1150#[must_use]
1166pub fn render_explore(r: &ExploreReport, budget_bytes: usize) -> String {
1167 let mut out = render_context(&r.context);
1168
1169 if let Some(imp) = &r.impact {
1170 let rendered = render_impact(imp);
1173 let mut body = rendered.lines().skip(1).peekable();
1174 if body.peek().is_some() {
1175 out.push_str("impact:\n");
1176 for line in body {
1177 let _ = writeln!(out, " {line}");
1178 }
1179 }
1180 }
1181
1182 if let Some((name, key, share)) = r.owners.as_ref().and_then(|o| o.top.as_ref()) {
1183 let _ = writeln!(
1184 out,
1185 "owner: {} ({}) {share:.2} of the file's commits",
1186 sanitize(name),
1187 sanitize(key)
1188 );
1189 }
1190
1191 let capped = cap_bytes(&out, budget_bytes);
1192 if !capped.is_empty() || out.is_empty() || budget_bytes == 0 {
1193 return capped;
1194 }
1195 let head = out.lines().next().unwrap_or_default();
1200 let mut end = budget_bytes - 1;
1201 while end > 0 && !head.is_char_boundary(end) {
1202 end -= 1;
1203 }
1204 format!("{}\n", &head[..end])
1205}
1206
1207#[must_use]
1213pub fn render_owners(o: &OwnersReport) -> String {
1214 let mut out = String::new();
1215 let _ = writeln!(out, "mushroomdb owners — {}", sanitize(&o.path));
1216 if let Some((name, key, share)) = &o.top {
1217 let _ = writeln!(
1218 out,
1219 "top {} ({}) {share:.2} of the file's commits",
1220 sanitize(name),
1221 sanitize(key)
1222 );
1223 }
1224 section(
1225 &mut out,
1226 "knows",
1227 &o.knows
1228 .iter()
1229 .map(|(name, score)| format!("{} {score:.2}", sanitize(name)))
1230 .collect::<Vec<_>>(),
1231 );
1232 if let Some((sha, ts, subject)) = &o.last_touch {
1233 let _ = writeln!(out, "last touch {}", commit_line(sha, *ts, subject));
1234 }
1235 section(
1236 &mut out,
1237 "by quarter",
1238 &o.by_quarter
1239 .iter()
1240 .map(|(q, name, n)| format!("{} {} {n}", sanitize(q), sanitize(name)))
1241 .collect::<Vec<_>>(),
1242 );
1243 cap_lines(&out, MAX_TOOL_LINES)
1244}
1245
1246#[must_use]
1248pub fn render_why(w: &WhyReport) -> String {
1249 let mut out = String::new();
1250 let _ = writeln!(
1251 out,
1252 "mushroomdb why — {} ↔ {}",
1253 sanitize(&w.a),
1254 sanitize(&w.b)
1255 );
1256 for key in &w.unknown {
1257 let _ = writeln!(out, "unknown: {}", sanitize(key));
1258 }
1259 if !w.unknown.is_empty() {
1260 return cap_lines(&out, MAX_TOOL_LINES);
1261 }
1262 let links = pair_up(&w.links);
1263 for (link, both_ways) in links.iter().take(MAX_WHY_LINKS) {
1264 render_link(&mut out, link, *both_ways);
1265 }
1266 if links.len() > MAX_WHY_LINKS {
1267 let _ = writeln!(
1268 out,
1269 "… {} not shown",
1270 plural(links.len() - MAX_WHY_LINKS, "link")
1271 );
1272 }
1273 if let Some(shared) = &w.shared {
1274 let _ = writeln!(
1275 out,
1276 "co-change {}, below the co_changed rule's similarity floor so no edge was written",
1277 plural(shared.count, "shared commit")
1278 );
1279 for line in &shared.evidence {
1280 let _ = writeln!(out, " {}", sanitize(line));
1281 }
1282 }
1283 if !w.path.is_empty() {
1284 let mut walk = sanitize(&w.a);
1285 for (edge_type, node) in &w.path {
1286 let _ = write!(walk, " -[{}]-> {}", sanitize(edge_type), sanitize(node));
1287 }
1288 let _ = writeln!(out, "path {walk}");
1289 }
1290 if w.links.is_empty() && w.path.is_empty() && w.shared.is_none() {
1291 let _ = writeln!(out, "no link");
1292 }
1293 cap_lines(&out, MAX_TOOL_LINES)
1294}
1295
1296fn pair_up(links: &[WhyLink]) -> Vec<(&WhyLink, bool)> {
1309 let mut out: Vec<(&WhyLink, bool)> = Vec::new();
1310 let mut folded: Vec<bool> = vec![false; links.len()];
1311 for (i, link) in links.iter().enumerate() {
1312 if folded[i] {
1313 continue;
1314 }
1315 let mut both_ways = false;
1316 for (j, other) in links.iter().enumerate().skip(i + 1) {
1317 if !folded[j]
1318 && other.rule == link.rule
1319 && other.edge_type == link.edge_type
1320 && other.direction != link.direction
1321 && other.score == link.score
1322 && other.evidence == link.evidence
1323 {
1324 folded[j] = true;
1325 both_ways = true;
1326 break;
1327 }
1328 }
1329 out.push((link, both_ways));
1330 }
1331 out
1332}
1333
1334fn render_link(out: &mut String, link: &WhyLink, both_ways: bool) {
1335 let mut head = format!(
1336 "{} {} {}",
1337 sanitize(&link.edge_type),
1338 if both_ways {
1339 "a↔b".to_string()
1340 } else {
1341 sanitize(&link.direction)
1342 },
1343 sanitize(&link.rule)
1344 );
1345 if let Some(score) = link.score {
1346 let _ = write!(head, " {score:.2}");
1347 }
1348 if let Some(via) = &link.via {
1349 let _ = write!(head, " via {}", sanitize(via));
1350 }
1351 let _ = writeln!(out, "{head}");
1352 for line in &link.evidence {
1353 let _ = writeln!(out, " {}", sanitize(line));
1354 }
1355}
1356
1357#[cfg(test)]
1358mod tests {
1359 use super::*;
1360
1361 #[test]
1362 fn cap_bytes_keeps_whole_lines_and_never_half_of_one() {
1363 let text = "aaaa\nbbbb\ncccc\n"; assert_eq!(cap_bytes(text, 15), text, "the whole text fits exactly");
1365 assert_eq!(
1366 cap_bytes(text, 14),
1367 "aaaa\nbbbb\n",
1368 "the last line is whole"
1369 );
1370 assert_eq!(cap_bytes(text, 10), "aaaa\nbbbb\n");
1371 assert_eq!(cap_bytes(text, 9), "aaaa\n");
1372 assert_eq!(
1373 cap_bytes(text, 4),
1374 "",
1375 "a first line too long yields nothing, never a fragment"
1376 );
1377 assert_eq!(cap_bytes(text, 0), "");
1378 assert_eq!(cap_bytes("abc", 4), "abc\n");
1380 assert_eq!(cap_bytes("abc", 3), "");
1381 }
1382
1383 #[test]
1384 fn a_timestamp_reads_as_a_utc_date_and_a_quarter() {
1385 for (ts, date, quarter) in [
1388 (0_i64, "1970-01-01", "1970Q1"),
1389 (1_582_934_400, "2020-02-29", "2020Q1"),
1390 (951_782_400, "2000-02-29", "2000Q1"),
1391 (1_600_000_000, "2020-09-13", "2020Q3"),
1392 (1_609_459_199, "2020-12-31", "2020Q4"),
1393 (1_609_459_200, "2021-01-01", "2021Q1"),
1394 (-1, "1969-12-31", "1969Q4"),
1395 ] {
1396 assert_eq!(ymd(ts), date, "{ts}");
1397 assert_eq!(quarter_label(quarter_index(ts)), quarter, "{ts}");
1398 }
1399 }
1400
1401 #[test]
1402 fn quarter_indices_are_a_count_a_window_can_be_measured_in() {
1403 let q3 = quarter_index(1_600_000_000); assert_eq!(quarter_label(q3 - 3), "2019Q4");
1405 assert_eq!(quarter_label(q3 + 1), "2020Q4");
1406 assert_eq!(quarter_label(q3 + 2), "2021Q1");
1407 }
1408
1409 #[test]
1410 fn sanitize_replaces_every_control_character_one_for_one() {
1411 let forged = "Ada\nmushroomdb map\t— 9 files\u{7f}\u{1b}[31m";
1412 let clean = sanitize(forged);
1413 assert_eq!(clean.len(), forged.len(), "one byte in, one byte out");
1414 assert!(!clean.contains('\n') && !clean.contains('\t') && !clean.contains('\u{1b}'));
1415 assert_eq!(clean, "Ada mushroomdb map — 9 files [31m");
1416 }
1417
1418 #[test]
1420 fn sanitize_neutralizes_bidi_zero_width_and_separators() {
1421 for (cp, name) in [
1422 ('\u{202e}', "U+202E RIGHT-TO-LEFT OVERRIDE"),
1423 ('\u{200b}', "U+200B ZERO WIDTH SPACE"),
1424 ('\u{2028}', "U+2028 LINE SEPARATOR"),
1425 ('\u{2029}', "U+2029 PARAGRAPH SEPARATOR"),
1426 ] {
1427 let forged = format!("safe{cp}tail");
1428 let clean = sanitize(&forged);
1429 assert_eq!(clean, "safe tail", "{name} must render as one space");
1430 assert_eq!(
1431 clean.chars().count(),
1432 forged.chars().count(),
1433 "{name}: one char in, one char out"
1434 );
1435 }
1436 }
1437
1438 #[test]
1443 fn sanitize_covers_the_whole_class_not_just_the_named_four() {
1444 for cp in [
1445 '\u{202a}', '\u{202b}', '\u{202c}', '\u{202d}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', '\u{200c}', '\u{200d}', '\u{200e}', '\u{200f}', '\u{feff}', '\u{0085}', ] {
1451 let clean = sanitize(&format!("a{cp}b"));
1452 assert_eq!(
1453 clean, "a b",
1454 "U+{:04X} is the same class as the four §5.12 names",
1455 cp as u32
1456 );
1457 }
1458 }
1459
1460 #[test]
1467 fn sanitize_classifies_every_ascii_byte() {
1468 for b in 0u8..128 {
1469 let c = b as char;
1470 let got = sanitize(&c.to_string());
1471 if c.is_ascii_control() {
1472 assert_eq!(got, " ", "U+{b:04X} is an ASCII control and must blank");
1473 } else {
1474 assert_eq!(
1475 got,
1476 c.to_string(),
1477 "U+{b:04X} is printable ASCII and must survive untouched"
1478 );
1479 }
1480 }
1481 }
1482
1483 #[test]
1486 fn sanitize_never_grows_a_string() {
1487 let forged = "subject\u{202e}\u{200b}\u{2028}\u{2029}tail";
1488 let clean = sanitize(forged);
1489 assert!(
1490 clean.len() <= forged.len(),
1491 "bytes must not grow: {} -> {}",
1492 forged.len(),
1493 clean.len()
1494 );
1495 assert_eq!(
1496 clean.chars().count(),
1497 forged.chars().count(),
1498 "characters are one for one"
1499 );
1500 }
1501
1502 #[test]
1503 fn thousands_groups_from_the_right() {
1504 for (n, want) in [
1505 (0, "0"),
1506 (7, "7"),
1507 (999, "999"),
1508 (1_000, "1,000"),
1509 (1_204, "1,204"),
1510 (999_999, "999,999"),
1511 (1_830_412, "1,830,412"),
1512 ] {
1513 assert_eq!(thousands(n), want, "{n}");
1514 }
1515 }
1516
1517 #[test]
1518 fn plural_says_one_file_and_two_files() {
1519 assert_eq!(plural(1, "file"), "1 file");
1520 assert_eq!(plural(0, "file"), "0 files");
1521 assert_eq!(plural(1_204, "commit"), "1,204 commits");
1522 }
1523
1524 #[test]
1525 fn age_picks_one_coarse_unit() {
1526 for (secs, want) in [
1527 (-5, "0s"),
1528 (0, "0s"),
1529 (59, "59s"),
1530 (60, "1m"),
1531 (720, "12m"),
1532 (3_600, "1h"),
1533 (86_399, "23h"),
1534 (86_400, "1d"),
1535 (20 * 86_400, "20d"),
1536 ] {
1537 assert_eq!(age(secs), want, "{secs}");
1538 }
1539 }
1540
1541 #[test]
1542 fn paths_split_into_a_base_and_its_directories() {
1543 assert_eq!(basename("src/core/db.rs"), "db.rs");
1544 assert_eq!(basename("README.md"), "README.md");
1545 assert_eq!(dir_components("src/core/db.rs"), vec!["src", "core"]);
1546 assert!(dir_components("README.md").is_empty());
1547 }
1548
1549 #[test]
1550 fn a_cluster_is_named_by_the_directory_its_files_share() {
1551 let same = vec![
1553 "crates/core-api/src/db.rs".to_string(),
1554 "crates/core-api/src/algo.rs".to_string(),
1555 ];
1556 assert_eq!(cluster_name(&same), "crates/core-api/src");
1557 let partial = vec![
1560 "crates/core-api/src/db.rs".to_string(),
1561 "crates/core-api/tests/algo.rs".to_string(),
1562 ];
1563 assert_eq!(cluster_name(&partial), "crates/core-api src, tests");
1564 }
1565
1566 #[test]
1567 fn files_sharing_no_directory_are_named_by_their_commonest_segments() {
1568 let mixed = vec![
1569 "docs/site/algorithms.md".to_string(),
1570 "docs/site/install.md".to_string(),
1571 "site/index.html".to_string(),
1572 "README.md".to_string(),
1573 ];
1574 assert_eq!(cluster_name(&mixed), "<mixed> site, docs");
1577 assert_eq!(cluster_name(&["a.rs".to_string()]), "<mixed> a.rs");
1578 assert_eq!(cluster_name(&[]), "<mixed>");
1579 }
1580
1581 #[test]
1582 fn a_segment_counts_once_per_key_however_often_it_repeats() {
1583 let keys = vec!["a/a/a/a.rs".to_string(), "b/x.rs".to_string()];
1584 assert_eq!(top_tokens(&keys, "", 1, true), vec!["a".to_string()]);
1585 assert_eq!(top_tokens(&keys, "", 1, false), vec!["a".to_string()]);
1587 }
1588
1589 #[test]
1590 fn short_names_keep_the_path_only_where_a_filename_repeats() {
1591 let keys = vec![
1592 "src/net/mod.rs".to_string(),
1593 "src/io/mod.rs".to_string(),
1594 "src/db.rs".to_string(),
1595 ];
1596 assert_eq!(
1597 short_names(&keys),
1598 vec!["src/net/mod.rs", "src/io/mod.rs", "db.rs"]
1599 );
1600 }
1601
1602 #[test]
1603 fn cap_lines_keeps_the_first_lines_and_a_trailing_newline() {
1604 assert_eq!(cap_lines("a\nb\nc\n", 2), "a\nb\n");
1605 assert_eq!(cap_lines("a\nb", 9), "a\nb\n");
1606 assert_eq!(cap_lines("", 9), "");
1607 }
1608}