1use crate::query;
26
27const HEURISTIC_SUFFIX: &str = " (heuristic)";
32
33const INFRA_SUFFIX: &str = " (infra)";
37
38fn heuristic_suffix(heuristic: bool) -> &'static str {
43 if heuristic {
44 HEURISTIC_SUFFIX
45 } else {
46 ""
47 }
48}
49
50fn source_suffix(source: &str) -> String {
54 if source.is_empty() {
55 String::new()
56 } else {
57 format!(" {source}")
58 }
59}
60
61pub fn seed_kind_str(kind: query::SeedKind) -> &'static str {
64 match kind {
65 query::SeedKind::File => "file",
66 query::SeedKind::Symbol => "symbol",
67 }
68}
69
70fn rle(values: &[String]) -> Vec<String> {
75 let mut out = Vec::new();
76 let mut i = 0;
77 while i < values.len() {
78 let mut j = i + 1;
79 while j < values.len() && values[j] == values[i] {
80 j += 1;
81 }
82 if j - i > 1 {
83 out.push(format!("{}x{}", values[i], j - i));
84 } else {
85 out.push(values[i].clone());
86 }
87 i = j;
88 }
89 out
90}
91
92fn group_by_file<R>(
105 rows: &[R],
106 file_of: fn(&R) -> &str,
107 line_fmt: impl Fn(&R) -> String,
108) -> Vec<String> {
109 let mut out = Vec::new();
110 let mut i = 0;
111 while i < rows.len() {
112 let mut j = i + 1;
113 while j < rows.len() && file_of(&rows[j]) == file_of(&rows[i]) {
114 j += 1;
115 }
116 let lines: Vec<String> = rows[i..j].iter().map(|r| line_fmt(r)).collect();
117 out.push(format!("{}:{}", file_of(&rows[i]), rle(&lines).join(",")));
118 i = j;
119 }
120 out
121}
122
123fn ref_kind_block<R>(
127 out: &mut Vec<String>,
128 label: &str,
129 table: Option<&query::Table<R>>,
130 row_fmt: impl Fn(&R) -> String,
131) {
132 let Some(t) = table else { return };
133 let dropped_note = if t.dropped != 0 {
134 format!(", {} dropped", t.dropped)
135 } else {
136 String::new()
137 };
138 out.push(format!(" {label} ({}{dropped_note}):", t.total));
139 for r in &t.rows {
140 out.push(format!(" {}", row_fmt(r)));
141 }
142}
143
144fn compact_block<R>(
147 out: &mut Vec<String>,
148 label: &str,
149 table: Option<&query::Table<R>>,
150 file_of: fn(&R) -> &str,
151 line_fmt: impl Fn(&R) -> String,
152) {
153 let Some(t) = table else { return };
154 if t.total == 0 {
155 return;
156 }
157 let dropped_note = if t.dropped != 0 {
158 format!(", {} dropped", t.dropped)
159 } else {
160 String::new()
161 };
162 out.push(format!("{label} ({}{dropped_note}):", t.total));
163 for line in group_by_file(&t.rows, file_of, line_fmt) {
164 out.push(format!(" {line}"));
165 }
166}
167
168fn member_refs_line(m: &query::MemberRefs) -> String {
175 let named = m
176 .members
177 .iter()
178 .map(|e| format!("{} {}", e.name, e.count))
179 .collect::<Vec<_>>()
180 .join(", ");
181 let more = if m.dropped != 0 {
182 format!(" +{} more", m.dropped)
183 } else {
184 String::new()
185 };
186 format!(
187 "member refs: {} across {} member(s): {named}{more}",
188 m.total, m.member_count
189 )
190}
191
192pub fn render_refs_text(model: &query::RefsModel) -> String {
194 let mut out: Vec<String> = Vec::new();
195 out.push(format!("{} ({})", model.id, model.kind));
196 out.push(format!(
197 "def: {}",
198 model
199 .sites
200 .iter()
201 .map(|s| format!("{}:{}", s.file, s.line))
202 .collect::<Vec<_>>()
203 .join(" ")
204 ));
205
206 out.push("inbound:".to_string());
207 ref_kind_block(
208 &mut out,
209 "inherits",
210 Some(&model.inbound.inherits),
211 |r: &query::InboundRow| {
212 format!(
213 "{}:{} inherits{}{}",
214 r.file,
215 r.line,
216 heuristic_suffix(r.heuristic),
217 source_suffix(&r.source)
218 )
219 },
220 );
221 ref_kind_block(
222 &mut out,
223 "uses-type",
224 Some(&model.inbound.uses_type),
225 |r: &query::InboundRow| {
226 format!(
227 "{}:{} uses-type{}{}",
228 r.file,
229 r.line,
230 heuristic_suffix(r.heuristic),
231 source_suffix(&r.source)
232 )
233 },
234 );
235 ref_kind_block(
236 &mut out,
237 "uses-member",
238 Some(&model.inbound.uses_member),
239 |r: &query::InboundRow| {
240 format!(
241 "{}:{} uses-member{}{}",
242 r.file,
243 r.line,
244 heuristic_suffix(r.heuristic),
245 source_suffix(&r.source)
246 )
247 },
248 );
249 let inbound_dropped = model.inbound.inherits.dropped
255 + model.inbound.uses_type.dropped
256 + model.inbound.uses_member.dropped;
257 if inbound_dropped != 0 {
258 out.push(format!(" +{inbound_dropped} more"));
259 }
260 if let Some(m) = &model.member_refs {
261 out.push(member_refs_line(m));
262 }
263
264 if let Some(ob) = &model.outbound {
265 out.push("outbound:".to_string());
266 ref_kind_block(
267 &mut out,
268 "inherits",
269 Some(&ob.inherits),
270 |r: &query::OutboundRow| {
271 format!(
272 "{}:{} inherits -> {}{}{}",
273 r.file,
274 r.line,
275 r.to_file,
276 heuristic_suffix(r.heuristic),
277 source_suffix(&r.source)
278 )
279 },
280 );
281 ref_kind_block(
282 &mut out,
283 "uses-type",
284 Some(&ob.uses_type),
285 |r: &query::OutboundRow| {
286 format!(
287 "{}:{} uses-type -> {}{}{}",
288 r.file,
289 r.line,
290 r.to_file,
291 heuristic_suffix(r.heuristic),
292 source_suffix(&r.source)
293 )
294 },
295 );
296 ref_kind_block(
297 &mut out,
298 "uses-member",
299 Some(&ob.uses_member),
300 |r: &query::OutboundRow| {
301 format!(
302 "{}:{} uses-member -> {}{}{}",
303 r.file,
304 r.line,
305 r.to_file,
306 heuristic_suffix(r.heuristic),
307 source_suffix(&r.source)
308 )
309 },
310 );
311 ref_kind_block(
312 &mut out,
313 "imports",
314 Some(&ob.imports),
315 |r: &query::ImportRow| {
316 format!(
317 "{}:{} imports -> {}{}",
318 r.file,
319 r.line,
320 r.target,
321 source_suffix(&r.source)
322 )
323 },
324 );
325 let outbound_dropped = ob.inherits.dropped
331 + ob.uses_type.dropped
332 + ob.uses_member.dropped
333 + ob.imports.dropped;
334 if outbound_dropped != 0 {
335 out.push(format!(" +{outbound_dropped} more, use --all"));
336 }
337 }
338
339 let amb_in = &model.ambiguous.inbound;
340 let amb_out = &model.ambiguous.outbound;
341 if amb_in.total != 0 || amb_out.total != 0 {
342 out.push("ambiguous:".to_string());
343 let amb_row = |r: &query::AmbiguousRow| {
344 format!(
345 "{}:{} {} raw=\"{}\" candidates={}",
346 r.file, r.line, r.origin, r.raw, r.candidate_count
347 )
348 };
349 if amb_in.total != 0 {
350 ref_kind_block(&mut out, "inbound", Some(amb_in), amb_row);
351 }
352 if amb_out.total != 0 {
353 ref_kind_block(&mut out, "outbound", Some(amb_out), amb_row);
354 }
355 }
356
357 if model.manifest_gap != 0 {
358 out.push(format!(
359 "manifest gap: {} graph file(s) not in manifest",
360 model.manifest_gap
361 ));
362 }
363 out.join("\n")
364}
365
366pub fn render_impact_text(query: &str, model: &query::ImpactModel) -> String {
368 let mut out: Vec<String> = Vec::new();
369 let joined = model.seed_files.join(", ");
370 let seeds = if joined.is_empty() {
371 "-".to_string()
372 } else {
373 joined
374 };
375 out.push(format!(
376 "impact: {query} ({}, seed files: {seeds}) hops<={}",
377 seed_kind_str(model.kind),
378 model.hops
379 ));
380 let heuristic_note = if model.heuristic_affected != 0 {
385 format!(" (+{} heuristic)", model.heuristic_affected)
386 } else {
387 String::new()
388 };
389 let tests_note = if model.tests_affected != 0 {
395 format!(" tests={}", model.tests_affected)
396 } else {
397 String::new()
398 };
399 out.push(format!(
400 "affected files: {}{heuristic_note} shown: {} dropped: {}{tests_note}",
401 model.total_affected,
402 model.rows.len(),
403 model.dropped
404 ));
405 out.push("file hops via top-symbols".to_string());
406 for r in &model.rows {
407 let mut syms = r.top_symbols.join(", ");
408 if r.top_symbols_more != 0 {
409 syms.push_str(&format!(" +{}", r.top_symbols_more));
410 }
411 let via = if r.heuristic {
416 r.heuristic_count.to_string()
417 } else if r.ambiguous_count != 0 {
418 format!("{}(+{}amb)", r.via_count, r.ambiguous_count)
419 } else {
420 r.via_count.to_string()
421 };
422 let iface_via = if r.iface_via.is_empty() {
426 String::new()
427 } else {
428 format!(" via {}", r.iface_via.join(", "))
429 };
430 let class_suffix = if r.infra { INFRA_SUFFIX } else { "" };
434 out.push(format!(
435 "{} {} {via} {syms}{iface_via}{}{class_suffix}",
436 r.file,
437 r.hop,
438 heuristic_suffix(r.heuristic)
439 ));
440 }
441 if !model.braked.is_empty() {
446 let list = model
447 .braked
448 .iter()
449 .map(|b| format!("{} (fan-in {})", b.iface, b.fanin))
450 .collect::<Vec<_>>()
451 .join(", ");
452 out.push(format!("braked: {list} — raise --iface-max-fanin to widen"));
453 }
454 if !model.braked_files.is_empty() {
458 let list = model
459 .braked_files
460 .iter()
461 .map(|b| format!("{} (in-degree {})", b.file, b.indegree))
462 .collect::<Vec<_>>()
463 .join(", ");
464 out.push(format!(
465 "braked: {list} — raise --hub-max-indegree to widen"
466 ));
467 }
468 if model.manifest_gap != 0 {
469 out.push(format!(
470 "manifest gap: {} graph file(s) not in manifest",
471 model.manifest_gap
472 ));
473 }
474 out.join("\n")
475}
476
477pub fn render_refs_compact(model: &query::RefsModel) -> String {
479 let mut out: Vec<String> = Vec::new();
480 out.push(format!("{} ({})", model.id, model.kind));
481 out.push(format!(
482 "def: {}",
483 model
484 .sites
485 .iter()
486 .map(|s| format!("{}:{}", s.file, s.line))
487 .collect::<Vec<_>>()
488 .join(" ")
489 ));
490
491 let file_of_ib: fn(&query::InboundRow) -> &str = |r| r.file.as_str();
509 let line_ib = |r: &query::InboundRow| {
510 if r.heuristic {
511 format!("{}h", r.line)
512 } else {
513 r.line.to_string()
514 }
515 };
516 compact_block(
517 &mut out,
518 "in:inherits",
519 Some(&model.inbound.inherits),
520 file_of_ib,
521 line_ib,
522 );
523 compact_block(
524 &mut out,
525 "in:uses-type",
526 Some(&model.inbound.uses_type),
527 file_of_ib,
528 line_ib,
529 );
530 compact_block(
531 &mut out,
532 "in:uses-member",
533 Some(&model.inbound.uses_member),
534 file_of_ib,
535 line_ib,
536 );
537
538 if let Some(ob) = &model.outbound {
539 let file_of_ob: fn(&query::OutboundRow) -> &str = |r| r.file.as_str();
540 let line_ob = |r: &query::OutboundRow| {
541 if r.heuristic {
542 format!("{}h", r.line)
543 } else {
544 r.line.to_string()
545 }
546 };
547 compact_block(
548 &mut out,
549 "out:inherits",
550 Some(&ob.inherits),
551 file_of_ob,
552 line_ob,
553 );
554 compact_block(
555 &mut out,
556 "out:uses-type",
557 Some(&ob.uses_type),
558 file_of_ob,
559 line_ob,
560 );
561 compact_block(
562 &mut out,
563 "out:uses-member",
564 Some(&ob.uses_member),
565 file_of_ob,
566 line_ob,
567 );
568
569 let file_of_imp: fn(&query::ImportRow) -> &str = |r| r.file.as_str();
570 let line_imp = |r: &query::ImportRow| r.line.to_string();
571 compact_block(
572 &mut out,
573 "out:imports",
574 Some(&ob.imports),
575 file_of_imp,
576 line_imp,
577 );
578 }
579
580 let amb_in = &model.ambiguous.inbound;
581 let amb_out = &model.ambiguous.outbound;
582 let file_of_amb: fn(&query::AmbiguousRow) -> &str = |r| r.file.as_str();
583 let line_amb =
584 |r: &query::AmbiguousRow| format!("{}(candidates={})", r.line, r.candidate_count);
585 compact_block(&mut out, "amb:in", Some(amb_in), file_of_amb, line_amb);
586 compact_block(&mut out, "amb:out", Some(amb_out), file_of_amb, line_amb);
587
588 let ob = model.outbound.as_ref();
593 let ob_sum = |f: fn(&query::OutboundTables) -> usize| ob.map_or(0, f);
594 let edges = model.inbound.inherits.total
595 + model.inbound.uses_type.total
596 + model.inbound.uses_member.total
597 + ob_sum(|o| o.inherits.total + o.uses_type.total + o.uses_member.total + o.imports.total);
598 let shown = model.inbound.inherits.rows.len()
599 + model.inbound.uses_type.rows.len()
600 + model.inbound.uses_member.rows.len()
601 + ob_sum(|o| {
602 o.inherits.rows.len()
603 + o.uses_type.rows.len()
604 + o.uses_member.rows.len()
605 + o.imports.rows.len()
606 });
607 let dropped = model.inbound.inherits.dropped
608 + model.inbound.uses_type.dropped
609 + model.inbound.uses_member.dropped
610 + ob_sum(|o| {
611 o.inherits.dropped + o.uses_type.dropped + o.uses_member.dropped + o.imports.dropped
612 });
613 let ambiguous = amb_in.total + amb_out.total;
614 let gap = if model.manifest_gap != 0 {
615 format!(" gap={}", model.manifest_gap)
616 } else {
617 String::new()
618 };
619 if let Some(m) = &model.member_refs {
623 let named = m
624 .members
625 .iter()
626 .map(|e| format!("{}={}", e.name, e.count))
627 .collect::<Vec<_>>()
628 .join(",");
629 let more = if m.dropped != 0 {
630 format!(",+{}", m.dropped)
631 } else {
632 String::new()
633 };
634 out.push(format!("mem: {named}{more}"));
635 }
636 out.push(format!(
637 "summary: edges={edges} shown={shown} dropped={dropped} ambiguous={ambiguous}{gap}"
638 ));
639 out.join("\n")
640}
641
642fn read_def_line(model: &query::ReadModel) -> String {
648 let sites = &model.refs.sites;
649 if sites.is_empty() {
650 return "def: -".to_string();
651 }
652 let rendered: Vec<String> = sites
653 .iter()
654 .enumerate()
655 .map(|(i, s)| match (i, &model.span) {
656 (0, Some(sp)) => format!("{}:{}-{}", sp.file, sp.start_line, sp.end_line),
657 _ => format!("{}:{}", s.file, s.line),
658 })
659 .collect();
660 format!("def: {}", rendered.join(" "))
661}
662
663pub fn render_read_text(model: &query::ReadModel) -> String {
668 let rendered = render_refs_text(&model.refs);
669 let mut out: Vec<String> = rendered.split('\n').map(str::to_string).collect();
670 if out.len() > 1 {
671 out[1] = read_def_line(model);
672 }
673 if let Some(sp) = &model.span {
674 out.splice(2..2, sp.source.split('\n').map(str::to_string));
675 }
676 out.join("\n")
677}
678
679pub fn render_read_compact(model: &query::ReadModel) -> String {
684 let rendered = render_refs_compact(&model.refs);
685 let mut out: Vec<String> = rendered.split('\n').map(str::to_string).collect();
686 if out.len() > 1 {
687 out[1] = read_def_line(model);
688 }
689 out.join("\n")
690}
691
692pub fn render_impact_compact(query: &str, model: &query::ImpactModel) -> String {
694 let mut out: Vec<String> = Vec::new();
695 let joined = model.seed_files.join(", ");
696 let seeds = if joined.is_empty() {
697 "-".to_string()
698 } else {
699 joined
700 };
701 out.push(format!(
702 "impact: {query} ({}, seed: {seeds}) hops<={}",
703 seed_kind_str(model.kind),
704 model.hops
705 ));
706
707 let mut by_hop: std::collections::HashMap<u32, Vec<&query::ImpactRow>> =
713 std::collections::HashMap::new();
714 for r in &model.rows {
715 by_hop.entry(r.hop).or_default().push(r);
716 }
717 let mut hops: Vec<u32> = by_hop.keys().copied().collect();
718 hops.sort_unstable();
719 for hop in hops {
720 let rows = &by_hop[&hop];
721 out.push(format!("hop {hop} ({}):", rows.len()));
722 let lines: Vec<String> = rows
723 .iter()
724 .map(|r| {
725 if r.heuristic {
730 return format!("{} via={}h", r.file, r.heuristic_count);
731 }
732 let via = if r.ambiguous_count != 0 {
733 format!("{}(+{}amb)", r.via_count, r.ambiguous_count)
734 } else {
735 r.via_count.to_string()
736 };
737 let iface_suffix = if r.iface_via.is_empty() {
739 String::new()
740 } else {
741 format!(" iface={}", r.iface_via.join(","))
742 };
743 let class_suffix = if r.infra { " class=infra" } else { "" };
745 format!("{} via={via}{iface_suffix}{class_suffix}", r.file)
746 })
747 .collect();
748 for line in rle(&lines) {
749 out.push(format!(" {line}"));
750 }
751 }
752
753 let ambiguous: u32 = model.rows.iter().map(|r| r.ambiguous_count).sum();
754 let gap = if model.manifest_gap != 0 {
755 format!(" gap={}", model.manifest_gap)
756 } else {
757 String::new()
758 };
759 let heur = if model.heuristic_affected != 0 {
763 format!(" heuristic={}", model.heuristic_affected)
764 } else {
765 String::new()
766 };
767 let tests = if model.tests_affected != 0 {
768 format!(" tests={}", model.tests_affected)
769 } else {
770 String::new()
771 };
772 let braked = if model.braked.is_empty() && model.braked_files.is_empty() {
777 String::new()
778 } else {
779 let terms: Vec<String> = model
780 .braked
781 .iter()
782 .map(|b| format!("{}:{}", b.iface, b.fanin))
783 .chain(
784 model
785 .braked_files
786 .iter()
787 .map(|b| format!("{}:{}", b.file, b.indegree)),
788 )
789 .collect();
790 format!(" braked={}", terms.join(","))
791 };
792 out.push(format!(
793 "summary: affected={} shown={} dropped={} ambiguous={ambiguous}{heur}{tests}{gap}{braked}",
794 model.total_affected,
795 model.rows.len(),
796 model.dropped
797 ));
798 out.join("\n")
799}
800
801pub fn render_tests_text(model: &query::TestsModel) -> String {
809 let mut out: Vec<String> = vec![format!("tests for {}", model.symbol)];
810 if model.rows.is_empty() {
811 out.push("no test references found".to_string());
812 return out.join("\n");
813 }
814 out.push(format!(
815 "covered by {} test file(s), {} reference(s)",
816 model.test_file_count, model.ref_count
817 ));
818 out.push(String::new());
819 for r in &model.rows {
820 out.push(format!("{}{}", r.file, heuristic_suffix(r.heuristic)));
821 let lines = r
822 .lines
823 .iter()
824 .map(|l| l.to_string())
825 .collect::<Vec<_>>()
826 .join(", ");
827 for def_id in &r.test_defs {
828 out.push(format!(" {def_id} lines: {lines}"));
829 }
830 }
831 out.join("\n")
832}
833
834pub fn render_tests_compact(model: &query::TestsModel) -> String {
839 let heur = if model.heuristic_file_count != 0 {
840 format!(" heuristic={}", model.heuristic_file_count)
841 } else {
842 String::new()
843 };
844 let mut out: Vec<String> = vec![format!(
845 "tests {} files={} refs={}{heur}",
846 model.symbol, model.test_file_count, model.ref_count
847 )];
848 for r in &model.rows {
849 let lines = r
850 .lines
851 .iter()
852 .map(|l| {
853 if r.heuristic {
854 format!("{l}h")
855 } else {
856 l.to_string()
857 }
858 })
859 .collect::<Vec<_>>()
860 .join(",");
861 out.push(format!("{} {lines}", r.file));
862 }
863 out.join("\n")
864}
865
866#[cfg(test)]
867mod tests {
868 use super::*;
869 use crate::query::{
870 AmbiguousRow, AmbiguousTables, DefSite, ImpactModel, ImpactRow, ImportRow, InboundRow,
871 InboundTables, OutboundRow, OutboundTables, RefsModel, SeedKind, Table, TestRow,
872 TestsModel,
873 };
874
875 fn table<R>(rows: Vec<R>, dropped: usize) -> Table<R> {
876 Table {
877 total: rows.len() + dropped,
878 dropped,
879 rows,
880 }
881 }
882
883 fn empty_inbound() -> InboundTables {
884 InboundTables {
885 inherits: table(vec![], 0),
886 uses_type: table(vec![], 0),
887 uses_member: table(vec![], 0),
888 }
889 }
890 fn empty_outbound() -> OutboundTables {
891 OutboundTables {
892 inherits: table(vec![], 0),
893 uses_type: table(vec![], 0),
894 uses_member: table(vec![], 0),
895 imports: table(vec![], 0),
896 }
897 }
898 fn empty_ambiguous() -> AmbiguousTables {
899 AmbiguousTables {
900 inbound: table(vec![], 0),
901 outbound: table(vec![], 0),
902 }
903 }
904
905 fn refs_model(
906 inbound: InboundTables,
907 outbound: OutboundTables,
908 ambiguous: AmbiguousTables,
909 manifest_gap: usize,
910 ) -> RefsModel {
911 RefsModel {
912 query: "IFoo".to_string(),
913 id: "App.IFoo".to_string(),
914 kind: "interface".to_string(),
915 sites: vec![DefSite {
916 file: "src/IFoo.cs".to_string(),
917 line: 3,
918 }],
919 inbound,
920 outbound: Some(outbound),
921 ambiguous,
922 manifest_gap,
923 member_refs: None,
924 }
925 }
926
927 #[test]
930 fn refs_compact_one_header_per_kind_empty_kinds_print_nothing() {
931 let mut inbound = empty_inbound();
932 inbound.inherits = table(
933 vec![InboundRow {
934 file: "src/Foo.cs".into(),
935 line: 5,
936 heuristic: false,
937 source: String::new(),
938 }],
939 0,
940 );
941 let mut outbound = empty_outbound();
942 outbound.uses_type = table(
943 vec![OutboundRow {
944 file: "src/IFoo.cs".into(),
945 line: 3,
946 to_file: "src/Bar.cs".into(),
947 to: "App.Bar".into(),
948 heuristic: false,
949 source: String::new(),
950 }],
951 0,
952 );
953 let model = refs_model(inbound, outbound, empty_ambiguous(), 0);
954 let out = render_refs_compact(&model);
955 assert!(out.contains("in:inherits (1):\n src/Foo.cs:5"));
956 assert!(out.contains("out:uses-type (1):\n src/IFoo.cs:3"));
957 assert!(
958 !out.contains("in:uses-type"),
959 "a kind with zero rows must not print a header"
960 );
961 assert!(!out.contains("out:inherits"));
962 assert!(!out.contains("out:imports"));
963 assert!(
964 !out.contains("Bar.cs"),
965 "outbound target file is dropped in compact mode -- only path:line survives"
966 );
967 }
968
969 #[test]
970 fn refs_compact_same_file_line_collapses_to_nxn() {
971 let mut outbound = empty_outbound();
972 outbound.inherits = table(
973 vec![
974 OutboundRow {
975 file: "src/Widget.cs".into(),
976 line: 5,
977 to_file: "src/IWidget.cs".into(),
978 to: "App.IWidget".into(),
979 heuristic: false,
980 source: String::new(),
981 },
982 OutboundRow {
983 file: "src/Widget.cs".into(),
984 line: 5,
985 to_file: "src/IGadget.cs".into(),
986 to: "App.IGadget".into(),
987 heuristic: false,
988 source: String::new(),
989 },
990 ],
991 0,
992 );
993 let model = refs_model(empty_inbound(), outbound, empty_ambiguous(), 0);
994 let out = render_refs_compact(&model);
995 assert!(out.contains("out:inherits (2):\n src/Widget.cs:5x2"));
996 }
997
998 #[test]
999 fn refs_compact_groups_multiple_lines_under_one_file_mention() {
1000 let mut inbound = empty_inbound();
1001 inbound.uses_type = table(
1002 vec![
1003 InboundRow {
1004 file: "src/Consumers/Big.cs".into(),
1005 line: 12,
1006 heuristic: false,
1007 source: String::new(),
1008 },
1009 InboundRow {
1010 file: "src/Consumers/Big.cs".into(),
1011 line: 40,
1012 heuristic: false,
1013 source: String::new(),
1014 },
1015 InboundRow {
1016 file: "src/Consumers/Big.cs".into(),
1017 line: 40,
1018 heuristic: false,
1019 source: String::new(),
1020 },
1021 InboundRow {
1022 file: "src/Consumers/Small.cs".into(),
1023 line: 3,
1024 heuristic: false,
1025 source: String::new(),
1026 },
1027 ],
1028 0,
1029 );
1030 let model = refs_model(inbound, empty_outbound(), empty_ambiguous(), 0);
1031 let out = render_refs_compact(&model);
1032 assert!(out.contains(
1033 "in:uses-type (4):\n src/Consumers/Big.cs:12,40x2\n src/Consumers/Small.cs:3"
1034 ));
1035 assert_eq!(
1036 out.matches("src/Consumers/Big.cs").count(),
1037 1,
1038 "the repeated file path must appear exactly once"
1039 );
1040 }
1041
1042 #[test]
1043 fn refs_compact_summary_line_folds_all_counts() {
1044 let mut inbound = empty_inbound();
1045 inbound.inherits = table(
1046 vec![
1047 InboundRow {
1048 file: "src/A.cs".into(),
1049 line: 1,
1050 heuristic: false,
1051 source: String::new(),
1052 },
1053 InboundRow {
1054 file: "src/B.cs".into(),
1055 line: 2,
1056 heuristic: false,
1057 source: String::new(),
1058 },
1059 ],
1060 3,
1061 );
1062 let mut ambiguous = empty_ambiguous();
1063 ambiguous.inbound = table(
1064 vec![AmbiguousRow {
1065 file: "src/C.cs".into(),
1066 line: 9,
1067 origin: "uses-type".into(),
1068 raw: "Config".into(),
1069 candidate_count: 2,
1070 }],
1071 0,
1072 );
1073 let model = refs_model(inbound, empty_outbound(), ambiguous, 4);
1074 let out = render_refs_compact(&model);
1075 let last_line = out.lines().last().unwrap();
1076 assert_eq!(
1077 last_line,
1078 "summary: edges=5 shown=2 dropped=3 ambiguous=1 gap=4"
1079 );
1080 assert!(out.contains("amb:in (1):\n src/C.cs:9(candidates=2)"));
1081 assert!(
1082 !out.contains("Config"),
1083 "the raw ambiguous token is not a minimal column and must not appear"
1084 );
1085 }
1086
1087 fn impact_model(
1088 rows: Vec<ImpactRow>,
1089 total_affected: usize,
1090 dropped: usize,
1091 manifest_gap: usize,
1092 ) -> ImpactModel {
1093 let heuristic_affected = rows.iter().filter(|r| r.heuristic).count();
1094 ImpactModel {
1095 kind: SeedKind::File,
1096 seed_files: vec!["src/IFoo.cs".to_string()],
1097 hops: 2,
1098 total_affected,
1099 rows,
1100 dropped,
1101 manifest_gap,
1102 heuristic_affected,
1103 tests_affected: 0,
1104 braked: vec![],
1105 braked_files: vec![],
1106 }
1107 }
1108
1109 fn impact_row(
1111 file: &str,
1112 hop: u32,
1113 via_count: u32,
1114 ambiguous_count: u32,
1115 top_symbols: &[&str],
1116 top_symbols_more: usize,
1117 score: f64,
1118 ) -> ImpactRow {
1119 ImpactRow {
1120 file: file.into(),
1121 hop,
1122 via_count,
1123 ambiguous_count,
1124 top_symbols: top_symbols.iter().map(|s| (*s).to_string()).collect(),
1125 top_symbols_more,
1126 score,
1127 heuristic_count: 0,
1128 heuristic: false,
1129 iface_via: vec![],
1130 from_lines: vec![],
1131 infra: false,
1132 }
1133 }
1134
1135 fn heuristic_impact_row(
1138 file: &str,
1139 hop: u32,
1140 heuristic_count: u32,
1141 top_symbols: &[&str],
1142 score: f64,
1143 ) -> ImpactRow {
1144 ImpactRow {
1145 heuristic_count,
1146 heuristic: true,
1147 ..impact_row(file, hop, 0, 0, top_symbols, 0, score)
1148 }
1149 }
1150
1151 #[test]
1152 fn impact_compact_groups_rows_into_ascending_hop_buckets() {
1153 let model = impact_model(
1154 vec![
1155 impact_row("src/Two.cs", 2, 1, 0, &["Foo"], 0, 0.1),
1156 impact_row("src/One.cs", 1, 2, 1, &["Foo", "Bar"], 5, 0.5),
1157 ],
1158 3,
1159 0,
1160 0,
1161 );
1162 let out = render_impact_compact("IFoo", &model);
1163 let hop1 = out.find("hop 1").unwrap();
1164 let hop2 = out.find("hop 2").unwrap();
1165 assert!(
1166 hop1 < hop2,
1167 "hop 1 bucket must precede hop 2 regardless of row order in the model"
1168 );
1169 assert!(out.contains("hop 1 (1):\n src/One.cs via=2(+1amb)"));
1170 assert!(out.contains("hop 2 (1):\n src/Two.cs via=1"));
1171 assert!(!out.contains("Bar"), "compact drops top-symbols entirely");
1172 assert!(out.contains("summary: affected=3 shown=2 dropped=0 ambiguous=1"));
1173 }
1174
1175 #[test]
1176 fn impact_compact_manifest_gap_folds_into_summary() {
1177 let model = impact_model(vec![], 0, 0, 2);
1178 let out = render_impact_compact("IFoo", &model);
1179 let last_line = out.lines().last().unwrap();
1180 assert_eq!(
1181 last_line,
1182 "summary: affected=0 shown=0 dropped=0 ambiguous=0 gap=2"
1183 );
1184 }
1185
1186 #[test]
1190 fn ref_kind_block_missing_table_prints_nothing() {
1191 let mut out: Vec<String> = vec!["before".to_string()];
1192 ref_kind_block::<InboundRow>(&mut out, "inherits", None, |r| {
1193 format!("{}:{}", r.file, r.line)
1194 });
1195 assert_eq!(out, vec!["before".to_string()]);
1196 }
1197
1198 #[test]
1199 fn compact_block_missing_table_prints_nothing() {
1200 let mut out: Vec<String> = vec!["before".to_string()];
1201 compact_block::<InboundRow>(
1202 &mut out,
1203 "in:inherits",
1204 None,
1205 |r| r.file.as_str(),
1206 |r| r.line.to_string(),
1207 );
1208 assert_eq!(out, vec!["before".to_string()]);
1209 }
1210
1211 #[test]
1212 fn ref_kind_block_present_but_empty_table_still_prints_header() {
1213 let mut out: Vec<String> = Vec::new();
1214 let t: Table<InboundRow> = table(vec![], 0);
1215 ref_kind_block(&mut out, "inherits", Some(&t), |r: &InboundRow| {
1216 format!("{}:{}", r.file, r.line)
1217 });
1218 assert_eq!(out, vec![" inherits (0):".to_string()]);
1219 }
1220
1221 #[test]
1222 fn compact_block_present_but_empty_table_prints_nothing_unlike_ref_kind_block() {
1223 let mut out: Vec<String> = Vec::new();
1224 let t: Table<InboundRow> = table(vec![], 0);
1225 compact_block(
1226 &mut out,
1227 "in:inherits",
1228 Some(&t),
1229 |r: &InboundRow| r.file.as_str(),
1230 |r: &InboundRow| r.line.to_string(),
1231 );
1232 assert!(out.is_empty());
1233 }
1234
1235 #[test]
1236 fn ref_kind_block_dropped_note_only_appears_when_nonzero() {
1237 let mut out: Vec<String> = Vec::new();
1238 let t = table(
1239 vec![InboundRow {
1240 file: "a.cs".into(),
1241 line: 1,
1242 heuristic: false,
1243 source: String::new(),
1244 }],
1245 2,
1246 );
1247 ref_kind_block(&mut out, "inherits", Some(&t), |r: &InboundRow| {
1248 format!("{}:{}", r.file, r.line)
1249 });
1250 assert_eq!(out[0], " inherits (3, 2 dropped):");
1251 }
1252
1253 #[test]
1254 fn rle_collapses_consecutive_equal_runs_only() {
1255 let values = vec![
1256 "5".to_string(),
1257 "5".to_string(),
1258 "3".to_string(),
1259 "3".to_string(),
1260 "3".to_string(),
1261 "1".to_string(),
1262 ];
1263 assert_eq!(
1264 rle(&values),
1265 vec!["5x2".to_string(), "3x3".to_string(), "1".to_string()]
1266 );
1267 }
1268
1269 #[test]
1270 fn seed_kind_str_matches_js_string_literals() {
1271 assert_eq!(seed_kind_str(SeedKind::File), "file");
1272 assert_eq!(seed_kind_str(SeedKind::Symbol), "symbol");
1273 }
1274
1275 #[test]
1278 fn member_refs_line_renders_after_the_inbound_block_and_caps_the_named_members() {
1279 let mut model = refs_model(empty_inbound(), empty_outbound(), empty_ambiguous(), 0);
1280 model.member_refs = Some(query::MemberRefs {
1281 total: 3,
1282 member_count: 2,
1283 members: vec![
1284 query::MemberRefEntry {
1285 name: "EnableX".into(),
1286 count: 2,
1287 },
1288 query::MemberRefEntry {
1289 name: "EnableY".into(),
1290 count: 1,
1291 },
1292 ],
1293 dropped: 0,
1294 });
1295 let out = render_refs_text(&model);
1296 assert!(
1297 out.contains("\nmember refs: 3 across 2 member(s): EnableX 2, EnableY 1"),
1298 "{out}"
1299 );
1300 assert!(
1301 render_refs_compact(&model).contains("\nmem: EnableX=2,EnableY=1\nsummary:"),
1302 "{}",
1303 render_refs_compact(&model)
1304 );
1305
1306 model.member_refs = Some(query::MemberRefs {
1307 total: 9,
1308 member_count: 7,
1309 members: vec![query::MemberRefEntry {
1310 name: "A".into(),
1311 count: 9,
1312 }],
1313 dropped: 6,
1314 });
1315 assert!(
1316 render_refs_text(&model).contains("member refs: 9 across 7 member(s): A 9 +6 more"),
1317 "{}",
1318 render_refs_text(&model)
1319 );
1320 assert!(
1321 render_refs_compact(&model).contains("mem: A=9,+6"),
1322 "{}",
1323 render_refs_compact(&model)
1324 );
1325 }
1326
1327 #[test]
1328 fn a_model_with_no_member_refs_renders_exactly_as_before() {
1329 let out = render_refs_text(&refs_model(
1330 empty_inbound(),
1331 empty_outbound(),
1332 empty_ambiguous(),
1333 0,
1334 ));
1335 assert!(!out.contains("member refs:"), "{out}");
1336 assert!(!render_refs_compact(&refs_model(
1337 empty_inbound(),
1338 empty_outbound(),
1339 empty_ambiguous(),
1340 0
1341 ))
1342 .contains("mem: "));
1343 }
1344
1345 #[test]
1348 fn refs_text_suffixes_only_tagged_rows_and_never_an_imports_row() {
1349 let mut inbound = empty_inbound();
1350 inbound.uses_member = table(
1351 vec![
1352 InboundRow {
1353 file: "src/Fact.cs".into(),
1354 line: 4,
1355 heuristic: false,
1356 source: String::new(),
1357 },
1358 InboundRow {
1359 file: "src/Guess.cs".into(),
1360 line: 9,
1361 heuristic: true,
1362 source: String::new(),
1363 },
1364 ],
1365 0,
1366 );
1367 let mut outbound = empty_outbound();
1368 outbound.uses_member = table(
1369 vec![OutboundRow {
1370 file: "src/Guess.cs".into(),
1371 line: 9,
1372 to_file: "src/Widget.cs".into(),
1373 to: "App.Widget".into(),
1374 heuristic: true,
1375 source: String::new(),
1376 }],
1377 0,
1378 );
1379 outbound.imports = table(
1383 vec![ImportRow {
1384 file: "src/Guess.cs".into(),
1385 line: 1,
1386 target: "App.Core".into(),
1387 source: String::new(),
1388 }],
1389 0,
1390 );
1391
1392 let out = render_refs_text(&refs_model(inbound, outbound, empty_ambiguous(), 0));
1393 assert!(out.contains(" src/Fact.cs:4 uses-member\n"), "{out}");
1394 assert!(
1395 out.contains(" src/Guess.cs:9 uses-member (heuristic)"),
1396 "{out}"
1397 );
1398 assert!(
1399 out.contains(" src/Guess.cs:9 uses-member -> src/Widget.cs (heuristic)"),
1400 "{out}"
1401 );
1402 assert!(
1403 out.ends_with(" src/Guess.cs:1 imports -> App.Core"),
1404 "an imports row is never suffixed\n{out}"
1405 );
1406 assert_eq!(out.matches("(heuristic)").count(), 2);
1407 }
1408
1409 #[test]
1410 fn refs_compact_marks_a_heuristic_row_with_a_trailing_h_and_rle_keeps_them_distinct() {
1411 let mut inbound = empty_inbound();
1412 inbound.uses_member = table(
1413 vec![
1414 InboundRow {
1415 file: "src/A.cs".into(),
1416 line: 5,
1417 heuristic: false,
1418 source: String::new(),
1419 },
1420 InboundRow {
1421 file: "src/A.cs".into(),
1422 line: 5,
1423 heuristic: true,
1424 source: String::new(),
1425 },
1426 InboundRow {
1427 file: "src/A.cs".into(),
1428 line: 5,
1429 heuristic: true,
1430 source: String::new(),
1431 },
1432 ],
1433 0,
1434 );
1435 let out = render_refs_compact(&refs_model(inbound, empty_outbound(), empty_ambiguous(), 0));
1436 assert!(
1437 out.contains("in:uses-member (3):\n src/A.cs:5,5hx2"),
1438 "`5` and `5h` are distinct RLE entries\n{out}"
1439 );
1440 }
1441
1442 #[test]
1443 fn impact_text_declares_heuristic_reached_files_beside_the_count_only_when_there_are_some() {
1444 let with_guess = impact_model(
1445 vec![
1446 impact_row("src/Direct.cs", 1, 1, 0, &["Widget"], 0, 0.5),
1447 heuristic_impact_row("src/Guessed.cs", 1, 2, &["Widget"], 0.0),
1448 ],
1449 1,
1450 0,
1451 0,
1452 );
1453 let out = render_impact_text("Widget", &with_guess);
1454 assert!(
1455 out.contains("affected files: 1 (+1 heuristic) shown: 2 dropped: 0"),
1456 "{out}"
1457 );
1458 assert!(out.contains("src/Direct.cs 1 1 Widget\n"), "{out}");
1459 assert!(
1460 out.contains("src/Guessed.cs 1 2 Widget (heuristic)"),
1461 "the via column reports the GUESS count, never the zero viaCount\n{out}"
1462 );
1463
1464 let without = impact_model(
1465 vec![impact_row("src/Direct.cs", 1, 1, 0, &["Widget"], 0, 0.5)],
1466 1,
1467 0,
1468 0,
1469 );
1470 let out = render_impact_text("Widget", &without);
1471 assert!(
1472 out.contains("affected files: 1 shown: 1 dropped: 0"),
1473 "byte-unchanged when there is nothing to declare\n{out}"
1474 );
1475 assert!(!out.contains("heuristic"), "{out}");
1476 }
1477
1478 #[test]
1479 fn impact_compact_summary_inserts_heuristic_before_gap() {
1480 let model = impact_model(
1481 vec![heuristic_impact_row(
1482 "src/Guessed.cs",
1483 1,
1484 3,
1485 &["Widget"],
1486 0.0,
1487 )],
1488 0,
1489 0,
1490 2,
1491 );
1492 let out = render_impact_compact("Widget", &model);
1493 assert!(out.contains("hop 1 (1):\n src/Guessed.cs via=3h"), "{out}");
1494 assert_eq!(
1495 out.lines().last().unwrap(),
1496 "summary: affected=0 shown=1 dropped=0 ambiguous=0 heuristic=1 gap=2"
1497 );
1498
1499 let none = impact_model(
1501 vec![impact_row("src/Direct.cs", 1, 1, 0, &["Widget"], 0, 0.5)],
1502 1,
1503 0,
1504 2,
1505 );
1506 assert_eq!(
1507 render_impact_compact("Widget", &none)
1508 .lines()
1509 .last()
1510 .unwrap(),
1511 "summary: affected=1 shown=1 dropped=0 ambiguous=0 gap=2"
1512 );
1513 }
1514
1515 fn tests_model(rows: Vec<TestRow>) -> TestsModel {
1518 let precise: Vec<&TestRow> = rows.iter().filter(|r| !r.heuristic).collect();
1519 let guessed: Vec<&TestRow> = rows.iter().filter(|r| r.heuristic).collect();
1520 TestsModel {
1521 query: "OrderService".to_string(),
1522 symbol: "App.Orders.OrderService".to_string(),
1523 def_files: vec!["src/OrderService.cs".to_string()],
1524 test_file_count: precise.len(),
1525 ref_count: precise.iter().map(|r| r.ref_count).sum(),
1526 heuristic_file_count: guessed.len(),
1527 heuristic_ref_count: guessed.iter().map(|r| r.ref_count).sum(),
1528 rows,
1529 }
1530 }
1531
1532 fn test_row(file: &str, test_defs: &[&str], lines: &[usize], heuristic: bool) -> TestRow {
1533 TestRow {
1534 file: file.into(),
1535 test_defs: test_defs.iter().map(|s| (*s).to_string()).collect(),
1536 lines: lines.to_vec(),
1537 ref_count: lines.len(),
1538 heuristic,
1539 }
1540 }
1541
1542 #[test]
1543 fn tests_text_heads_the_file_and_indents_one_line_per_test_def() {
1544 let model = tests_model(vec![test_row(
1545 "tests/OrderServiceTests.cs",
1546 &["App.Orders.Tests.OrderServiceTests"],
1547 &[10, 11],
1548 false,
1549 )]);
1550 assert_eq!(
1551 render_tests_text(&model),
1552 "tests for App.Orders.OrderService\ncovered by 1 test file(s), 2 reference(s)\n\ntests/OrderServiceTests.cs\n App.Orders.Tests.OrderServiceTests lines: 10, 11"
1553 );
1554 }
1555
1556 #[test]
1557 fn tests_text_answers_the_zero_case_in_one_line_instead_of_a_header_over_a_void() {
1558 let mut model = tests_model(vec![]);
1559 model.symbol = "App.Orders.Untested".to_string();
1560 assert_eq!(
1561 render_tests_text(&model),
1562 "tests for App.Orders.Untested\nno test references found"
1563 );
1564 }
1565
1566 #[test]
1567 fn tests_text_marks_a_guessed_file_with_the_shared_heuristic_suffix() {
1568 let model = tests_model(vec![
1569 test_row(
1570 "tests/OrderServiceTests.cs",
1571 &["App.Orders.Tests.OrderServiceTests"],
1572 &[10],
1573 false,
1574 ),
1575 test_row(
1576 "tests/Partial.Extra.cs",
1577 &["App.Orders.Tests.PartialTests"],
1578 &[9],
1579 true,
1580 ),
1581 ]);
1582 let out = render_tests_text(&model);
1583 assert!(
1584 out.contains("covered by 1 test file(s), 1 reference(s)"),
1585 "counts stay precise-only\n{out}"
1586 );
1587 assert!(
1588 out.ends_with(
1589 "tests/Partial.Extra.cs (heuristic)\n App.Orders.Tests.PartialTests lines: 9"
1590 ),
1591 "{out}"
1592 );
1593 }
1594
1595 #[test]
1596 fn tests_compact_folds_the_counts_into_the_header_and_drops_the_def_ids() {
1597 let model = tests_model(vec![test_row(
1598 "tests/OrderServiceTests.cs",
1599 &["App.Orders.Tests.OrderServiceTests"],
1600 &[10, 11],
1601 false,
1602 )]);
1603 assert_eq!(
1604 render_tests_compact(&model),
1605 "tests App.Orders.OrderService files=1 refs=2\ntests/OrderServiceTests.cs 10,11"
1606 );
1607
1608 let mut empty = tests_model(vec![]);
1609 empty.symbol = "App.Orders.Untested".to_string();
1610 assert_eq!(
1611 render_tests_compact(&empty),
1612 "tests App.Orders.Untested files=0 refs=0"
1613 );
1614 }
1615
1616 #[test]
1617 fn tests_compact_declares_heuristic_files_in_the_header_and_marks_their_lines() {
1618 let model = tests_model(vec![
1619 test_row(
1620 "tests/OrderServiceTests.cs",
1621 &["App.Orders.Tests.OrderServiceTests"],
1622 &[10],
1623 false,
1624 ),
1625 test_row(
1626 "tests/Partial.Extra.cs",
1627 &["App.Orders.Tests.PartialTests"],
1628 &[9, 12],
1629 true,
1630 ),
1631 ]);
1632 assert_eq!(
1633 render_tests_compact(&model),
1634 "tests App.Orders.OrderService files=1 refs=1 heuristic=1\ntests/OrderServiceTests.cs 10\ntests/Partial.Extra.cs 9h,12h"
1635 );
1636 }
1637
1638 #[test]
1639 fn impact_summaries_append_tests_only_when_the_blast_radius_reaches_one() {
1640 let reached = ImpactModel {
1641 tests_affected: 1,
1642 ..impact_model(
1643 vec![impact_row(
1644 "tests/OrderServiceTests.cs",
1645 1,
1646 1,
1647 0,
1648 &["OrderService"],
1649 0,
1650 0.5,
1651 )],
1652 1,
1653 0,
1654 0,
1655 )
1656 };
1657 assert!(
1658 render_impact_text("OrderService", &reached)
1659 .contains("affected files: 1 shown: 1 dropped: 0 tests=1"),
1660 "{}",
1661 render_impact_text("OrderService", &reached)
1662 );
1663 assert_eq!(
1664 render_impact_compact("OrderService", &reached)
1665 .lines()
1666 .last()
1667 .unwrap(),
1668 "summary: affected=1 shown=1 dropped=0 ambiguous=0 tests=1"
1669 );
1670
1671 let none = impact_model(
1672 vec![impact_row(
1673 "src/Other.cs",
1674 1,
1675 1,
1676 0,
1677 &["OrderService"],
1678 0,
1679 0.5,
1680 )],
1681 1,
1682 0,
1683 0,
1684 );
1685 assert!(
1686 render_impact_text("OrderService", &none)
1687 .contains("affected files: 1 shown: 1 dropped: 0\n"),
1688 "its ABSENCE is the gap signal -- never printed as tests=0"
1689 );
1690 assert_eq!(
1691 render_impact_compact("OrderService", &none)
1692 .lines()
1693 .last()
1694 .unwrap(),
1695 "summary: affected=1 shown=1 dropped=0 ambiguous=0"
1696 );
1697 }
1698
1699 #[test]
1700 fn impact_compact_orders_tests_after_heuristic_and_before_gap() {
1701 let model = ImpactModel {
1702 tests_affected: 1,
1703 ..impact_model(
1704 vec![
1705 impact_row(
1706 "tests/OrderServiceTests.cs",
1707 1,
1708 1,
1709 0,
1710 &["OrderService"],
1711 0,
1712 0.5,
1713 ),
1714 heuristic_impact_row("src/Guessed.cs", 1, 3, &["OrderService"], 0.0),
1715 ],
1716 1,
1717 0,
1718 2,
1719 )
1720 };
1721 assert_eq!(
1722 render_impact_compact("OrderService", &model)
1723 .lines()
1724 .last()
1725 .unwrap(),
1726 "summary: affected=1 shown=2 dropped=0 ambiguous=0 heuristic=1 tests=1 gap=2"
1727 );
1728 }
1729}