1use crate::repograph::context::{ContextReport, Target};
9use crate::repograph::impact::{FileImpact, ImpactReport, Partner};
10use crate::repograph::map::RepoMap;
11use crate::repograph::owners::OwnersReport;
12use crate::repograph::why::{WhyLink, WhyReport};
13use std::fmt::Write as _;
14
15pub const MAX_MAP_LINES: usize = 40;
17pub const MAX_CONTEXT_LINES: usize = 60;
20pub const MAX_TOOL_LINES: usize = 25;
22
23pub const SEP: &str = " · ";
25
26#[must_use]
32pub fn sanitize(s: &str) -> String {
33 s.chars()
34 .map(|c| if c.is_ascii_control() { ' ' } else { c })
35 .collect()
36}
37
38#[must_use]
40pub fn thousands(n: usize) -> String {
41 let digits = n.to_string();
42 let mut out = String::with_capacity(digits.len() + digits.len() / 3);
43 for (i, c) in digits.chars().enumerate() {
44 if i > 0 && (digits.len() - i).is_multiple_of(3) {
45 out.push(',');
46 }
47 out.push(c);
48 }
49 out
50}
51
52#[must_use]
54pub fn plural(n: usize, word: &str) -> String {
55 if n == 1 {
56 format!("{n} {word}")
57 } else {
58 format!("{} {word}s", thousands(n))
59 }
60}
61
62#[must_use]
65pub fn age(secs: i64) -> String {
66 let s = secs.max(0);
67 if s < 60 {
68 format!("{s}s")
69 } else if s < 3_600 {
70 format!("{}m", s / 60)
71 } else if s < 86_400 {
72 format!("{}h", s / 3_600)
73 } else {
74 format!("{}d", s / 86_400)
75 }
76}
77
78const DAY: i64 = 86_400;
80
81fn civil_from_days(days: i64) -> (i64, u32, u32) {
89 let z = days + 719_468;
92 let era = z.div_euclid(146_097);
93 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;
98 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
99 let year = yoe + era * 400 + i64::from(month <= 2);
100 (year, month, day)
101}
102
103#[must_use]
105pub fn ymd(ts: i64) -> String {
106 let (y, m, d) = civil_from_days(ts.div_euclid(DAY));
107 format!("{y:04}-{m:02}-{d:02}")
108}
109
110#[must_use]
113pub fn quarter_index(ts: i64) -> i64 {
114 let (y, m, _) = civil_from_days(ts.div_euclid(DAY));
115 y * 4 + i64::from((m - 1) / 3)
116}
117
118#[must_use]
120pub fn quarter_label(index: i64) -> String {
121 format!("{}Q{}", index.div_euclid(4), index.rem_euclid(4) + 1)
122}
123
124#[must_use]
126pub fn basename(key: &str) -> &str {
127 key.rsplit_once('/').map_or(key, |(_, base)| base)
128}
129
130#[must_use]
133pub fn dir_components(key: &str) -> Vec<&str> {
134 let mut parts: Vec<&str> = key.split('/').collect();
135 parts.pop();
136 parts
137}
138
139#[must_use]
142pub fn common_dir_prefix(keys: &[String]) -> String {
143 let mut iter = keys.iter().map(|k| dir_components(k));
144 let Some(mut prefix) = iter.next() else {
145 return String::new();
146 };
147 for comps in iter {
148 let shared = prefix
149 .iter()
150 .zip(comps.iter())
151 .take_while(|(a, b)| a == b)
152 .count();
153 prefix.truncate(shared);
154 if prefix.is_empty() {
155 break;
156 }
157 }
158 prefix.join("/")
159}
160
161#[must_use]
168pub fn top_tokens(keys: &[String], prefix: &str, n: usize, dirs_only: bool) -> Vec<String> {
169 let mut counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
170 for key in keys {
171 let rest = match prefix.is_empty() {
172 true => key.as_str(),
173 false => key
174 .strip_prefix(prefix)
175 .unwrap_or(key)
176 .trim_start_matches('/'),
177 };
178 let mut seen: Vec<&str> = rest.split('/').filter(|s| !s.is_empty()).collect();
179 if dirs_only {
180 seen.pop();
181 }
182 seen.sort_unstable();
183 seen.dedup();
184 for token in seen {
185 *counts.entry(token).or_default() += 1;
186 }
187 }
188 let mut ranked: Vec<(&str, usize)> = counts.into_iter().collect();
189 ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
190 ranked
191 .into_iter()
192 .take(n)
193 .map(|(t, _)| t.to_string())
194 .collect()
195}
196
197pub const MIXED: &str = "<mixed>";
199
200#[must_use]
211pub fn cluster_name(keys: &[String]) -> String {
212 let prefix = common_dir_prefix(keys);
213 let head = if prefix.is_empty() {
214 MIXED.to_string()
215 } else {
216 prefix.clone()
217 };
218 let mut tokens = top_tokens(keys, &prefix, 2, true);
219 if tokens.is_empty() && prefix.is_empty() {
220 tokens = top_tokens(keys, &prefix, 2, false);
222 }
223 if tokens.is_empty() {
224 head
225 } else {
226 format!("{head} {}", tokens.join(", "))
227 }
228}
229
230#[must_use]
236pub fn short_names(keys: &[String]) -> Vec<String> {
237 let mut seen: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
238 for key in keys {
239 *seen.entry(basename(key)).or_default() += 1;
240 }
241 keys.iter()
242 .map(|k| match seen.get(basename(k)) {
243 Some(1) => sanitize(basename(k)),
244 _ => sanitize(k),
245 })
246 .collect()
247}
248
249#[must_use]
251pub fn cap_lines(text: &str, max: usize) -> String {
252 let mut out = String::with_capacity(text.len());
253 for line in text.lines().take(max) {
254 out.push_str(line);
255 out.push('\n');
256 }
257 out
258}
259
260pub const EMPTY_MAP: &str =
263 "mushroomdb map — empty store; run: mushroomdb ingest-git <db> <repo>\n";
264
265#[must_use]
272pub fn render_map(m: &RepoMap) -> String {
273 if m.files == 0 {
274 return EMPTY_MAP.to_string();
275 }
276 let mut out = String::new();
277
278 let sync = match &m.last_sync {
280 None => "not synced".to_string(),
281 Some(s) => {
282 let sha = sanitize(&s.sha);
283 let short: String = sha.chars().take(7).collect();
284 match s.age_secs {
285 Some(secs) => format!("synced {} ago at {short}", age(secs)),
286 None => format!("synced at {short}"),
287 }
288 }
289 };
290 let _ = writeln!(
291 out,
292 "mushroomdb map — {}, {}, {}, {} · {sync}{}",
293 plural(m.files, "file"),
294 plural(m.symbols, "symbol"),
295 plural(m.commits, "commit"),
296 plural(m.authors, "author"),
297 if m.truncated { " (truncated)" } else { "" }
298 );
299
300 if !m.communities.is_empty() {
301 out.push_str("clusters (co-change + imports)\n");
302 for (i, c) in m.communities.iter().enumerate() {
303 let samples = short_names(&c.samples);
304 let _ = writeln!(
305 out,
306 " {}. {} ({}, cohesion {:.2}){}{}",
307 i + 1,
308 sanitize(&c.name),
309 plural(c.size, "file"),
310 c.cohesion,
311 if samples.is_empty() { "" } else { " " },
312 samples.join(", ")
313 );
314 }
315 }
316
317 if !m.key_files.is_empty() {
318 out.push_str("key files (most depended-on)\n");
319 let items: Vec<String> = m
323 .key_files
324 .iter()
325 .map(|(k, s)| format!("{} {s:.2}", sanitize(k)))
326 .collect();
327 let _ = writeln!(out, " {}", items.join(SEP));
328 }
329
330 if !m.owners.is_empty() {
331 out.push_str("owners\n");
332 let items: Vec<String> = m
333 .owners
334 .iter()
335 .enumerate()
336 .map(|(i, (name, n))| match i {
337 0 => format!("{} {}", sanitize(name), plural(*n, "file")),
339 _ => format!("{} {n}", sanitize(name)),
340 })
341 .collect();
342 let _ = writeln!(out, " {}", items.join(SEP));
343 }
344
345 if !m.hot_files.is_empty() {
346 let _ = writeln!(out, "hot (last {} days)", m.hot_days);
347 let items: Vec<String> = m
348 .hot_files
349 .iter()
350 .map(|(k, n)| format!("{} {n}", sanitize(k)))
351 .collect();
352 let _ = writeln!(out, " {}", items.join(SEP));
353 }
354
355 if m.stale_concepts > 0 {
356 let (noun, verb) = if m.stale_concepts == 1 {
357 ("concept", "needs")
358 } else {
359 ("concepts", "need")
360 };
361 let _ = writeln!(
362 out,
363 "notes: {} {noun} {verb} re-learning (source changed)",
364 m.stale_concepts
365 );
366 }
367
368 if !m.questions.is_empty() {
369 let asks: Vec<String> = m.questions.iter().map(|q| sanitize(q)).collect();
370 let _ = writeln!(out, "ask me: {}", asks.join(SEP));
371 }
372
373 cap_lines(&out, MAX_MAP_LINES)
374}
375
376const MAX_SOURCE_PRINTED: usize = 40;
383const MAX_CANDIDATES: usize = 20;
387const MAX_IMPACT_FILES: usize = 5;
389const MAX_IMPACT_UNKNOWN: usize = 3;
399const MAX_WHY_LINKS: usize = 5;
401
402fn section(out: &mut String, name: &str, items: &[String]) {
404 if !items.is_empty() {
405 let _ = writeln!(out, "{name} {}", items.join(SEP));
406 }
407}
408
409fn commit_line(sha: &str, ts: i64, subject: &str) -> String {
411 let short: String = sanitize(sha).chars().take(7).collect();
412 format!("{short} {} {}", ymd(ts), sanitize(subject))
413}
414
415#[must_use]
418pub fn render_context(c: &ContextReport) -> String {
419 let mut out = String::new();
420 match &c.target {
421 Target::Unknown { target } if c.candidates.is_empty() => {
422 let _ = writeln!(out, "mushroomdb context — unknown: {}", sanitize(target));
423 return out;
424 }
425 Target::Unknown { target } => {
426 let _ = writeln!(
427 out,
428 "mushroomdb context — {} is ambiguous: {}",
429 sanitize(target),
430 plural(c.candidates.len(), "symbol")
431 );
432 for key in c.candidates.iter().take(MAX_CANDIDATES) {
433 let _ = writeln!(out, " {}", sanitize(key));
434 }
435 if c.candidates.len() > MAX_CANDIDATES {
436 let _ = writeln!(
437 out,
438 " … {} not shown",
439 plural(c.candidates.len() - MAX_CANDIDATES, "symbol")
440 );
441 }
442 return cap_lines(&out, MAX_CONTEXT_LINES);
443 }
444 Target::File { path } => {
445 let _ = writeln!(out, "mushroomdb context — file {}", sanitize(path));
446 }
447 Target::Symbol { key } => {
448 let _ = writeln!(
449 out,
450 "mushroomdb context — symbol {} in {}",
451 sanitize(key),
452 sanitize(&c.file)
453 );
454 }
455 }
456
457 if let Some(sig) = &c.signature {
458 let _ = writeln!(out, "signature {}", sanitize(sig));
459 }
460 if let Some(doc) = &c.doc {
461 let _ = writeln!(out, "doc {}", sanitize(doc));
462 }
463 let mut about: Vec<String> = Vec::new();
464 if let Some((first, last)) = c.lines {
465 about.push(format!("lines {first}-{last}"));
466 }
467 if let Some(owner) = &c.owner {
468 about.push(format!("owner {}", sanitize(owner)));
469 }
470 section(&mut out, "where", &about);
471
472 if let Some(source) = &c.source {
473 let first = c.lines.map_or(1, |(first, _)| first);
474 let total = source.lines().count();
475 let _ = writeln!(out, "source");
476 for (i, line) in source.lines().take(MAX_SOURCE_PRINTED).enumerate() {
477 let n = first as usize + i;
478 let _ = writeln!(out, " {n:>5} | {}", sanitize(line));
479 }
480 if total > MAX_SOURCE_PRINTED {
481 let _ = writeln!(
482 out,
483 " … {} more",
484 plural(total - MAX_SOURCE_PRINTED, "line")
485 );
486 }
487 }
488
489 let mut callers: Vec<String> = c
492 .callers
493 .iter()
494 .map(|s| {
495 let lines: Vec<String> = s
496 .lines
497 .iter()
498 .filter(|n| **n > 0)
499 .map(u32::to_string)
500 .collect();
501 let more = s.sites.saturating_sub(s.lines.len());
502 let mut item = match lines.is_empty() {
503 true => sanitize(&s.file),
504 false => format!("{}: {}", sanitize(&s.file), lines.join(", ")),
505 };
506 if more > 0 {
507 let _ = write!(item, " …(+{more})");
508 }
509 item
510 })
511 .collect();
512 if c.callers_not_shown > 0 {
513 callers.push(format!(
514 "… {} not shown",
515 plural(c.callers_not_shown, "file")
516 ));
517 }
518 section(&mut out, "callers", &callers);
519 let callees: Vec<String> = c
520 .callees
521 .iter()
522 .map(|(key, line)| match line {
523 0 => sanitize(key),
524 n => format!("{} line {n}", sanitize(key)),
525 })
526 .collect();
527 section(&mut out, "callees", &callees);
528 section(
529 &mut out,
530 "imports",
531 &c.imports.iter().map(|k| sanitize(k)).collect::<Vec<_>>(),
532 );
533 section(
534 &mut out,
535 "importers",
536 &c.importers.iter().map(|k| sanitize(k)).collect::<Vec<_>>(),
537 );
538 section(
539 &mut out,
540 "co-change",
541 &c.partners
542 .iter()
543 .map(|(k, s)| format!("{} {s:.2}", sanitize(k)))
544 .collect::<Vec<_>>(),
545 );
546 section(
547 &mut out,
548 "commits",
549 &c.recent_commits
550 .iter()
551 .map(|(sha, ts, subject)| commit_line(sha, *ts, subject))
552 .collect::<Vec<_>>(),
553 );
554 for (key, text) in &c.notes {
555 let _ = writeln!(out, "note {} {}", sanitize(key), sanitize(text));
556 }
557 for (key, name) in &c.concepts {
558 let _ = writeln!(out, "concept {} {}", sanitize(key), sanitize(name));
559 }
560 cap_lines(&out, MAX_CONTEXT_LINES)
561}
562
563fn partner_item(p: &Partner, with_score: bool) -> String {
566 let mut item = sanitize(&p.path);
567 match p.shared_commits {
571 Some(n) => {
572 let _ = write!(item, " ({})", plural(n, "shared commit"));
573 }
574 None if with_score => {
575 let _ = write!(item, " {:.2}", p.score);
576 }
577 None => {}
578 }
579 if p.modified {
580 item.push_str(" modified");
581 }
582 item
583}
584
585#[must_use]
587pub fn render_impact(r: &ImpactReport) -> String {
588 let mut out = String::new();
589 let _ = writeln!(
590 out,
591 "mushroomdb impact — {}",
592 plural(r.files.len(), "changed file")
593 );
594 for f in r.files.iter().take(MAX_IMPACT_FILES) {
595 render_file_impact(&mut out, f);
596 }
597 if r.files.len() > MAX_IMPACT_FILES {
598 let _ = writeln!(
599 out,
600 "… {} not shown",
601 plural(r.files.len() - MAX_IMPACT_FILES, "file")
602 );
603 }
604 for path in r.unknown.iter().take(MAX_IMPACT_UNKNOWN) {
605 let _ = writeln!(out, "unknown: {}", sanitize(path));
606 }
607 if r.unknown.len() > MAX_IMPACT_UNKNOWN {
608 let _ = writeln!(
609 out,
610 "…and {} more unknown",
611 r.unknown.len() - MAX_IMPACT_UNKNOWN
612 );
613 }
614 cap_lines(&out, MAX_TOOL_LINES)
615}
616
617fn render_file_impact(out: &mut String, f: &FileImpact) {
618 match &f.owner {
619 Some(owner) => {
620 let _ = writeln!(out, "{} ({})", sanitize(&f.path), sanitize(owner));
621 }
622 None => {
623 let _ = writeln!(out, "{}", sanitize(&f.path));
624 }
625 }
626 section(
627 out,
628 " partners ",
629 &f.partners
630 .iter()
631 .map(|p| partner_item(p, true))
632 .collect::<Vec<_>>(),
633 );
634 section(
635 out,
636 " importers",
637 &f.importers
638 .iter()
639 .map(|p| partner_item(p, false))
640 .collect::<Vec<_>>(),
641 );
642 section(
643 out,
644 " used by ",
645 &f.symbols_used_elsewhere
646 .iter()
647 .map(|(key, n)| format!("{} {}", sanitize(key), plural(*n, "caller")))
648 .collect::<Vec<_>>(),
649 );
650}
651
652#[must_use]
658pub fn render_owners(o: &OwnersReport) -> String {
659 let mut out = String::new();
660 let _ = writeln!(out, "mushroomdb owners — {}", sanitize(&o.path));
661 if let Some((name, key, share)) = &o.top {
662 let _ = writeln!(
663 out,
664 "top {} ({}) {share:.2} of the file's commits",
665 sanitize(name),
666 sanitize(key)
667 );
668 }
669 section(
670 &mut out,
671 "knows",
672 &o.knows
673 .iter()
674 .map(|(name, score)| format!("{} {score:.2}", sanitize(name)))
675 .collect::<Vec<_>>(),
676 );
677 if let Some((sha, ts, subject)) = &o.last_touch {
678 let _ = writeln!(out, "last touch {}", commit_line(sha, *ts, subject));
679 }
680 section(
681 &mut out,
682 "by quarter",
683 &o.by_quarter
684 .iter()
685 .map(|(q, name, n)| format!("{} {} {n}", sanitize(q), sanitize(name)))
686 .collect::<Vec<_>>(),
687 );
688 cap_lines(&out, MAX_TOOL_LINES)
689}
690
691#[must_use]
693pub fn render_why(w: &WhyReport) -> String {
694 let mut out = String::new();
695 let _ = writeln!(
696 out,
697 "mushroomdb why — {} ↔ {}",
698 sanitize(&w.a),
699 sanitize(&w.b)
700 );
701 for key in &w.unknown {
702 let _ = writeln!(out, "unknown: {}", sanitize(key));
703 }
704 if !w.unknown.is_empty() {
705 return cap_lines(&out, MAX_TOOL_LINES);
706 }
707 let links = pair_up(&w.links);
708 for (link, both_ways) in links.iter().take(MAX_WHY_LINKS) {
709 render_link(&mut out, link, *both_ways);
710 }
711 if links.len() > MAX_WHY_LINKS {
712 let _ = writeln!(
713 out,
714 "… {} not shown",
715 plural(links.len() - MAX_WHY_LINKS, "link")
716 );
717 }
718 if let Some(shared) = &w.shared {
719 let _ = writeln!(
720 out,
721 "co-change {}, below the co_changed rule's similarity floor so no edge was written",
722 plural(shared.count, "shared commit")
723 );
724 for line in &shared.evidence {
725 let _ = writeln!(out, " {}", sanitize(line));
726 }
727 }
728 if !w.path.is_empty() {
729 let mut walk = sanitize(&w.a);
730 for (edge_type, node) in &w.path {
731 let _ = write!(walk, " -[{}]-> {}", sanitize(edge_type), sanitize(node));
732 }
733 let _ = writeln!(out, "path {walk}");
734 }
735 if w.links.is_empty() && w.path.is_empty() && w.shared.is_none() {
736 let _ = writeln!(out, "no link");
737 }
738 cap_lines(&out, MAX_TOOL_LINES)
739}
740
741fn pair_up(links: &[WhyLink]) -> Vec<(&WhyLink, bool)> {
754 let mut out: Vec<(&WhyLink, bool)> = Vec::new();
755 let mut folded: Vec<bool> = vec![false; links.len()];
756 for (i, link) in links.iter().enumerate() {
757 if folded[i] {
758 continue;
759 }
760 let mut both_ways = false;
761 for (j, other) in links.iter().enumerate().skip(i + 1) {
762 if !folded[j]
763 && other.rule == link.rule
764 && other.edge_type == link.edge_type
765 && other.direction != link.direction
766 && other.score == link.score
767 && other.evidence == link.evidence
768 {
769 folded[j] = true;
770 both_ways = true;
771 break;
772 }
773 }
774 out.push((link, both_ways));
775 }
776 out
777}
778
779fn render_link(out: &mut String, link: &WhyLink, both_ways: bool) {
780 let mut head = format!(
781 "{} {} {}",
782 sanitize(&link.edge_type),
783 if both_ways {
784 "a↔b".to_string()
785 } else {
786 sanitize(&link.direction)
787 },
788 sanitize(&link.rule)
789 );
790 if let Some(score) = link.score {
791 let _ = write!(head, " {score:.2}");
792 }
793 if let Some(via) = &link.via {
794 let _ = write!(head, " via {}", sanitize(via));
795 }
796 let _ = writeln!(out, "{head}");
797 for line in &link.evidence {
798 let _ = writeln!(out, " {}", sanitize(line));
799 }
800}
801
802#[cfg(test)]
803mod tests {
804 use super::*;
805
806 #[test]
807 fn a_timestamp_reads_as_a_utc_date_and_a_quarter() {
808 for (ts, date, quarter) in [
811 (0_i64, "1970-01-01", "1970Q1"),
812 (1_582_934_400, "2020-02-29", "2020Q1"),
813 (951_782_400, "2000-02-29", "2000Q1"),
814 (1_600_000_000, "2020-09-13", "2020Q3"),
815 (1_609_459_199, "2020-12-31", "2020Q4"),
816 (1_609_459_200, "2021-01-01", "2021Q1"),
817 (-1, "1969-12-31", "1969Q4"),
818 ] {
819 assert_eq!(ymd(ts), date, "{ts}");
820 assert_eq!(quarter_label(quarter_index(ts)), quarter, "{ts}");
821 }
822 }
823
824 #[test]
825 fn quarter_indices_are_a_count_a_window_can_be_measured_in() {
826 let q3 = quarter_index(1_600_000_000); assert_eq!(quarter_label(q3 - 3), "2019Q4");
828 assert_eq!(quarter_label(q3 + 1), "2020Q4");
829 assert_eq!(quarter_label(q3 + 2), "2021Q1");
830 }
831
832 #[test]
833 fn sanitize_replaces_every_control_character_one_for_one() {
834 let forged = "Ada\nmushroomdb map\t— 9 files\u{7f}\u{1b}[31m";
835 let clean = sanitize(forged);
836 assert_eq!(clean.len(), forged.len(), "one byte in, one byte out");
837 assert!(!clean.contains('\n') && !clean.contains('\t') && !clean.contains('\u{1b}'));
838 assert_eq!(clean, "Ada mushroomdb map — 9 files [31m");
839 }
840
841 #[test]
842 fn thousands_groups_from_the_right() {
843 for (n, want) in [
844 (0, "0"),
845 (7, "7"),
846 (999, "999"),
847 (1_000, "1,000"),
848 (1_204, "1,204"),
849 (999_999, "999,999"),
850 (1_830_412, "1,830,412"),
851 ] {
852 assert_eq!(thousands(n), want, "{n}");
853 }
854 }
855
856 #[test]
857 fn plural_says_one_file_and_two_files() {
858 assert_eq!(plural(1, "file"), "1 file");
859 assert_eq!(plural(0, "file"), "0 files");
860 assert_eq!(plural(1_204, "commit"), "1,204 commits");
861 }
862
863 #[test]
864 fn age_picks_one_coarse_unit() {
865 for (secs, want) in [
866 (-5, "0s"),
867 (0, "0s"),
868 (59, "59s"),
869 (60, "1m"),
870 (720, "12m"),
871 (3_600, "1h"),
872 (86_399, "23h"),
873 (86_400, "1d"),
874 (20 * 86_400, "20d"),
875 ] {
876 assert_eq!(age(secs), want, "{secs}");
877 }
878 }
879
880 #[test]
881 fn paths_split_into_a_base_and_its_directories() {
882 assert_eq!(basename("src/core/db.rs"), "db.rs");
883 assert_eq!(basename("README.md"), "README.md");
884 assert_eq!(dir_components("src/core/db.rs"), vec!["src", "core"]);
885 assert!(dir_components("README.md").is_empty());
886 }
887
888 #[test]
889 fn a_cluster_is_named_by_the_directory_its_files_share() {
890 let same = vec![
892 "crates/core-api/src/db.rs".to_string(),
893 "crates/core-api/src/algo.rs".to_string(),
894 ];
895 assert_eq!(cluster_name(&same), "crates/core-api/src");
896 let partial = vec![
899 "crates/core-api/src/db.rs".to_string(),
900 "crates/core-api/tests/algo.rs".to_string(),
901 ];
902 assert_eq!(cluster_name(&partial), "crates/core-api src, tests");
903 }
904
905 #[test]
906 fn files_sharing_no_directory_are_named_by_their_commonest_segments() {
907 let mixed = vec![
908 "docs/site/algorithms.md".to_string(),
909 "docs/site/install.md".to_string(),
910 "site/index.html".to_string(),
911 "README.md".to_string(),
912 ];
913 assert_eq!(cluster_name(&mixed), "<mixed> site, docs");
916 assert_eq!(cluster_name(&["a.rs".to_string()]), "<mixed> a.rs");
917 assert_eq!(cluster_name(&[]), "<mixed>");
918 }
919
920 #[test]
921 fn a_segment_counts_once_per_key_however_often_it_repeats() {
922 let keys = vec!["a/a/a/a.rs".to_string(), "b/x.rs".to_string()];
923 assert_eq!(top_tokens(&keys, "", 1, true), vec!["a".to_string()]);
924 assert_eq!(top_tokens(&keys, "", 1, false), vec!["a".to_string()]);
926 }
927
928 #[test]
929 fn short_names_keep_the_path_only_where_a_filename_repeats() {
930 let keys = vec![
931 "src/net/mod.rs".to_string(),
932 "src/io/mod.rs".to_string(),
933 "src/db.rs".to_string(),
934 ];
935 assert_eq!(
936 short_names(&keys),
937 vec!["src/net/mod.rs", "src/io/mod.rs", "db.rs"]
938 );
939 }
940
941 #[test]
942 fn cap_lines_keeps_the_first_lines_and_a_trailing_newline() {
943 assert_eq!(cap_lines("a\nb\nc\n", 2), "a\nb\n");
944 assert_eq!(cap_lines("a\nb", 9), "a\nb\n");
945 assert_eq!(cap_lines("", 9), "");
946 }
947}