1use crate::graph::HeuristicTier;
26use crate::query;
27
28const HEURISTIC_SUFFIX: &str = " (heuristic)";
37
38const EXT_SUFFIX: &str = " (extension)";
44const GUESS_SUFFIX: &str = " (guess)";
45
46const INFRA_SUFFIX: &str = " (infra)";
50
51const TEST_PROJECT_SUFFIX: &str = " (test project)";
56
57fn heuristic_suffix(heuristic: bool, tier: Option<HeuristicTier>) -> &'static str {
65 match (heuristic, tier) {
66 (false, _) => "",
67 (true, Some(HeuristicTier::Ext)) => EXT_SUFFIX,
68 (true, Some(HeuristicTier::Guess)) => GUESS_SUFFIX,
69 (true, None) => HEURISTIC_SUFFIX,
70 }
71}
72
73fn test_via_suffix(via: query::TestVia) -> &'static str {
79 match via {
80 query::TestVia::Attribute => "",
81 query::TestVia::Project => TEST_PROJECT_SUFFIX,
82 }
83}
84
85fn compact_marker(heuristic: bool, tier: Option<HeuristicTier>) -> &'static str {
95 match (heuristic, tier) {
96 (false, _) => "",
97 (true, Some(HeuristicTier::Ext)) => "x",
98 (true, _) => "h",
99 }
100}
101
102fn source_suffix(source: &str) -> String {
106 if source.is_empty() {
107 String::new()
108 } else {
109 format!(" {source}")
110 }
111}
112
113pub fn seed_kind_str(kind: query::SeedKind) -> &'static str {
116 match kind {
117 query::SeedKind::File => "file",
118 query::SeedKind::Symbol => "symbol",
119 }
120}
121
122fn rle(values: &[String]) -> Vec<String> {
127 let mut out = Vec::new();
128 let mut i = 0;
129 while i < values.len() {
130 let mut j = i + 1;
131 while j < values.len() && values[j] == values[i] {
132 j += 1;
133 }
134 if j - i > 1 {
135 out.push(format!("{}x{}", values[i], j - i));
136 } else {
137 out.push(values[i].clone());
138 }
139 i = j;
140 }
141 out
142}
143
144fn group_by_file<R>(
157 rows: &[R],
158 file_of: fn(&R) -> &str,
159 line_fmt: impl Fn(&R) -> String,
160) -> Vec<String> {
161 let mut out = Vec::new();
162 let mut i = 0;
163 while i < rows.len() {
164 let mut j = i + 1;
165 while j < rows.len() && file_of(&rows[j]) == file_of(&rows[i]) {
166 j += 1;
167 }
168 let lines: Vec<String> = rows[i..j].iter().map(|r| line_fmt(r)).collect();
169 out.push(format!("{}:{}", file_of(&rows[i]), rle(&lines).join(",")));
170 i = j;
171 }
172 out
173}
174
175fn ref_kind_block<R>(
179 out: &mut Vec<String>,
180 label: &str,
181 table: Option<&query::Table<R>>,
182 row_fmt: impl Fn(&R) -> String,
183) {
184 let Some(t) = table else { return };
185 let dropped_note = if t.dropped != 0 {
186 format!(", {} dropped", t.dropped)
187 } else {
188 String::new()
189 };
190 out.push(format!(" {label} ({}{dropped_note}):", t.total));
191 for r in &t.rows {
192 out.push(format!(" {}", row_fmt(r)));
193 }
194}
195
196fn compact_block<R>(
199 out: &mut Vec<String>,
200 label: &str,
201 table: Option<&query::Table<R>>,
202 file_of: fn(&R) -> &str,
203 line_fmt: impl Fn(&R) -> String,
204) {
205 let Some(t) = table else { return };
206 if t.total == 0 {
207 return;
208 }
209 let dropped_note = if t.dropped != 0 {
210 format!(", {} dropped", t.dropped)
211 } else {
212 String::new()
213 };
214 out.push(format!("{label} ({}{dropped_note}):", t.total));
215 for line in group_by_file(&t.rows, file_of, line_fmt) {
216 out.push(format!(" {line}"));
217 }
218}
219
220fn member_refs_line(m: &query::MemberRefs) -> String {
227 let named = m
228 .members
229 .iter()
230 .map(|e| format!("{} {}", e.name, e.count))
231 .collect::<Vec<_>>()
232 .join(", ");
233 let more = if m.dropped != 0 {
234 format!(" +{} more", m.dropped)
235 } else {
236 String::new()
237 };
238 format!(
239 "member refs: {} across {} member(s): {named}{more}",
240 m.total, m.member_count
241 )
242}
243
244#[allow(
246 clippy::too_many_lines,
247 reason = "one ordered pass emitting every section of the refs model in the fixed order the text output promises"
248)]
249pub fn render_refs_text(model: &query::RefsModel) -> String {
250 let mut out: Vec<String> = Vec::new();
251 out.push(format!("{} ({})", model.id, model.kind));
252 out.push(format!(
253 "def: {}",
254 model
255 .sites
256 .iter()
257 .map(|s| format!("{}:{}", s.file, s.line))
258 .collect::<Vec<_>>()
259 .join(" ")
260 ));
261
262 out.push("inbound:".to_string());
263 ref_kind_block(
264 &mut out,
265 "inherits",
266 Some(&model.inbound.inherits),
267 |r: &query::InboundRow| {
268 format!(
269 "{}:{} inherits{}{}",
270 r.file,
271 r.line,
272 heuristic_suffix(r.heuristic, r.tier),
273 source_suffix(&r.source)
274 )
275 },
276 );
277 ref_kind_block(
278 &mut out,
279 "uses-type",
280 Some(&model.inbound.uses_type),
281 |r: &query::InboundRow| {
282 format!(
283 "{}:{} uses-type{}{}",
284 r.file,
285 r.line,
286 heuristic_suffix(r.heuristic, r.tier),
287 source_suffix(&r.source)
288 )
289 },
290 );
291 ref_kind_block(
292 &mut out,
293 "uses-member",
294 Some(&model.inbound.uses_member),
295 |r: &query::InboundRow| {
296 format!(
297 "{}:{} uses-member{}{}",
298 r.file,
299 r.line,
300 heuristic_suffix(r.heuristic, r.tier),
301 source_suffix(&r.source)
302 )
303 },
304 );
305 let inbound_dropped = model.inbound.inherits.dropped
311 + model.inbound.uses_type.dropped
312 + model.inbound.uses_member.dropped;
313 if inbound_dropped != 0 {
314 out.push(format!(" +{inbound_dropped} more"));
315 }
316 if let Some(m) = &model.member_refs {
317 out.push(member_refs_line(m));
318 }
319
320 if let Some(ob) = &model.outbound {
321 out.push("outbound:".to_string());
322 ref_kind_block(
323 &mut out,
324 "inherits",
325 Some(&ob.inherits),
326 |r: &query::OutboundRow| {
327 format!(
328 "{}:{} inherits -> {}{}{}",
329 r.file,
330 r.line,
331 r.to_file,
332 heuristic_suffix(r.heuristic, r.tier),
333 source_suffix(&r.source)
334 )
335 },
336 );
337 ref_kind_block(
338 &mut out,
339 "uses-type",
340 Some(&ob.uses_type),
341 |r: &query::OutboundRow| {
342 format!(
343 "{}:{} uses-type -> {}{}{}",
344 r.file,
345 r.line,
346 r.to_file,
347 heuristic_suffix(r.heuristic, r.tier),
348 source_suffix(&r.source)
349 )
350 },
351 );
352 ref_kind_block(
353 &mut out,
354 "uses-member",
355 Some(&ob.uses_member),
356 |r: &query::OutboundRow| {
357 format!(
358 "{}:{} uses-member -> {}{}{}",
359 r.file,
360 r.line,
361 r.to_file,
362 heuristic_suffix(r.heuristic, r.tier),
363 source_suffix(&r.source)
364 )
365 },
366 );
367 ref_kind_block(
368 &mut out,
369 "imports",
370 Some(&ob.imports),
371 |r: &query::ImportRow| {
372 format!(
373 "{}:{} imports -> {}{}",
374 r.file,
375 r.line,
376 r.target,
377 source_suffix(&r.source)
378 )
379 },
380 );
381 let outbound_dropped = ob.inherits.dropped
387 + ob.uses_type.dropped
388 + ob.uses_member.dropped
389 + ob.imports.dropped;
390 if outbound_dropped != 0 {
391 out.push(format!(" +{outbound_dropped} more, use --all"));
392 }
393 }
394
395 let amb_in = &model.ambiguous.inbound;
396 let amb_out = &model.ambiguous.outbound;
397 if amb_in.total != 0 || amb_out.total != 0 {
398 out.push("ambiguous:".to_string());
399 let amb_row = |r: &query::AmbiguousRow| {
400 format!(
401 "{}:{} {} raw=\"{}\" candidates={}",
402 r.file, r.line, r.origin, r.raw, r.candidate_count
403 )
404 };
405 if amb_in.total != 0 {
406 ref_kind_block(&mut out, "inbound", Some(amb_in), amb_row);
407 }
408 if amb_out.total != 0 {
409 ref_kind_block(&mut out, "outbound", Some(amb_out), amb_row);
410 }
411 }
412
413 if model.manifest_gap != 0 {
414 out.push(format!(
415 "manifest gap: {} graph file(s) not in manifest",
416 model.manifest_gap
417 ));
418 }
419 out.join("\n")
420}
421
422pub fn render_impact_text(query: &str, model: &query::ImpactModel) -> String {
424 let mut out: Vec<String> = Vec::new();
425 let joined = model.seed_files.join(", ");
426 let seeds = if joined.is_empty() {
427 "-".to_string()
428 } else {
429 joined
430 };
431 out.push(format!(
432 "impact: {query} ({}, seed files: {seeds}) hops<={}",
433 seed_kind_str(model.kind),
434 model.hops
435 ));
436 let heuristic_note = if model.heuristic_affected != 0 {
441 format!(" (+{} heuristic)", model.heuristic_affected)
442 } else {
443 String::new()
444 };
445 let tests_note = if model.tests_affected != 0 {
451 format!(" tests={}", model.tests_affected)
452 } else {
453 String::new()
454 };
455 out.push(format!(
456 "affected files: {}{heuristic_note} shown: {} dropped: {}{tests_note}",
457 model.total_affected,
458 model.rows.len(),
459 model.dropped
460 ));
461 out.push("file hops via top-symbols".to_string());
462 for r in &model.rows {
463 let mut syms = r.top_symbols.join(", ");
464 if r.top_symbols_more != 0 {
465 syms.push_str(&format!(" +{}", r.top_symbols_more));
466 }
467 let via = if r.heuristic {
472 r.heuristic_count.to_string()
473 } else if r.ambiguous_count != 0 {
474 format!("{}(+{}amb)", r.via_count, r.ambiguous_count)
475 } else {
476 r.via_count.to_string()
477 };
478 let iface_via = if r.iface_via.is_empty() {
482 String::new()
483 } else {
484 format!(" via {}", r.iface_via.join(", "))
485 };
486 let class_suffix = if r.infra { INFRA_SUFFIX } else { "" };
490 out.push(format!(
491 "{} {} {via} {syms}{iface_via}{}{class_suffix}",
492 r.file,
493 r.hop,
494 heuristic_suffix(r.heuristic, r.tier)
495 ));
496 }
497 if !model.braked.is_empty() {
502 let list = model
503 .braked
504 .iter()
505 .map(|b| format!("{} (fan-in {})", b.iface, b.fanin))
506 .collect::<Vec<_>>()
507 .join(", ");
508 out.push(format!("braked: {list} — raise --iface-max-fanin to widen"));
509 }
510 if !model.braked_files.is_empty() {
514 let list = model
515 .braked_files
516 .iter()
517 .map(|b| format!("{} (in-degree {})", b.file, b.indegree))
518 .collect::<Vec<_>>()
519 .join(", ");
520 out.push(format!(
521 "braked: {list} — raise --hub-max-indegree to widen"
522 ));
523 }
524 if model.manifest_gap != 0 {
525 out.push(format!(
526 "manifest gap: {} graph file(s) not in manifest",
527 model.manifest_gap
528 ));
529 }
530 out.join("\n")
531}
532
533#[allow(
535 clippy::too_many_lines,
536 reason = "one ordered pass mirroring render_refs_text's section order in the compact shape"
537)]
538pub fn render_refs_compact(model: &query::RefsModel) -> String {
539 let mut out: Vec<String> = Vec::new();
540 out.push(format!("{} ({})", model.id, model.kind));
541 out.push(format!(
542 "def: {}",
543 model
544 .sites
545 .iter()
546 .map(|s| format!("{}:{}", s.file, s.line))
547 .collect::<Vec<_>>()
548 .join(" ")
549 ));
550
551 let file_of_ib: fn(&query::InboundRow) -> &str = |r| r.file.as_str();
567 let line_ib =
568 |r: &query::InboundRow| format!("{}{}", r.line, compact_marker(r.heuristic, r.tier));
569 compact_block(
570 &mut out,
571 "in:inherits",
572 Some(&model.inbound.inherits),
573 file_of_ib,
574 line_ib,
575 );
576 compact_block(
577 &mut out,
578 "in:uses-type",
579 Some(&model.inbound.uses_type),
580 file_of_ib,
581 line_ib,
582 );
583 compact_block(
584 &mut out,
585 "in:uses-member",
586 Some(&model.inbound.uses_member),
587 file_of_ib,
588 line_ib,
589 );
590
591 if let Some(ob) = &model.outbound {
592 let file_of_ob: fn(&query::OutboundRow) -> &str = |r| r.file.as_str();
593 let line_ob =
594 |r: &query::OutboundRow| format!("{}{}", r.line, compact_marker(r.heuristic, r.tier));
595 compact_block(
596 &mut out,
597 "out:inherits",
598 Some(&ob.inherits),
599 file_of_ob,
600 line_ob,
601 );
602 compact_block(
603 &mut out,
604 "out:uses-type",
605 Some(&ob.uses_type),
606 file_of_ob,
607 line_ob,
608 );
609 compact_block(
610 &mut out,
611 "out:uses-member",
612 Some(&ob.uses_member),
613 file_of_ob,
614 line_ob,
615 );
616
617 let file_of_imp: fn(&query::ImportRow) -> &str = |r| r.file.as_str();
618 let line_imp = |r: &query::ImportRow| r.line.to_string();
619 compact_block(
620 &mut out,
621 "out:imports",
622 Some(&ob.imports),
623 file_of_imp,
624 line_imp,
625 );
626 }
627
628 let amb_in = &model.ambiguous.inbound;
629 let amb_out = &model.ambiguous.outbound;
630 let file_of_amb: fn(&query::AmbiguousRow) -> &str = |r| r.file.as_str();
631 let line_amb =
632 |r: &query::AmbiguousRow| format!("{}(candidates={})", r.line, r.candidate_count);
633 compact_block(&mut out, "amb:in", Some(amb_in), file_of_amb, line_amb);
634 compact_block(&mut out, "amb:out", Some(amb_out), file_of_amb, line_amb);
635
636 let ob = model.outbound.as_ref();
641 let ob_sum = |f: fn(&query::OutboundTables) -> usize| ob.map_or(0, f);
642 let edges = model.inbound.inherits.total
643 + model.inbound.uses_type.total
644 + model.inbound.uses_member.total
645 + ob_sum(|o| o.inherits.total + o.uses_type.total + o.uses_member.total + o.imports.total);
646 let shown = model.inbound.inherits.rows.len()
647 + model.inbound.uses_type.rows.len()
648 + model.inbound.uses_member.rows.len()
649 + ob_sum(|o| {
650 o.inherits.rows.len()
651 + o.uses_type.rows.len()
652 + o.uses_member.rows.len()
653 + o.imports.rows.len()
654 });
655 let dropped = model.inbound.inherits.dropped
656 + model.inbound.uses_type.dropped
657 + model.inbound.uses_member.dropped
658 + ob_sum(|o| {
659 o.inherits.dropped + o.uses_type.dropped + o.uses_member.dropped + o.imports.dropped
660 });
661 let ambiguous = amb_in.total + amb_out.total;
662 let gap = if model.manifest_gap != 0 {
663 format!(" gap={}", model.manifest_gap)
664 } else {
665 String::new()
666 };
667 if let Some(m) = &model.member_refs {
671 let named = m
672 .members
673 .iter()
674 .map(|e| format!("{}={}", e.name, e.count))
675 .collect::<Vec<_>>()
676 .join(",");
677 let more = if m.dropped != 0 {
678 format!(",+{}", m.dropped)
679 } else {
680 String::new()
681 };
682 out.push(format!("mem: {named}{more}"));
683 }
684 out.push(format!(
685 "summary: edges={edges} shown={shown} dropped={dropped} ambiguous={ambiguous}{gap}"
686 ));
687 out.join("\n")
688}
689
690fn read_def_line(model: &query::ReadModel) -> String {
696 let sites = &model.refs.sites;
697 if sites.is_empty() {
698 return "def: -".to_string();
699 }
700 let rendered: Vec<String> = sites
701 .iter()
702 .enumerate()
703 .map(|(i, s)| match (i, &model.span) {
704 (0, Some(sp)) => format!("{}:{}-{}", sp.file, sp.start_line, sp.end_line),
705 _ => format!("{}:{}", s.file, s.line),
706 })
707 .collect();
708 format!("def: {}", rendered.join(" "))
709}
710
711pub fn render_read_text(model: &query::ReadModel) -> String {
716 let rendered = render_refs_text(&model.refs);
717 let mut out: Vec<String> = rendered.split('\n').map(str::to_string).collect();
718 if out.len() > 1 {
719 out[1] = read_def_line(model);
720 }
721 if let Some(sp) = &model.span {
722 out.splice(2..2, sp.source.split('\n').map(str::to_string));
723 }
724 out.join("\n")
725}
726
727pub fn render_read_compact(model: &query::ReadModel) -> String {
732 let rendered = render_refs_compact(&model.refs);
733 let mut out: Vec<String> = rendered.split('\n').map(str::to_string).collect();
734 if out.len() > 1 {
735 out[1] = read_def_line(model);
736 }
737 out.join("\n")
738}
739
740pub fn render_impact_compact(query: &str, model: &query::ImpactModel) -> String {
742 let mut out: Vec<String> = Vec::new();
743 let joined = model.seed_files.join(", ");
744 let seeds = if joined.is_empty() {
745 "-".to_string()
746 } else {
747 joined
748 };
749 out.push(format!(
750 "impact: {query} ({}, seed: {seeds}) hops<={}",
751 seed_kind_str(model.kind),
752 model.hops
753 ));
754
755 let mut by_hop: std::collections::HashMap<u32, Vec<&query::ImpactRow>> =
761 std::collections::HashMap::new();
762 for r in &model.rows {
763 by_hop.entry(r.hop).or_default().push(r);
764 }
765 let mut hops: Vec<u32> = by_hop.keys().copied().collect();
766 hops.sort_unstable();
767 for hop in hops {
768 let rows = &by_hop[&hop];
769 out.push(format!("hop {hop} ({}):", rows.len()));
770 let lines: Vec<String> = rows
771 .iter()
772 .map(|r| {
773 if r.heuristic {
778 return format!(
779 "{} via={}{}",
780 r.file,
781 r.heuristic_count,
782 compact_marker(r.heuristic, r.tier)
783 );
784 }
785 let via = if r.ambiguous_count != 0 {
786 format!("{}(+{}amb)", r.via_count, r.ambiguous_count)
787 } else {
788 r.via_count.to_string()
789 };
790 let iface_suffix = if r.iface_via.is_empty() {
792 String::new()
793 } else {
794 format!(" iface={}", r.iface_via.join(","))
795 };
796 let class_suffix = if r.infra { " class=infra" } else { "" };
798 format!("{} via={via}{iface_suffix}{class_suffix}", r.file)
799 })
800 .collect();
801 for line in rle(&lines) {
802 out.push(format!(" {line}"));
803 }
804 }
805
806 let ambiguous: u32 = model.rows.iter().map(|r| r.ambiguous_count).sum();
807 let gap = if model.manifest_gap != 0 {
808 format!(" gap={}", model.manifest_gap)
809 } else {
810 String::new()
811 };
812 let heur = if model.heuristic_affected != 0 {
816 format!(" heuristic={}", model.heuristic_affected)
817 } else {
818 String::new()
819 };
820 let tests = if model.tests_affected != 0 {
821 format!(" tests={}", model.tests_affected)
822 } else {
823 String::new()
824 };
825 let braked = if model.braked.is_empty() && model.braked_files.is_empty() {
830 String::new()
831 } else {
832 let terms: Vec<String> = model
833 .braked
834 .iter()
835 .map(|b| format!("{}:{}", b.iface, b.fanin))
836 .chain(
837 model
838 .braked_files
839 .iter()
840 .map(|b| format!("{}:{}", b.file, b.indegree)),
841 )
842 .collect();
843 format!(" braked={}", terms.join(","))
844 };
845 out.push(format!(
846 "summary: affected={} shown={} dropped={} ambiguous={ambiguous}{heur}{tests}{gap}{braked}",
847 model.total_affected,
848 model.rows.len(),
849 model.dropped
850 ));
851 out.join("\n")
852}
853
854pub fn render_tests_text(model: &query::TestsModel) -> String {
862 let mut out: Vec<String> = vec![format!("tests for {}", model.symbol)];
863 if model.rows.is_empty() {
864 out.push("no test references found".to_string());
865 return out.join("\n");
866 }
867 out.push(format!(
868 "covered by {} test file(s), {} reference(s)",
869 model.test_file_count, model.ref_count
870 ));
871 out.push(String::new());
872 for r in &model.rows {
873 out.push(format!(
874 "{}{}{}",
875 r.file,
876 heuristic_suffix(r.heuristic, r.tier),
877 test_via_suffix(r.via)
878 ));
879 let lines = r
880 .lines
881 .iter()
882 .map(|l| l.to_string())
883 .collect::<Vec<_>>()
884 .join(", ");
885 if r.test_defs.is_empty() {
886 out.push(format!(" lines: {lines}"));
890 } else {
891 for def_id in &r.test_defs {
892 out.push(format!(" {def_id} lines: {lines}"));
893 }
894 }
895 }
896 out.join("\n")
897}
898
899pub fn render_tests_compact(model: &query::TestsModel) -> String {
904 let heur = if model.heuristic_file_count != 0 {
905 format!(" heuristic={}", model.heuristic_file_count)
906 } else {
907 String::new()
908 };
909 let mut out: Vec<String> = vec![format!(
910 "tests {} files={} refs={}{heur}",
911 model.symbol, model.test_file_count, model.ref_count
912 )];
913 for r in &model.rows {
914 let lines = r
915 .lines
916 .iter()
917 .map(|l| format!("{l}{}", compact_marker(r.heuristic, r.tier)))
918 .collect::<Vec<_>>()
919 .join(",");
920 out.push(format!("{} {lines}", r.file));
921 }
922 out.join("\n")
923}
924
925#[cfg(test)]
926mod tests {
927 use super::*;
928 use crate::query::{
929 AmbiguousRow, AmbiguousTables, DefSite, ImpactModel, ImpactRow, ImportRow, InboundRow,
930 InboundTables, OutboundRow, OutboundTables, RefsModel, SeedKind, Table, TestRow,
931 TestsModel, Why,
932 };
933
934 fn table<R>(rows: Vec<R>, dropped: usize) -> Table<R> {
935 Table {
936 total: rows.len() + dropped,
937 dropped,
938 rows,
939 }
940 }
941
942 fn empty_inbound() -> InboundTables {
943 InboundTables {
944 inherits: table(vec![], 0),
945 uses_type: table(vec![], 0),
946 uses_member: table(vec![], 0),
947 }
948 }
949 fn empty_outbound() -> OutboundTables {
950 OutboundTables {
951 inherits: table(vec![], 0),
952 uses_type: table(vec![], 0),
953 uses_member: table(vec![], 0),
954 imports: table(vec![], 0),
955 }
956 }
957 fn empty_ambiguous() -> AmbiguousTables {
958 AmbiguousTables {
959 inbound: table(vec![], 0),
960 outbound: table(vec![], 0),
961 }
962 }
963
964 fn refs_model(
965 inbound: InboundTables,
966 outbound: OutboundTables,
967 ambiguous: AmbiguousTables,
968 manifest_gap: usize,
969 ) -> RefsModel {
970 RefsModel {
971 query: "IFoo".to_string(),
972 id: "App.IFoo".to_string(),
973 kind: "interface".to_string(),
974 sites: vec![DefSite {
975 file: "src/IFoo.cs".to_string(),
976 line: 3,
977 }],
978 inbound,
979 outbound: Some(outbound),
980 ambiguous,
981 manifest_gap,
982 member_refs: None,
983 }
984 }
985
986 #[test]
989 fn refs_compact_one_header_per_kind_empty_kinds_print_nothing() {
990 let mut inbound = empty_inbound();
991 inbound.inherits = table(
992 vec![InboundRow {
993 file: "src/Foo.cs".into(),
994 line: 5,
995 heuristic: false,
996 tier: None,
997 source: String::new(),
998 }],
999 0,
1000 );
1001 let mut outbound = empty_outbound();
1002 outbound.uses_type = table(
1003 vec![OutboundRow {
1004 file: "src/IFoo.cs".into(),
1005 line: 3,
1006 to_file: "src/Bar.cs".into(),
1007 to: "App.Bar".into(),
1008 heuristic: false,
1009 tier: None,
1010 source: String::new(),
1011 }],
1012 0,
1013 );
1014 let model = refs_model(inbound, outbound, empty_ambiguous(), 0);
1015 let out = render_refs_compact(&model);
1016 assert!(out.contains("in:inherits (1):\n src/Foo.cs:5"));
1017 assert!(out.contains("out:uses-type (1):\n src/IFoo.cs:3"));
1018 assert!(
1019 !out.contains("in:uses-type"),
1020 "a kind with zero rows must not print a header"
1021 );
1022 assert!(!out.contains("out:inherits"));
1023 assert!(!out.contains("out:imports"));
1024 assert!(
1025 !out.contains("Bar.cs"),
1026 "outbound target file is dropped in compact mode -- only path:line survives"
1027 );
1028 }
1029
1030 #[test]
1031 fn refs_compact_same_file_line_collapses_to_nxn() {
1032 let mut outbound = empty_outbound();
1033 outbound.inherits = table(
1034 vec![
1035 OutboundRow {
1036 file: "src/Widget.cs".into(),
1037 line: 5,
1038 to_file: "src/IWidget.cs".into(),
1039 to: "App.IWidget".into(),
1040 heuristic: false,
1041 tier: None,
1042 source: String::new(),
1043 },
1044 OutboundRow {
1045 file: "src/Widget.cs".into(),
1046 line: 5,
1047 to_file: "src/IGadget.cs".into(),
1048 to: "App.IGadget".into(),
1049 heuristic: false,
1050 tier: None,
1051 source: String::new(),
1052 },
1053 ],
1054 0,
1055 );
1056 let model = refs_model(empty_inbound(), outbound, empty_ambiguous(), 0);
1057 let out = render_refs_compact(&model);
1058 assert!(out.contains("out:inherits (2):\n src/Widget.cs:5x2"));
1059 }
1060
1061 #[test]
1062 fn refs_compact_groups_multiple_lines_under_one_file_mention() {
1063 let mut inbound = empty_inbound();
1064 inbound.uses_type = table(
1065 vec![
1066 InboundRow {
1067 file: "src/Consumers/Big.cs".into(),
1068 line: 12,
1069 heuristic: false,
1070 tier: None,
1071 source: String::new(),
1072 },
1073 InboundRow {
1074 file: "src/Consumers/Big.cs".into(),
1075 line: 40,
1076 heuristic: false,
1077 tier: None,
1078 source: String::new(),
1079 },
1080 InboundRow {
1081 file: "src/Consumers/Big.cs".into(),
1082 line: 40,
1083 heuristic: false,
1084 tier: None,
1085 source: String::new(),
1086 },
1087 InboundRow {
1088 file: "src/Consumers/Small.cs".into(),
1089 line: 3,
1090 heuristic: false,
1091 tier: None,
1092 source: String::new(),
1093 },
1094 ],
1095 0,
1096 );
1097 let model = refs_model(inbound, empty_outbound(), empty_ambiguous(), 0);
1098 let out = render_refs_compact(&model);
1099 assert!(out.contains(
1100 "in:uses-type (4):\n src/Consumers/Big.cs:12,40x2\n src/Consumers/Small.cs:3"
1101 ));
1102 assert_eq!(
1103 out.matches("src/Consumers/Big.cs").count(),
1104 1,
1105 "the repeated file path must appear exactly once"
1106 );
1107 }
1108
1109 #[test]
1110 fn refs_compact_summary_line_folds_all_counts() {
1111 let mut inbound = empty_inbound();
1112 inbound.inherits = table(
1113 vec![
1114 InboundRow {
1115 file: "src/A.cs".into(),
1116 line: 1,
1117 heuristic: false,
1118 tier: None,
1119 source: String::new(),
1120 },
1121 InboundRow {
1122 file: "src/B.cs".into(),
1123 line: 2,
1124 heuristic: false,
1125 tier: None,
1126 source: String::new(),
1127 },
1128 ],
1129 3,
1130 );
1131 let mut ambiguous = empty_ambiguous();
1132 ambiguous.inbound = table(
1133 vec![AmbiguousRow {
1134 file: "src/C.cs".into(),
1135 line: 9,
1136 origin: "uses-type".into(),
1137 raw: "Config".into(),
1138 candidate_count: 2,
1139 }],
1140 0,
1141 );
1142 let model = refs_model(inbound, empty_outbound(), ambiguous, 4);
1143 let out = render_refs_compact(&model);
1144 let last_line = out.lines().last().unwrap();
1145 assert_eq!(
1146 last_line,
1147 "summary: edges=5 shown=2 dropped=3 ambiguous=1 gap=4"
1148 );
1149 assert!(out.contains("amb:in (1):\n src/C.cs:9(candidates=2)"));
1150 assert!(
1151 !out.contains("Config"),
1152 "the raw ambiguous token is not a minimal column and must not appear"
1153 );
1154 }
1155
1156 fn impact_model(
1157 rows: Vec<ImpactRow>,
1158 total_affected: usize,
1159 dropped: usize,
1160 manifest_gap: usize,
1161 ) -> ImpactModel {
1162 let heuristic_affected = rows.iter().filter(|r| r.heuristic).count();
1163 ImpactModel {
1164 kind: SeedKind::File,
1165 seed_files: vec!["src/IFoo.cs".to_string()],
1166 hops: 2,
1167 total_affected,
1168 rows,
1169 dropped,
1170 manifest_gap,
1171 heuristic_affected,
1172 tests_affected: 0,
1173 braked: vec![],
1174 braked_files: vec![],
1175 }
1176 }
1177
1178 fn impact_row(
1180 file: &str,
1181 hop: u32,
1182 via_count: u32,
1183 ambiguous_count: u32,
1184 top_symbols: &[&str],
1185 top_symbols_more: usize,
1186 score: f64,
1187 ) -> ImpactRow {
1188 ImpactRow {
1189 file: file.into(),
1190 hop,
1191 via_count,
1192 ambiguous_count,
1193 top_symbols: top_symbols.iter().map(|s| (*s).to_string()).collect(),
1194 top_symbols_more,
1195 score,
1196 heuristic_count: 0,
1197 heuristic: false,
1198 tier: None,
1199 iface_via: vec![],
1200 from_lines: vec![],
1201 infra: false,
1202 why: Why::UsesMemberPrecise,
1203 }
1204 }
1205
1206 fn heuristic_impact_row(
1208 file: &str,
1209 hop: u32,
1210 heuristic_count: u32,
1211 top_symbols: &[&str],
1212 score: f64,
1213 ) -> ImpactRow {
1214 ImpactRow {
1215 heuristic_count,
1216 heuristic: true,
1217 ..impact_row(file, hop, 0, 0, top_symbols, 0, score)
1218 }
1219 }
1220
1221 #[test]
1222 fn impact_compact_groups_rows_into_ascending_hop_buckets() {
1223 let model = impact_model(
1224 vec![
1225 impact_row("src/Two.cs", 2, 1, 0, &["Foo"], 0, 0.1),
1226 impact_row("src/One.cs", 1, 2, 1, &["Foo", "Bar"], 5, 0.5),
1227 ],
1228 3,
1229 0,
1230 0,
1231 );
1232 let out = render_impact_compact("IFoo", &model);
1233 let hop1 = out.find("hop 1").unwrap();
1234 let hop2 = out.find("hop 2").unwrap();
1235 assert!(
1236 hop1 < hop2,
1237 "hop 1 bucket must precede hop 2 regardless of row order in the model"
1238 );
1239 assert!(out.contains("hop 1 (1):\n src/One.cs via=2(+1amb)"));
1240 assert!(out.contains("hop 2 (1):\n src/Two.cs via=1"));
1241 assert!(!out.contains("Bar"), "compact drops top-symbols entirely");
1242 assert!(out.contains("summary: affected=3 shown=2 dropped=0 ambiguous=1"));
1243 }
1244
1245 #[test]
1246 fn impact_compact_manifest_gap_folds_into_summary() {
1247 let model = impact_model(vec![], 0, 0, 2);
1248 let out = render_impact_compact("IFoo", &model);
1249 let last_line = out.lines().last().unwrap();
1250 assert_eq!(
1251 last_line,
1252 "summary: affected=0 shown=0 dropped=0 ambiguous=0 gap=2"
1253 );
1254 }
1255
1256 #[test]
1260 fn ref_kind_block_missing_table_prints_nothing() {
1261 let mut out: Vec<String> = vec!["before".to_string()];
1262 ref_kind_block::<InboundRow>(&mut out, "inherits", None, |r| {
1263 format!("{}:{}", r.file, r.line)
1264 });
1265 assert_eq!(out, vec!["before".to_string()]);
1266 }
1267
1268 #[test]
1269 fn compact_block_missing_table_prints_nothing() {
1270 let mut out: Vec<String> = vec!["before".to_string()];
1271 compact_block::<InboundRow>(
1272 &mut out,
1273 "in:inherits",
1274 None,
1275 |r| r.file.as_str(),
1276 |r| r.line.to_string(),
1277 );
1278 assert_eq!(out, vec!["before".to_string()]);
1279 }
1280
1281 #[test]
1282 fn ref_kind_block_present_but_empty_table_still_prints_header() {
1283 let mut out: Vec<String> = Vec::new();
1284 let t: Table<InboundRow> = table(vec![], 0);
1285 ref_kind_block(&mut out, "inherits", Some(&t), |r: &InboundRow| {
1286 format!("{}:{}", r.file, r.line)
1287 });
1288 assert_eq!(out, vec![" inherits (0):".to_string()]);
1289 }
1290
1291 #[test]
1292 fn compact_block_present_but_empty_table_prints_nothing_unlike_ref_kind_block() {
1293 let mut out: Vec<String> = Vec::new();
1294 let t: Table<InboundRow> = table(vec![], 0);
1295 compact_block(
1296 &mut out,
1297 "in:inherits",
1298 Some(&t),
1299 |r: &InboundRow| r.file.as_str(),
1300 |r: &InboundRow| r.line.to_string(),
1301 );
1302 assert!(out.is_empty());
1303 }
1304
1305 #[test]
1306 fn ref_kind_block_dropped_note_only_appears_when_nonzero() {
1307 let mut out: Vec<String> = Vec::new();
1308 let t = table(
1309 vec![InboundRow {
1310 file: "a.cs".into(),
1311 line: 1,
1312 heuristic: false,
1313 tier: None,
1314 source: String::new(),
1315 }],
1316 2,
1317 );
1318 ref_kind_block(&mut out, "inherits", Some(&t), |r: &InboundRow| {
1319 format!("{}:{}", r.file, r.line)
1320 });
1321 assert_eq!(out[0], " inherits (3, 2 dropped):");
1322 }
1323
1324 #[test]
1325 fn rle_collapses_consecutive_equal_runs_only() {
1326 let values = vec![
1327 "5".to_string(),
1328 "5".to_string(),
1329 "3".to_string(),
1330 "3".to_string(),
1331 "3".to_string(),
1332 "1".to_string(),
1333 ];
1334 assert_eq!(
1335 rle(&values),
1336 vec!["5x2".to_string(), "3x3".to_string(), "1".to_string()]
1337 );
1338 }
1339
1340 #[test]
1341 fn seed_kind_str_matches_js_string_literals() {
1342 assert_eq!(seed_kind_str(SeedKind::File), "file");
1343 assert_eq!(seed_kind_str(SeedKind::Symbol), "symbol");
1344 }
1345
1346 #[test]
1349 fn member_refs_line_renders_after_the_inbound_block_and_caps_the_named_members() {
1350 let mut model = refs_model(empty_inbound(), empty_outbound(), empty_ambiguous(), 0);
1351 model.member_refs = Some(query::MemberRefs {
1352 total: 3,
1353 member_count: 2,
1354 members: vec![
1355 query::MemberRefEntry {
1356 name: "EnableX".into(),
1357 count: 2,
1358 },
1359 query::MemberRefEntry {
1360 name: "EnableY".into(),
1361 count: 1,
1362 },
1363 ],
1364 dropped: 0,
1365 });
1366 let out = render_refs_text(&model);
1367 assert!(
1368 out.contains("\nmember refs: 3 across 2 member(s): EnableX 2, EnableY 1"),
1369 "{out}"
1370 );
1371 assert!(
1372 render_refs_compact(&model).contains("\nmem: EnableX=2,EnableY=1\nsummary:"),
1373 "{}",
1374 render_refs_compact(&model)
1375 );
1376
1377 model.member_refs = Some(query::MemberRefs {
1378 total: 9,
1379 member_count: 7,
1380 members: vec![query::MemberRefEntry {
1381 name: "A".into(),
1382 count: 9,
1383 }],
1384 dropped: 6,
1385 });
1386 assert!(
1387 render_refs_text(&model).contains("member refs: 9 across 7 member(s): A 9 +6 more"),
1388 "{}",
1389 render_refs_text(&model)
1390 );
1391 assert!(
1392 render_refs_compact(&model).contains("mem: A=9,+6"),
1393 "{}",
1394 render_refs_compact(&model)
1395 );
1396 }
1397
1398 #[test]
1399 fn a_model_with_no_member_refs_renders_exactly_as_before() {
1400 let out = render_refs_text(&refs_model(
1401 empty_inbound(),
1402 empty_outbound(),
1403 empty_ambiguous(),
1404 0,
1405 ));
1406 assert!(!out.contains("member refs:"), "{out}");
1407 assert!(!render_refs_compact(&refs_model(
1408 empty_inbound(),
1409 empty_outbound(),
1410 empty_ambiguous(),
1411 0
1412 ))
1413 .contains("mem: "));
1414 }
1415
1416 #[test]
1419 fn refs_text_suffixes_only_tagged_rows_and_never_an_imports_row() {
1420 let mut inbound = empty_inbound();
1421 inbound.uses_member = table(
1422 vec![
1423 InboundRow {
1424 file: "src/Fact.cs".into(),
1425 line: 4,
1426 heuristic: false,
1427 tier: None,
1428 source: String::new(),
1429 },
1430 InboundRow {
1431 file: "src/Guess.cs".into(),
1432 line: 9,
1433 heuristic: true,
1434 tier: None,
1435 source: String::new(),
1436 },
1437 ],
1438 0,
1439 );
1440 let mut outbound = empty_outbound();
1441 outbound.uses_member = table(
1442 vec![OutboundRow {
1443 file: "src/Guess.cs".into(),
1444 line: 9,
1445 to_file: "src/Widget.cs".into(),
1446 to: "App.Widget".into(),
1447 heuristic: true,
1448 tier: None,
1449 source: String::new(),
1450 }],
1451 0,
1452 );
1453 outbound.imports = table(
1457 vec![ImportRow {
1458 file: "src/Guess.cs".into(),
1459 line: 1,
1460 target: "App.Core".into(),
1461 source: String::new(),
1462 }],
1463 0,
1464 );
1465
1466 let out = render_refs_text(&refs_model(inbound, outbound, empty_ambiguous(), 0));
1467 assert!(out.contains(" src/Fact.cs:4 uses-member\n"), "{out}");
1468 assert!(
1469 out.contains(" src/Guess.cs:9 uses-member (heuristic)"),
1470 "{out}"
1471 );
1472 assert!(
1473 out.contains(" src/Guess.cs:9 uses-member -> src/Widget.cs (heuristic)"),
1474 "{out}"
1475 );
1476 assert!(
1477 out.ends_with(" src/Guess.cs:1 imports -> App.Core"),
1478 "an imports row is never suffixed\n{out}"
1479 );
1480 assert_eq!(out.matches("(heuristic)").count(), 2);
1481 }
1482
1483 #[test]
1484 fn refs_text_marks_an_extension_row_and_a_guess_row_with_their_own_words() {
1485 let row = |file: &str, line: usize, tier: Option<HeuristicTier>| InboundRow {
1486 file: file.into(),
1487 line,
1488 heuristic: tier.is_some(),
1489 tier,
1490 source: String::new(),
1491 };
1492 let mut inbound = empty_inbound();
1493 inbound.uses_member = table(
1494 vec![
1495 row("src/Fact.cs", 4, None),
1496 row("src/Ext.cs", 7, Some(HeuristicTier::Ext)),
1497 row("src/Guess.cs", 9, Some(HeuristicTier::Guess)),
1498 ],
1499 0,
1500 );
1501 let out = render_refs_text(&refs_model(inbound, empty_outbound(), empty_ambiguous(), 0));
1502 assert!(out.contains(" src/Fact.cs:4 uses-member\n"), "{out}");
1503 assert!(
1504 out.contains(" src/Ext.cs:7 uses-member (extension)"),
1505 "{out}"
1506 );
1507 assert!(
1508 out.contains(" src/Guess.cs:9 uses-member (guess)"),
1509 "{out}"
1510 );
1511 assert_eq!(out.matches("(heuristic)").count(), 0, "{out}");
1515 assert!(out.contains(" uses-member (3):"), "{out}");
1518 }
1519
1520 #[test]
1521 fn refs_compact_marks_a_heuristic_row_with_a_trailing_h_and_rle_keeps_them_distinct() {
1522 let mut inbound = empty_inbound();
1523 inbound.uses_member = table(
1524 vec![
1525 InboundRow {
1526 file: "src/A.cs".into(),
1527 line: 5,
1528 heuristic: false,
1529 tier: None,
1530 source: String::new(),
1531 },
1532 InboundRow {
1533 file: "src/A.cs".into(),
1534 line: 5,
1535 heuristic: true,
1536 tier: None,
1537 source: String::new(),
1538 },
1539 InboundRow {
1540 file: "src/A.cs".into(),
1541 line: 5,
1542 heuristic: true,
1543 tier: None,
1544 source: String::new(),
1545 },
1546 ],
1547 0,
1548 );
1549 let out = render_refs_compact(&refs_model(inbound, empty_outbound(), empty_ambiguous(), 0));
1550 assert!(
1551 out.contains("in:uses-member (3):\n src/A.cs:5,5hx2"),
1552 "`5` and `5h` are distinct RLE entries\n{out}"
1553 );
1554 }
1555
1556 #[test]
1557 fn refs_compact_marks_ext_with_x_and_guess_with_h_and_rle_keeps_them_distinct() {
1558 let row = |line: usize, tier: Option<HeuristicTier>| InboundRow {
1559 file: "src/A.cs".into(),
1560 line,
1561 heuristic: tier.is_some(),
1562 tier,
1563 source: String::new(),
1564 };
1565 let mut inbound = empty_inbound();
1566 inbound.uses_member = table(
1567 vec![
1568 row(5, None),
1569 row(5, Some(HeuristicTier::Ext)),
1570 row(5, Some(HeuristicTier::Ext)),
1571 row(5, Some(HeuristicTier::Guess)),
1572 ],
1573 0,
1574 );
1575 let out = render_refs_compact(&refs_model(inbound, empty_outbound(), empty_ambiguous(), 0));
1576 assert!(
1580 out.contains("in:uses-member (4):\n src/A.cs:5,5xx2,5h"),
1581 "{out}"
1582 );
1583 }
1584
1585 #[test]
1586 fn impact_text_declares_heuristic_reached_files_beside_the_count_only_when_there_are_some() {
1587 let with_guess = impact_model(
1588 vec![
1589 impact_row("src/Direct.cs", 1, 1, 0, &["Widget"], 0, 0.5),
1590 heuristic_impact_row("src/Guessed.cs", 1, 2, &["Widget"], 0.0),
1591 ],
1592 1,
1593 0,
1594 0,
1595 );
1596 let out = render_impact_text("Widget", &with_guess);
1597 assert!(
1598 out.contains("affected files: 1 (+1 heuristic) shown: 2 dropped: 0"),
1599 "{out}"
1600 );
1601 assert!(out.contains("src/Direct.cs 1 1 Widget\n"), "{out}");
1602 assert!(
1603 out.contains("src/Guessed.cs 1 2 Widget (heuristic)"),
1604 "the via column reports the GUESS count, never the zero viaCount\n{out}"
1605 );
1606
1607 let without = impact_model(
1608 vec![impact_row("src/Direct.cs", 1, 1, 0, &["Widget"], 0, 0.5)],
1609 1,
1610 0,
1611 0,
1612 );
1613 let out = render_impact_text("Widget", &without);
1614 assert!(
1615 out.contains("affected files: 1 shown: 1 dropped: 0"),
1616 "byte-unchanged when there is nothing to declare\n{out}"
1617 );
1618 assert!(!out.contains("heuristic"), "{out}");
1619 }
1620
1621 #[test]
1622 fn impact_text_marks_a_row_reached_only_by_an_extension_guess_as_extension() {
1623 let tiered = |file: &str, count: u32, tier: HeuristicTier| ImpactRow {
1624 heuristic_count: count,
1625 heuristic: true,
1626 tier: Some(tier),
1627 ..impact_row(file, 1, 0, 0, &["Widget"], 0, 0.0)
1628 };
1629 let model = impact_model(
1630 vec![
1631 impact_row("src/Direct.cs", 1, 1, 0, &["Widget"], 0, 0.5),
1632 tiered("src/Extended.cs", 2, HeuristicTier::Ext),
1633 tiered("src/Guessed.cs", 3, HeuristicTier::Guess),
1634 ],
1635 1,
1636 0,
1637 0,
1638 );
1639 let out = render_impact_text("Widget", &model);
1640 assert!(
1643 out.contains("affected files: 1 (+2 heuristic) shown: 3 dropped: 0"),
1644 "{out}"
1645 );
1646 assert!(
1647 out.contains("src/Extended.cs 1 2 Widget (extension)"),
1648 "{out}"
1649 );
1650 assert!(
1651 out.contains("src/Guessed.cs 1 3 Widget (guess)"),
1652 "{out}"
1653 );
1654 let compact = render_impact_compact("Widget", &model);
1656 assert!(compact.contains("src/Extended.cs via=2x"), "{compact}");
1657 assert!(compact.contains("src/Guessed.cs via=3h"), "{compact}");
1658 }
1659
1660 #[test]
1661 fn impact_compact_summary_inserts_heuristic_before_gap() {
1662 let model = impact_model(
1663 vec![heuristic_impact_row(
1664 "src/Guessed.cs",
1665 1,
1666 3,
1667 &["Widget"],
1668 0.0,
1669 )],
1670 0,
1671 0,
1672 2,
1673 );
1674 let out = render_impact_compact("Widget", &model);
1675 assert!(out.contains("hop 1 (1):\n src/Guessed.cs via=3h"), "{out}");
1676 assert_eq!(
1677 out.lines().last().unwrap(),
1678 "summary: affected=0 shown=1 dropped=0 ambiguous=0 heuristic=1 gap=2"
1679 );
1680
1681 let none = impact_model(
1683 vec![impact_row("src/Direct.cs", 1, 1, 0, &["Widget"], 0, 0.5)],
1684 1,
1685 0,
1686 2,
1687 );
1688 assert_eq!(
1689 render_impact_compact("Widget", &none)
1690 .lines()
1691 .last()
1692 .unwrap(),
1693 "summary: affected=1 shown=1 dropped=0 ambiguous=0 gap=2"
1694 );
1695 }
1696
1697 fn tests_model(rows: Vec<TestRow>) -> TestsModel {
1700 let precise: Vec<&TestRow> = rows.iter().filter(|r| !r.heuristic).collect();
1701 let guessed: Vec<&TestRow> = rows.iter().filter(|r| r.heuristic).collect();
1702 TestsModel {
1703 query: "OrderService".to_string(),
1704 symbol: "App.Orders.OrderService".to_string(),
1705 def_files: vec!["src/OrderService.cs".to_string()],
1706 test_file_count: precise.len(),
1707 ref_count: precise.iter().map(|r| r.ref_count).sum(),
1708 heuristic_file_count: guessed.len(),
1709 heuristic_ref_count: guessed.iter().map(|r| r.ref_count).sum(),
1710 rows,
1711 }
1712 }
1713
1714 fn test_row(file: &str, test_defs: &[&str], lines: &[usize], heuristic: bool) -> TestRow {
1715 TestRow {
1716 file: file.into(),
1717 test_defs: test_defs.iter().map(|s| (*s).to_string()).collect(),
1718 lines: lines.to_vec(),
1719 ref_count: lines.len(),
1720 heuristic,
1721 tier: None,
1722 via: query::TestVia::Attribute,
1723 }
1724 }
1725
1726 #[test]
1727 fn tests_text_heads_the_file_and_indents_one_line_per_test_def() {
1728 let model = tests_model(vec![test_row(
1729 "tests/OrderServiceTests.cs",
1730 &["App.Orders.Tests.OrderServiceTests"],
1731 &[10, 11],
1732 false,
1733 )]);
1734 assert_eq!(
1735 render_tests_text(&model),
1736 "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"
1737 );
1738 }
1739
1740 #[test]
1741 fn tests_text_answers_the_zero_case_in_one_line_instead_of_a_header_over_a_void() {
1742 let mut model = tests_model(vec![]);
1743 model.symbol = "App.Orders.Untested".to_string();
1744 assert_eq!(
1745 render_tests_text(&model),
1746 "tests for App.Orders.Untested\nno test references found"
1747 );
1748 }
1749
1750 #[test]
1751 fn tests_text_marks_a_guessed_file_with_the_shared_heuristic_suffix() {
1752 let model = tests_model(vec![
1753 test_row(
1754 "tests/OrderServiceTests.cs",
1755 &["App.Orders.Tests.OrderServiceTests"],
1756 &[10],
1757 false,
1758 ),
1759 test_row(
1760 "tests/Partial.Extra.cs",
1761 &["App.Orders.Tests.PartialTests"],
1762 &[9],
1763 true,
1764 ),
1765 ]);
1766 let out = render_tests_text(&model);
1767 assert!(
1768 out.contains("covered by 1 test file(s), 1 reference(s)"),
1769 "counts stay precise-only\n{out}"
1770 );
1771 assert!(
1772 out.ends_with(
1773 "tests/Partial.Extra.cs (heuristic)\n App.Orders.Tests.PartialTests lines: 9"
1774 ),
1775 "{out}"
1776 );
1777 }
1778
1779 #[test]
1780 fn tests_text_suffix_follows_the_row_tier() {
1781 let tiered = |file: &str, tier: Option<HeuristicTier>| TestRow {
1782 tier,
1783 heuristic: tier.is_some(),
1784 ..test_row(file, &["App.Orders.Tests.T"], &[9], tier.is_some())
1785 };
1786 let model = tests_model(vec![
1787 tiered("tests/Precise.cs", None),
1788 tiered("tests/Ext.cs", Some(HeuristicTier::Ext)),
1789 tiered("tests/Guess.cs", Some(HeuristicTier::Guess)),
1790 ]);
1791 let out = render_tests_text(&model);
1792 assert!(out.contains("\ntests/Precise.cs\n"), "{out}");
1793 assert!(out.contains("\ntests/Ext.cs (extension)\n"), "{out}");
1794 assert!(out.contains("\ntests/Guess.cs (guess)\n"), "{out}");
1795 assert!(
1798 out.contains("covered by 1 test file(s), 1 reference(s)"),
1799 "{out}"
1800 );
1801 assert_eq!(
1802 render_tests_compact(&model),
1803 "tests App.Orders.OrderService files=1 refs=1 heuristic=2\ntests/Precise.cs 9\ntests/Ext.cs 9x\ntests/Guess.cs 9h"
1804 );
1805 }
1806
1807 #[test]
1808 fn tests_text_marks_a_project_vouched_row_with_the_test_project_suffix_and_lists_its_lines_without_a_def_id(
1809 ) {
1810 let harness = TestRow {
1811 via: query::TestVia::Project,
1812 ..test_row("tests/App.Tests/FakeServer.cs", &[], &[12, 34], false)
1813 };
1814 let model = tests_model(vec![
1815 test_row(
1816 "tests/OrderServiceTests.cs",
1817 &["App.Orders.Tests.OrderServiceTests"],
1818 &[10],
1819 false,
1820 ),
1821 harness,
1822 ]);
1823 let out = render_tests_text(&model);
1824 assert!(
1825 out.ends_with("tests/App.Tests/FakeServer.cs (test project)\n lines: 12, 34"),
1826 "{out}"
1827 );
1828 assert!(
1830 out.contains(
1831 "tests/OrderServiceTests.cs\n App.Orders.Tests.OrderServiceTests lines: 10\n"
1832 ),
1833 "{out}"
1834 );
1835 }
1836
1837 #[test]
1838 fn tests_compact_folds_the_counts_into_the_header_and_drops_the_def_ids() {
1839 let model = tests_model(vec![test_row(
1840 "tests/OrderServiceTests.cs",
1841 &["App.Orders.Tests.OrderServiceTests"],
1842 &[10, 11],
1843 false,
1844 )]);
1845 assert_eq!(
1846 render_tests_compact(&model),
1847 "tests App.Orders.OrderService files=1 refs=2\ntests/OrderServiceTests.cs 10,11"
1848 );
1849
1850 let mut empty = tests_model(vec![]);
1851 empty.symbol = "App.Orders.Untested".to_string();
1852 assert_eq!(
1853 render_tests_compact(&empty),
1854 "tests App.Orders.Untested files=0 refs=0"
1855 );
1856 }
1857
1858 #[test]
1859 fn tests_compact_declares_heuristic_files_in_the_header_and_marks_their_lines() {
1860 let model = tests_model(vec![
1861 test_row(
1862 "tests/OrderServiceTests.cs",
1863 &["App.Orders.Tests.OrderServiceTests"],
1864 &[10],
1865 false,
1866 ),
1867 test_row(
1868 "tests/Partial.Extra.cs",
1869 &["App.Orders.Tests.PartialTests"],
1870 &[9, 12],
1871 true,
1872 ),
1873 ]);
1874 assert_eq!(
1875 render_tests_compact(&model),
1876 "tests App.Orders.OrderService files=1 refs=1 heuristic=1\ntests/OrderServiceTests.cs 10\ntests/Partial.Extra.cs 9h,12h"
1877 );
1878 }
1879
1880 #[test]
1881 fn impact_summaries_append_tests_only_when_the_blast_radius_reaches_one() {
1882 let reached = ImpactModel {
1883 tests_affected: 1,
1884 ..impact_model(
1885 vec![impact_row(
1886 "tests/OrderServiceTests.cs",
1887 1,
1888 1,
1889 0,
1890 &["OrderService"],
1891 0,
1892 0.5,
1893 )],
1894 1,
1895 0,
1896 0,
1897 )
1898 };
1899 assert!(
1900 render_impact_text("OrderService", &reached)
1901 .contains("affected files: 1 shown: 1 dropped: 0 tests=1"),
1902 "{}",
1903 render_impact_text("OrderService", &reached)
1904 );
1905 assert_eq!(
1906 render_impact_compact("OrderService", &reached)
1907 .lines()
1908 .last()
1909 .unwrap(),
1910 "summary: affected=1 shown=1 dropped=0 ambiguous=0 tests=1"
1911 );
1912
1913 let none = impact_model(
1914 vec![impact_row(
1915 "src/Other.cs",
1916 1,
1917 1,
1918 0,
1919 &["OrderService"],
1920 0,
1921 0.5,
1922 )],
1923 1,
1924 0,
1925 0,
1926 );
1927 assert!(
1928 render_impact_text("OrderService", &none)
1929 .contains("affected files: 1 shown: 1 dropped: 0\n"),
1930 "its ABSENCE is the gap signal -- never printed as tests=0"
1931 );
1932 assert_eq!(
1933 render_impact_compact("OrderService", &none)
1934 .lines()
1935 .last()
1936 .unwrap(),
1937 "summary: affected=1 shown=1 dropped=0 ambiguous=0"
1938 );
1939 }
1940
1941 #[test]
1942 fn impact_compact_orders_tests_after_heuristic_and_before_gap() {
1943 let model = ImpactModel {
1944 tests_affected: 1,
1945 ..impact_model(
1946 vec![
1947 impact_row(
1948 "tests/OrderServiceTests.cs",
1949 1,
1950 1,
1951 0,
1952 &["OrderService"],
1953 0,
1954 0.5,
1955 ),
1956 heuristic_impact_row("src/Guessed.cs", 1, 3, &["OrderService"], 0.0),
1957 ],
1958 1,
1959 0,
1960 2,
1961 )
1962 };
1963 assert_eq!(
1964 render_impact_compact("OrderService", &model)
1965 .lines()
1966 .last()
1967 .unwrap(),
1968 "summary: affected=1 shown=2 dropped=0 ambiguous=0 heuristic=1 tests=1 gap=2"
1969 );
1970 }
1971}