1use crate::report::sink::outln;
2use std::borrow::Cow;
3use std::fmt::Write as _;
4use std::path::Path;
5use std::time::Duration;
6
7use colored::Colorize;
8
9use super::health_hotspots::render_hotspots;
10use super::health_runtime::render_runtime_coverage;
11use super::health_targets::render_refactoring_targets;
12use super::{
13 MAX_FLAT_ITEMS, format_path, plural, print_explain_tip_if_tty, relative_path,
14 split_dir_filename, thousands,
15};
16use crate::health::scoring::{FileScoreConcern, file_score_concern_axis};
17
18const DOCS_HEALTH: &str = "https://docs.fallow.tools/explanations/health";
20
21pub(in crate::report) struct PrintHealthHumanInput<'a> {
22 pub(in crate::report) report: &'a fallow_output::HealthReport,
23 pub(in crate::report) root: &'a Path,
24 pub(in crate::report) elapsed: Duration,
25 pub(in crate::report) quiet: bool,
26 pub(in crate::report) show_explain_tip: bool,
27 pub(in crate::report) explain: bool,
28 pub(in crate::report) skip_score_and_trend: bool,
29 pub(in crate::report) css_requested: bool,
32}
33
34pub(in crate::report) fn print_health_human(input: &PrintHealthHumanInput<'_>) {
35 let report = input.report;
36 let root = input.root;
37 let elapsed = input.elapsed;
38 let quiet = input.quiet;
39 let show_explain_tip = input.show_explain_tip;
40 let explain = input.explain;
41 let skip_score_and_trend = input.skip_score_and_trend;
42 let css_requested = input.css_requested;
43 if !quiet {
44 eprintln!();
45 }
46
47 let has_score = report.health_score.is_some();
48 if report.findings.is_empty()
49 && report.file_scores.is_empty()
50 && report.coverage_gaps.is_none()
51 && report.hotspots.is_empty()
52 && report.targets.is_empty()
53 && report.runtime_coverage.is_none()
54 && report.coverage_intelligence.is_none()
55 && report.threshold_overrides.is_empty()
56 && report.css_analytics.is_none()
57 && !css_requested
58 && !has_score
59 {
60 print_health_empty_state(report, elapsed, quiet);
61 return;
62 }
63
64 let has_findings = !report.findings.is_empty()
65 || report.coverage_gaps.as_ref().is_some_and(|gaps| {
66 gaps.summary.untested_files > 0 || gaps.summary.untested_exports > 0
67 })
68 || report
69 .runtime_coverage
70 .as_ref()
71 .is_some_and(|coverage| !coverage.findings.is_empty());
72 print_explain_tip_if_tty(show_explain_tip && has_findings, quiet);
73
74 let lines = build_health_human_lines_with_explain(
75 report,
76 root,
77 explain,
78 skip_score_and_trend,
79 css_requested,
80 );
81 for line in lines {
82 outln!("{line}");
83 }
84
85 if !quiet {
86 print_health_final_status(report, elapsed);
87 }
88}
89
90fn print_health_empty_state(report: &fallow_output::HealthReport, elapsed: Duration, quiet: bool) {
91 if quiet {
92 return;
93 }
94
95 eprintln!(
96 "{}",
97 format!(
98 "\u{2713} No functions exceed complexity thresholds ({:.2}s)",
99 elapsed.as_secs_f64()
100 )
101 .green()
102 .bold()
103 );
104 eprintln!(
105 "{}",
106 format!(
107 " {} functions analyzed (max cyclomatic: {}, max cognitive: {}, max CRAP: {:.1})",
108 report.summary.functions_analyzed,
109 report.summary.max_cyclomatic_threshold,
110 report.summary.max_cognitive_threshold,
111 report.summary.max_crap_threshold,
112 )
113 .dimmed()
114 );
115}
116
117fn print_health_final_status(report: &fallow_output::HealthReport, elapsed: Duration) {
118 let s = &report.summary;
119 let mut parts = Vec::new();
120 parts.push(format!("{} above threshold", s.functions_above_threshold));
121 parts.push(format!("{} analyzed", s.functions_analyzed));
122 if let Some(avg) = s.average_maintainability {
123 let label = if avg >= 85.0 {
124 "good"
125 } else if avg >= 65.0 {
126 "moderate"
127 } else {
128 "low"
129 };
130 parts.push(format!("maintainability {avg:.1} ({label})"));
131 }
132 if let Some(ref production) = report.runtime_coverage {
133 parts.push(format!(
134 "{} unhit in production",
135 production.summary.functions_unhit
136 ));
137 }
138 eprintln!(
139 "{}",
140 format!(
141 "\u{2717} {} ({:.2}s)",
142 parts.join(" \u{00b7} "),
143 elapsed.as_secs_f64()
144 )
145 .red()
146 .bold()
147 );
148 if s.average_maintainability.is_some_and(|mi| mi < 85.0) {
149 eprintln!(
150 "{}",
151 " Maintainability scale: good \u{2265}85, moderate \u{2265}65, low <65 (0\u{2013}100)"
152 .dimmed()
153 );
154 }
155}
156
157#[cfg(test)]
160fn build_health_human_lines(report: &fallow_output::HealthReport, root: &Path) -> Vec<String> {
161 build_health_human_lines_with_explain(report, root, false, false, false)
162}
163
164fn build_health_human_lines_with_explain(
165 report: &fallow_output::HealthReport,
166 root: &Path,
167 explain: bool,
168 skip_score_and_trend: bool,
169 css_requested: bool,
170) -> Vec<String> {
171 let mut lines = Vec::new();
172 if !skip_score_and_trend {
173 render_health_score(&mut lines, report);
174 render_health_trend(&mut lines, report);
175 }
176 render_runtime_coverage(&mut lines, report, root);
177 render_coverage_intelligence(&mut lines, report, root);
178 render_vital_signs(&mut lines, report);
179 render_risk_profiles(&mut lines, report);
180 render_render_fan_in(&mut lines, report);
181 render_large_functions(&mut lines, report, root);
182 render_findings(&mut lines, report, root);
183 render_threshold_overrides(&mut lines, report, root);
184 render_coverage_gaps(&mut lines, report, root);
185 render_file_scores(&mut lines, report, root);
186 render_hotspots(&mut lines, report, root);
187 render_refactoring_targets(&mut lines, report, root);
188 render_css_analytics(&mut lines, report, css_requested);
189 if explain {
190 inject_explain_blocks(lines)
191 } else {
192 lines
193 }
194}
195
196fn render_css_analytics(
200 lines: &mut Vec<String>,
201 report: &fallow_output::HealthReport,
202 css_requested: bool,
203) {
204 let Some(ref css) = report.css_analytics else {
205 if css_requested && report.styling_health.is_none() {
211 lines.push(String::new());
212 lines.push("CSS health".bold().to_string());
213 lines.push(format!(
214 " {}",
215 "No stylesheets analyzed (none reachable from entry points; install dependencies for full resolution)."
216 .dimmed()
217 ));
218 }
219 return;
220 };
221
222 lines.push(String::new());
223 lines.push("CSS health".bold().to_string());
224 render_styling_health(lines, report);
225 render_css_analytics_summary(lines, &css.summary);
226 render_css_preprocessor_caveat(lines, &css.summary);
227 render_css_keyframe_candidates(lines, css);
228 render_css_unused_at_rules(lines, css);
229 render_css_scoped_unused(lines, css);
230 render_css_duplicate_blocks(lines, css);
231 render_css_tailwind_arbitrary(lines, css);
232 render_css_unresolved_classes(lines, css);
233 render_css_unreferenced_classes(lines, css);
234 render_css_unused_font_faces(lines, css);
235 render_css_unused_theme_tokens(lines, css);
236 render_css_near_duplicate_theme_tokens(lines, css);
237 render_css_font_size_unit_mix(lines, css);
238 render_css_notable_rules(lines, css);
239}
240
241fn render_styling_health(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
245 let Some(ref styling) = report.styling_health else {
246 return;
247 };
248 lines.push(format!(
249 " {} {} {}",
250 "Styling health:".cyan().bold(),
251 styling_health_colored(styling),
252 "(CSS quality, scored separately from the code health score)".dimmed(),
253 ));
254
255 if let Some(reason) = &styling.confidence_reason {
261 lines.push(format!(" {} {reason}", "Low confidence:".yellow().bold()));
262 }
263
264 let penalties = styling_health_penalties(&styling.penalties);
265 if !penalties.is_empty() {
266 lines.push(format!(
267 " {} {}",
268 "Deductions:".dimmed(),
269 render_health_score_penalties(&penalties)
270 ));
271 }
272}
273
274fn styling_health_penalties(p: &fallow_output::StylingHealthPenalties) -> Vec<(&'static str, f64)> {
279 let mut penalties = vec![
280 ("duplication", p.duplication),
281 ("dead surface", p.dead_surface),
282 ("broken refs", p.broken_references),
283 ("token erosion", p.token_erosion),
284 ("structural", p.structural),
285 ];
286 penalties.retain(|&(_, v)| v > 0.0);
287 penalties.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
288 penalties
289}
290
291fn styling_health_colored(styling: &fallow_output::StylingHealth) -> String {
292 if matches!(
297 styling.confidence,
298 fallow_output::StylingHealthConfidence::Low
299 ) {
300 return format!("~{:.0} {}", styling.score, styling.grade)
301 .dimmed()
302 .to_string();
303 }
304 let text = format!("{:.0} {}", styling.score, styling.grade);
305 if styling.score >= 85.0 {
306 text.green().bold().to_string()
307 } else if styling.score >= 70.0 {
308 text.yellow().bold().to_string()
309 } else if styling.score >= 55.0 {
310 text.yellow().to_string()
311 } else {
312 text.red().bold().to_string()
313 }
314}
315
316fn render_css_analytics_summary(
317 lines: &mut Vec<String>,
318 summary: &fallow_output::CssAnalyticsSummary,
319) {
320 let important_pct = if summary.total_declarations > 0 {
321 f64::from(summary.important_declarations) / f64::from(summary.total_declarations) * 100.0
322 } else {
323 0.0
324 };
325 lines.push(format!(
326 " {} stylesheet{} \u{00b7} {} rule{} \u{00b7} {important_pct:.1}% !important \u{00b7} {} empty \u{00b7} max nesting {}",
327 summary.files_analyzed,
328 plural(summary.files_analyzed as usize),
329 summary.total_rules,
330 plural(summary.total_rules as usize),
331 summary.empty_rules,
332 summary.max_nesting_depth,
333 ));
334 render_css_value_sprawl(lines, summary);
335 if summary.notable_truncated_files > 0 {
336 lines.push(
337 format!(
338 " (per-rule detail truncated in {} file{}; see --format json)",
339 summary.notable_truncated_files,
340 plural(summary.notable_truncated_files as usize),
341 )
342 .dimmed()
343 .to_string(),
344 );
345 }
346 if summary.custom_properties_defined > 0 || summary.custom_properties_undefined > 0 {
350 let undefined = if summary.custom_properties_undefined > 0 {
351 format!(", {} undefined", summary.custom_properties_undefined)
352 } else {
353 String::new()
354 };
355 lines.push(format!(
356 " custom properties: {} defined, {} unreferenced in CSS{undefined} (candidates; may be set from JS)",
357 summary.custom_properties_defined,
358 summary.custom_properties_unreferenced,
359 ));
360 }
361}
362
363fn render_css_scoped_unused(lines: &mut Vec<String>, css: &fallow_output::CssAnalyticsReport) {
364 let summary = &css.summary;
365 if !css.scoped_unused.is_empty() {
366 let class_word = if summary.scoped_unused_classes == 1 {
367 "class"
368 } else {
369 "classes"
370 };
371 lines.push(format!(
372 " {} unused scoped {class_word} in {} Vue SFC{} (candidates; verify)",
373 summary.scoped_unused_classes,
374 css.scoped_unused.len(),
375 plural(css.scoped_unused.len()),
376 ));
377 for entry in css.scoped_unused.iter().take(5) {
378 lines.push(format!(" {}: {}", entry.path, entry.classes.join(", ")));
379 }
380 if css.scoped_unused.len() > 5 {
381 let more = css.scoped_unused.len() - 5;
382 lines.push(
383 format!(
384 " ... and {more} more SFC{} (--format json for full list)",
385 plural(more),
386 )
387 .dimmed()
388 .to_string(),
389 );
390 }
391 }
392}
393
394fn sorted_css_notable_rules(
395 css: &fallow_output::CssAnalyticsReport,
396) -> Vec<(&str, &fallow_types::extract::CssRuleMetric)> {
397 let mut notable: Vec<(&str, &fallow_types::extract::CssRuleMetric)> = css
398 .files
399 .iter()
400 .flat_map(|file| {
401 file.analytics
402 .notable_rules
403 .iter()
404 .map(move |rule| (file.path.as_str(), rule))
405 })
406 .collect();
407 notable.sort_by(|a, b| {
408 let key = |m: &fallow_types::extract::CssRuleMetric| {
409 (
410 m.specificity_a,
411 m.specificity_b,
412 m.specificity_c,
413 m.complexity,
414 m.important_count,
415 )
416 };
417 key(b.1)
420 .cmp(&key(a.1))
421 .then_with(|| (a.0, a.1.line).cmp(&(b.0, b.1.line)))
422 });
423 notable
424}
425
426fn render_css_notable_rules(lines: &mut Vec<String>, css: &fallow_output::CssAnalyticsReport) {
427 let notable = sorted_css_notable_rules(css);
428 let total_notable = notable.len();
429
430 for (path, rule) in notable.iter().take(5) {
431 lines.push(format!(
432 " {path}:{} specificity ({},{},{}) \u{00b7} complexity {} \u{00b7} {} !important \u{00b7} nesting {}",
433 rule.line,
434 rule.specificity_a,
435 rule.specificity_b,
436 rule.specificity_c,
437 rule.complexity,
438 rule.important_count,
439 rule.nesting_depth,
440 ));
441 }
442 if total_notable > 5 {
443 let more = total_notable - 5;
444 lines.push(
445 format!(" ... and {more} more (--format json for full list)")
446 .dimmed()
447 .to_string(),
448 );
449 }
450}
451
452fn render_css_keyframe_candidates(
455 lines: &mut Vec<String>,
456 css: &fallow_output::CssAnalyticsReport,
457) {
458 let summary = &css.summary;
459 if summary.keyframes_defined > 0 {
460 if css.unreferenced_keyframes.is_empty() {
461 lines.push(format!(
462 " @keyframes: {} defined, 0 unreferenced",
463 summary.keyframes_defined,
464 ));
465 } else {
466 let listed = join_located_keyframes(
467 css.unreferenced_keyframes
468 .iter()
469 .map(|kf| (kf.name.as_str(), kf.path.as_str())),
470 css.unreferenced_keyframes.len(),
471 );
472 lines.push(format!(
473 " @keyframes: {} defined, {} unreferenced (candidates; verify): {listed}",
474 summary.keyframes_defined, summary.keyframes_unreferenced,
475 ));
476 }
477 }
478 if !css.undefined_keyframes.is_empty() {
479 let listed = join_located_keyframes(
480 css.undefined_keyframes
481 .iter()
482 .map(|kf| (kf.name.as_str(), kf.path.as_str())),
483 css.undefined_keyframes.len(),
484 );
485 lines.push(format!(
486 " undefined @keyframes: {} referenced but defined nowhere (candidates; likely typo or defined in CSS-in-JS): {listed}",
487 summary.keyframes_undefined,
488 ));
489 }
490}
491
492fn render_css_unused_at_rules(lines: &mut Vec<String>, css: &fallow_output::CssAnalyticsReport) {
496 use fallow_output::UnusedAtRuleKind;
497 if css.unused_at_rules.is_empty() {
498 return;
499 }
500 let render_kind = |lines: &mut Vec<String>, kind: UnusedAtRuleKind, label: &str, what: &str| {
501 let named: Vec<String> = css
502 .unused_at_rules
503 .iter()
504 .filter(|e| e.kind == kind)
505 .take(5)
506 .map(|e| format!("{} ({})", e.name, e.path))
507 .collect();
508 if named.is_empty() {
509 return;
510 }
511 let total = css
512 .unused_at_rules
513 .iter()
514 .filter(|e| e.kind == kind)
515 .count();
516 let more = if total > 5 {
517 format!(", +{} more", total - 5)
518 } else {
519 String::new()
520 };
521 lines.push(format!(
522 " {label}: {total} {what} (candidates; verify): {}{more}",
523 named.join(", ")
524 ));
525 };
526 render_kind(
527 lines,
528 UnusedAtRuleKind::PropertyRegistration,
529 "unused @property",
530 "registered but never used via var()",
531 );
532 render_kind(
533 lines,
534 UnusedAtRuleKind::Layer,
535 "unused @layer",
536 "declared but never populated",
537 );
538}
539
540fn render_css_unresolved_classes(lines: &mut Vec<String>, css: &fallow_output::CssAnalyticsReport) {
543 if css.unresolved_class_references.is_empty() {
544 return;
545 }
546 let total = css.unresolved_class_references.len();
547 lines.push(format!(
548 " {total} likely class typo{} (candidates; verify, may be defined in CSS-in-JS or an external stylesheet):",
549 plural(total),
550 ));
551 for entry in css.unresolved_class_references.iter().take(5) {
552 lines.push(format!(
553 " {}:{}: \"{}\" -> did you mean \"{}\"?",
554 entry.path, entry.line, entry.class, entry.suggestion,
555 ));
556 }
557 if total > 5 {
558 let more = total - 5;
559 lines.push(
560 format!(" ... and {more} more (--format json for full list)")
561 .dimmed()
562 .to_string(),
563 );
564 }
565}
566
567fn render_css_unreferenced_classes(
570 lines: &mut Vec<String>,
571 css: &fallow_output::CssAnalyticsReport,
572) {
573 if css.unreferenced_css_classes.is_empty() {
574 return;
575 }
576 let total = css.unreferenced_css_classes.len();
577 lines.push(format!(
578 " {total} global CSS class{} referenced by no in-project markup (candidates; verify no email / server template / CMS / Markdown applies them):",
579 if total == 1 { "" } else { "es" },
580 ));
581 for entry in css.unreferenced_css_classes.iter().take(5) {
582 lines.push(format!(" {}:{}: .{}", entry.path, entry.line, entry.class));
583 }
584 if total > 5 {
585 let more = total - 5;
586 lines.push(
587 format!(" ... and {more} more (--format json for full list)")
588 .dimmed()
589 .to_string(),
590 );
591 }
592}
593
594fn render_css_unused_font_faces(lines: &mut Vec<String>, css: &fallow_output::CssAnalyticsReport) {
597 if css.unused_font_faces.is_empty() {
598 return;
599 }
600 let total = css.unused_font_faces.len();
601 lines.push(format!(
602 " {total} unused @font-face{} (declared but applied by no font-family; candidates, may be set from JS/inline):",
603 plural(total),
604 ));
605 for entry in css.unused_font_faces.iter().take(5) {
606 lines.push(format!(" {}: {}", entry.path, entry.family));
607 }
608 if total > 5 {
609 let more = total - 5;
610 lines.push(
611 format!(" ... and {more} more (--format json for full list)")
612 .dimmed()
613 .to_string(),
614 );
615 }
616}
617
618fn render_css_unused_theme_tokens(
622 lines: &mut Vec<String>,
623 css: &fallow_output::CssAnalyticsReport,
624) {
625 if css.unused_theme_tokens.is_empty() {
626 return;
627 }
628 let total = css.unused_theme_tokens.len();
629 lines.push(format!(
630 " {total} Tailwind @theme token{} used by no utility, var(), or @apply (candidates; verify not consumed by a plugin or a downstream repo):",
631 plural(total),
632 ));
633 for entry in css.unused_theme_tokens.iter().take(5) {
634 lines.push(format!(" {}:{}: {}", entry.path, entry.line, entry.token));
635 }
636 if total > 5 {
637 let more = total - 5;
638 lines.push(
639 format!(" ... and {more} more (--format json for full list)")
640 .dimmed()
641 .to_string(),
642 );
643 }
644}
645
646fn render_css_near_duplicate_theme_tokens(
649 lines: &mut Vec<String>,
650 css: &fallow_output::CssAnalyticsReport,
651) {
652 if css.near_duplicate_theme_tokens.is_empty() {
653 return;
654 }
655 let total = css.near_duplicate_theme_tokens.len();
656 lines.push(format!(
657 " {total} near-duplicate Tailwind @theme token{} (candidates; verify semantic intent before reusing the nearest token):",
658 plural(total),
659 ));
660 for entry in css.near_duplicate_theme_tokens.iter().take(5) {
661 lines.push(format!(
662 " {}:{}: {} ~= {} (distance {:.2})",
663 entry.path,
664 entry.line,
665 entry.token,
666 entry.nearest_token.name,
667 entry.nearest_token.distance
668 ));
669 }
670 if total > 5 {
671 let more = total - 5;
672 lines.push(
673 format!(" ... and {more} more (--format json for full list)")
674 .dimmed()
675 .to_string(),
676 );
677 }
678}
679
680fn render_css_font_size_unit_mix(lines: &mut Vec<String>, css: &fallow_output::CssAnalyticsReport) {
684 let Some(mix) = &css.font_size_unit_mix else {
685 return;
686 };
687 let breakdown = mix
688 .notations
689 .iter()
690 .map(|n| format!("{} {}", n.count, n.notation))
691 .collect::<Vec<_>>()
692 .join(", ");
693 lines.push(format!(
694 " font sizes mix {} units ({breakdown}; candidate, standardize unless intentional)",
695 mix.notations.len(),
696 ));
697}
698
699fn render_css_preprocessor_caveat(
700 lines: &mut Vec<String>,
701 summary: &fallow_output::CssAnalyticsSummary,
702) {
703 if !summary.preprocessor_reachability_abstained {
704 return;
705 }
706 lines.push(format!(
707 " {}",
708 format!(
709 "Sass/Less reachability skipped: {} preprocessor stylesheet{} outnumber plain CSS, so generated classes were not treated as dead or broken.",
710 summary.preprocessor_stylesheets,
711 plural(summary.preprocessor_stylesheets as usize)
712 )
713 .dimmed()
714 ));
715}
716
717fn render_css_value_sprawl(lines: &mut Vec<String>, summary: &fallow_output::CssAnalyticsSummary) {
720 lines.push(format!(
721 " value sprawl: {} distinct color{} \u{00b7} {} font size{} \u{00b7} {} z-index value{}",
722 summary.unique_colors,
723 plural(summary.unique_colors as usize),
724 summary.unique_font_sizes,
725 plural(summary.unique_font_sizes as usize),
726 summary.unique_z_indexes,
727 plural(summary.unique_z_indexes as usize),
728 ));
729 let mut extra: Vec<String> = Vec::new();
730 if summary.unique_box_shadows > 0 {
731 extra.push(format!(
732 "{} shadow{}",
733 summary.unique_box_shadows,
734 plural(summary.unique_box_shadows as usize)
735 ));
736 }
737 if summary.unique_border_radii > 0 {
738 extra.push(format!(
739 "{} radius value{}",
740 summary.unique_border_radii,
741 plural(summary.unique_border_radii as usize)
742 ));
743 }
744 if summary.unique_line_heights > 0 {
745 extra.push(format!(
746 "{} line-height{}",
747 summary.unique_line_heights,
748 plural(summary.unique_line_heights as usize)
749 ));
750 }
751 if !extra.is_empty() {
752 lines.push(format!(
759 " value sprawl (cont.): {} (candidates; tokenize repeated values via custom properties)",
760 extra.join(" \u{00b7} ")
761 ));
762 }
763}
764
765fn render_css_tailwind_arbitrary(lines: &mut Vec<String>, css: &fallow_output::CssAnalyticsReport) {
769 if css.tailwind_arbitrary_values.is_empty() {
770 return;
771 }
772 let summary = &css.summary;
773 lines.push(format!(
774 " Tailwind arbitrary values: {} distinct ({} use{}) bypassing the scale (candidates; add a scale token or confirm one-off)",
775 summary.tailwind_arbitrary_values,
776 summary.tailwind_arbitrary_value_uses,
777 plural(summary.tailwind_arbitrary_value_uses as usize),
778 ));
779 for arb in css.tailwind_arbitrary_values.iter().take(5) {
780 lines.push(format!(
781 " {} ({}x): {}:{}",
782 arb.value, arb.count, arb.path, arb.line
783 ));
784 }
785 if css.tailwind_arbitrary_values.len() > 5 {
786 let more = css.tailwind_arbitrary_values.len() - 5;
787 lines.push(
788 format!(" ... and {more} more (--format json for full list)")
789 .dimmed()
790 .to_string(),
791 );
792 }
793}
794
795fn render_css_duplicate_blocks(lines: &mut Vec<String>, css: &fallow_output::CssAnalyticsReport) {
798 if css.duplicate_declaration_blocks.is_empty() {
799 return;
800 }
801 let summary = &css.summary;
802 let group_word = if summary.duplicate_declaration_blocks == 1 {
803 "group"
804 } else {
805 "groups"
806 };
807 lines.push(format!(
808 " duplicate declaration blocks: {} {group_word}, {} declarations removable (candidates; consolidate or confirm intentional overrides)",
809 summary.duplicate_declaration_blocks, summary.duplicate_declarations_total,
810 ));
811 for block in css.duplicate_declaration_blocks.iter().take(5) {
812 let locs = block
813 .occurrences
814 .iter()
815 .take(3)
816 .map(|occ| format!("{}:{}", occ.path, occ.line))
817 .collect::<Vec<_>>()
818 .join(", ");
819 let extra = block.occurrences.len().saturating_sub(3);
820 let more = if extra > 0 {
821 format!(", +{extra} more")
822 } else {
823 String::new()
824 };
825 lines.push(format!(
826 " {} declarations in {} rules: {locs}{more}",
827 block.declaration_count, block.occurrence_count,
828 ));
829 }
830 if css.duplicate_declaration_blocks.len() > 5 {
831 let more = css.duplicate_declaration_blocks.len() - 5;
832 lines.push(
833 format!(" ... and {more} more (--format json for full list)")
834 .dimmed()
835 .to_string(),
836 );
837 }
838}
839
840fn join_located_keyframes<'a>(
843 items: impl Iterator<Item = (&'a str, &'a str)>,
844 total: usize,
845) -> String {
846 let named = items
847 .take(5)
848 .map(|(name, path)| format!("{name} ({path})"))
849 .collect::<Vec<_>>()
850 .join(", ");
851 let extra = total.saturating_sub(5);
852 if extra > 0 {
853 format!("{named}, +{extra} more")
854 } else {
855 named
856 }
857}
858
859fn render_coverage_intelligence(
860 lines: &mut Vec<String>,
861 report: &fallow_output::HealthReport,
862 root: &Path,
863) {
864 let Some(ref intelligence) = report.coverage_intelligence else {
865 return;
866 };
867
868 lines.push(String::new());
869 lines.push("Coverage intelligence".bold().to_string());
870 lines.push(
871 format!(" Verdict: {}", intelligence.verdict)
872 .bold()
873 .to_string(),
874 );
875 if intelligence.findings.is_empty() {
876 if intelligence.summary.skipped_ambiguous_matches > 0 {
877 let match_word = if intelligence.summary.skipped_ambiguous_matches == 1 {
878 "match"
879 } else {
880 "matches"
881 };
882 lines.push(format!(
883 " No actionable findings; skipped {} ambiguous evidence {match_word}.",
884 intelligence.summary.skipped_ambiguous_matches
885 ));
886 }
887 return;
888 }
889 for finding in intelligence.findings.iter().take(MAX_FLAT_ITEMS) {
890 let relative = relative_path(&finding.path, root);
891 let identity = finding
892 .identity
893 .as_deref()
894 .map_or(String::new(), |name| format!(" {name}"));
895 let signals = finding
896 .signals
897 .iter()
898 .map(ToString::to_string)
899 .collect::<Vec<_>>()
900 .join(", ");
901 let action = finding
902 .actions
903 .first()
904 .map_or("Review this finding", |action| action.description.as_str());
905 lines.push(format!(
906 " {}:{}{} {} [{}]",
907 format_path(&relative.display().to_string()),
908 finding.line,
909 identity,
910 finding.verdict,
911 signals,
912 ));
913 lines.push(format!(" {action}"));
914 }
915}
916
917fn inject_explain_blocks(lines: Vec<String>) -> Vec<String> {
918 let mut out = Vec::with_capacity(lines.len());
919 for line in lines {
920 let explain = health_explain_for_header(&line);
921 out.push(line);
922 if let Some(text) = explain {
923 out.push(format!(" {}", format!("Description: {text}").dimmed()));
924 }
925 }
926 out
927}
928
929fn health_explain_for_header(line: &str) -> Option<String> {
930 if line.contains("Runtime coverage:") {
931 return rule_full("fallow/runtime-coverage");
932 }
933 if line.contains("Health score:") {
934 return Some(
935 "The 0-100 project health grade combines dead code, complexity, maintainability, duplication, dependency, hotspot, and coverage signals when available."
936 .to_string(),
937 );
938 }
939 if line.contains("Metrics:") {
940 return Some(
941 "Vital signs summarize the analyzed project before truncation: dead-code percentages, maintainability index, hotspot count, circular dependencies, unused dependencies, and duplication where available."
942 .to_string(),
943 );
944 }
945 if line.contains("Large functions (") {
946 return rule_full("fallow/high-cyclomatic-complexity");
947 }
948 if line.contains("High complexity functions (") {
949 return rule_full("fallow/high-complexity");
950 }
951 if line.contains("Coverage gaps (") {
952 return Some(
953 "Coverage gaps identify runtime-reachable files or exports with no static path from discovered test entry points."
954 .to_string(),
955 );
956 }
957 if line.contains("Hotspots (") {
958 return Some(
959 "Hotspots combine recent churn with complexity so frequently changed risky files surface before quieter debt."
960 .to_string(),
961 );
962 }
963 if line.contains("Refactoring targets (") {
964 return rule_full("fallow/refactoring-target");
965 }
966 None
967}
968
969fn rule_full(id: &str) -> Option<String> {
970 crate::explain::rule_by_id(id).map(|rule| rule.full.to_string())
971}
972
973pub(in crate::report) fn format_window(seconds: u64) -> String {
979 if seconds < 60 {
980 return format!("{seconds} s");
981 }
982 let minutes = seconds / 60;
983 if minutes < 120 {
984 return format!("{minutes} min");
985 }
986 let hours = minutes / 60;
987 if hours < 48 {
988 format!("{hours} h")
989 } else {
990 format!("{} d", hours / 24)
991 }
992}
993
994pub fn render_health_score(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
995 let Some(ref hs) = report.health_score else {
996 return;
997 };
998
999 lines.push(format!(
1000 "{} {} {}",
1001 "\u{25cf}".cyan(),
1002 "Health score:".cyan().bold(),
1003 health_score_colored(hs),
1004 ));
1005
1006 let p = &hs.penalties;
1007 let penalties = health_score_penalties(p);
1008 if !penalties.is_empty() {
1009 lines.push(format!(
1010 " {} {}",
1011 "Deductions:".dimmed(),
1012 render_health_score_penalties(&penalties)
1013 ));
1014 }
1015 if let Some(na_line) = health_score_na_line(p) {
1016 lines.push(format!(" {}", na_line.dimmed()));
1017 }
1018 if p.duplication.is_some_and(|dp| dp >= 5.0) {
1019 lines.push(format!(
1020 " {}",
1021 "Tip: add \"dist\" or \"__generated__\" to health.ignore in your config to exclude from duplication analysis"
1022 .dimmed()
1023 ));
1024 }
1025 lines.push(String::new());
1026}
1027
1028fn health_score_colored(hs: &fallow_output::HealthScore) -> String {
1029 let score_str = format!("{:.0}", hs.score);
1030 let grade_str = hs.grade;
1031 if hs.score >= 85.0 {
1032 format!("{score_str} {grade_str}")
1033 .green()
1034 .bold()
1035 .to_string()
1036 } else if hs.score >= 70.0 {
1037 format!("{score_str} {grade_str}")
1038 .yellow()
1039 .bold()
1040 .to_string()
1041 } else if hs.score >= 55.0 {
1042 format!("{score_str} {grade_str}").yellow().to_string()
1043 } else {
1044 format!("{score_str} {grade_str}").red().bold().to_string()
1045 }
1046}
1047
1048fn health_score_penalties(p: &fallow_output::HealthScorePenalties) -> Vec<(&'static str, f64)> {
1049 let mut penalties = Vec::new();
1050 push_optional_penalty(&mut penalties, "dead files", p.dead_files);
1051 push_optional_penalty(&mut penalties, "dead exports", p.dead_exports);
1052 penalties.push(("complexity", p.complexity));
1053 penalties.push(("p90", p.p90_complexity));
1054 push_optional_penalty(&mut penalties, "maintainability", p.maintainability);
1055 push_optional_penalty(&mut penalties, "hotspots", p.hotspots);
1056 push_optional_penalty(&mut penalties, "unused deps", p.unused_deps);
1057 push_optional_penalty(&mut penalties, "circular deps", p.circular_deps);
1058 push_optional_penalty(&mut penalties, "unit size", p.unit_size);
1059 push_optional_penalty(&mut penalties, "coupling", p.coupling);
1060 push_optional_penalty(&mut penalties, "duplication", p.duplication);
1061 penalties.retain(|&(_, v)| v > 0.0);
1062 penalties.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1063 penalties
1064}
1065
1066fn push_optional_penalty(
1067 penalties: &mut Vec<(&'static str, f64)>,
1068 label: &'static str,
1069 value: Option<f64>,
1070) {
1071 if let Some(value) = value {
1072 penalties.push((label, value));
1073 }
1074}
1075
1076fn render_health_score_penalties(penalties: &[(&str, f64)]) -> String {
1077 let parts: Vec<String> = penalties
1078 .iter()
1079 .enumerate()
1080 .map(|(i, &(label, val))| {
1081 let text = format!("{label} -{val:.1}");
1082 if i == 0 {
1083 text.yellow().to_string()
1084 } else {
1085 text.dimmed().to_string()
1086 }
1087 })
1088 .collect();
1089 parts.join(&format!(" {} ", "\u{00b7}".dimmed()))
1090}
1091
1092fn health_score_na_line(p: &fallow_output::HealthScorePenalties) -> Option<String> {
1093 let mut na_parts = Vec::new();
1094 if p.dead_files.is_none() {
1095 na_parts.push("dead code");
1096 }
1097 if p.maintainability.is_none() {
1098 na_parts.push("maintainability");
1099 }
1100 if p.hotspots.is_none() {
1101 na_parts.push("hotspots");
1102 }
1103 (!na_parts.is_empty()).then(|| {
1104 format!(
1105 "N/A: {} (enable the corresponding analysis flags)",
1106 na_parts.join(", ")
1107 )
1108 })
1109}
1110
1111fn fmt_trend_val(v: f64, unit: &str) -> String {
1113 if unit == "%" {
1114 format!("{v:.1}%")
1115 } else if (v - v.round()).abs() < 0.05 {
1116 format!("{v:.0}")
1117 } else {
1118 format!("{v:.1}")
1119 }
1120}
1121
1122fn fmt_trend_delta(v: f64, unit: &str) -> String {
1124 if unit == "%" {
1125 format!("{v:+.1}%")
1126 } else if (v - v.round()).abs() < 0.05 {
1127 format!("{v:+.0}")
1128 } else {
1129 format!("{v:+.1}")
1130 }
1131}
1132
1133pub fn render_health_trend(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
1134 let Some(ref trend) = report.health_trend else {
1135 return;
1136 };
1137
1138 use fallow_output::TrendDirection;
1139
1140 push_trend_header_line(lines, trend);
1141 push_trend_model_notes(lines, trend, report);
1142
1143 let all_stable = trend
1144 .metrics
1145 .iter()
1146 .all(|m| m.direction == TrendDirection::Stable);
1147 if all_stable {
1148 lines.push(format!(
1149 " {}",
1150 format!("All {} metrics unchanged", trend.metrics.len()).dimmed()
1151 ));
1152 lines.push(String::new());
1153 return;
1154 }
1155
1156 push_trend_metric_rows(lines, trend);
1157 lines.push(String::new());
1158}
1159
1160fn push_trend_header_line(lines: &mut Vec<String>, trend: &fallow_output::HealthTrend) {
1162 use fallow_output::TrendDirection;
1163
1164 let date = trend
1165 .compared_to
1166 .timestamp
1167 .get(..10)
1168 .unwrap_or(&trend.compared_to.timestamp);
1169 let sha_str = trend
1170 .compared_to
1171 .git_sha
1172 .as_deref()
1173 .map_or(String::new(), |sha| format!(" \u{00b7} {sha}"));
1174 let direction_label = format!(
1175 "{} {}",
1176 trend.overall_direction.arrow(),
1177 trend.overall_direction.label()
1178 );
1179 let direction_colored = match trend.overall_direction {
1180 TrendDirection::Improving => direction_label.green().bold().to_string(),
1181 TrendDirection::Declining => direction_label.red().bold().to_string(),
1182 TrendDirection::Stable => direction_label.dimmed().to_string(),
1183 };
1184 lines.push(format!(
1185 "{} {} {} {}",
1186 "\u{25cf}".cyan(),
1187 "Trend:".cyan().bold(),
1188 direction_colored,
1189 format!("(vs {date}{sha_str})").dimmed(),
1190 ));
1191}
1192
1193fn push_trend_model_notes(
1195 lines: &mut Vec<String>,
1196 trend: &fallow_output::HealthTrend,
1197 report: &fallow_output::HealthReport,
1198) {
1199 if let (Some(prev_model), Some(cur_model)) = (
1200 &trend.compared_to.coverage_model,
1201 &report.summary.coverage_model,
1202 ) && prev_model != cur_model
1203 {
1204 let prev_str = serde_json::to_string(prev_model).unwrap_or_default();
1205 let cur_str = serde_json::to_string(cur_model).unwrap_or_default();
1206 lines.push(format!(
1207 " {}",
1208 format!(
1209 "note: CRAP model changed ({} \u{2192} {}); score delta may reflect model change, not code change",
1210 prev_str.trim_matches('"'),
1211 cur_str.trim_matches('"'),
1212 )
1213 .yellow()
1214 ));
1215 }
1216
1217 if let Some(prev_version) = trend.compared_to.snapshot_schema_version
1218 && prev_version < fallow_output::SNAPSHOT_SCHEMA_VERSION
1219 {
1220 lines.push(format!(
1221 " {}",
1222 format!(
1223 "note: snapshot schema updated to v{} (added total LOC vital sign); score comparison still valid",
1224 fallow_output::SNAPSHOT_SCHEMA_VERSION
1225 )
1226 .yellow()
1227 ));
1228 }
1229}
1230
1231fn push_trend_metric_rows(lines: &mut Vec<String>, trend: &fallow_output::HealthTrend) {
1233 use fallow_output::TrendDirection;
1234
1235 for m in &trend.metrics {
1236 let label = format!("{:<18}", m.label);
1237 let prev_str = fmt_trend_val(m.previous, m.unit);
1238 let cur_str = fmt_trend_val(m.current, m.unit);
1239 let delta_str = fmt_trend_delta(m.delta, m.unit);
1240
1241 let direction_str = match m.direction {
1242 TrendDirection::Improving => format!("{} {}", m.direction.arrow(), m.direction.label())
1243 .green()
1244 .to_string(),
1245 TrendDirection::Declining => format!("{} {}", m.direction.arrow(), m.direction.label())
1246 .red()
1247 .to_string(),
1248 TrendDirection::Stable => format!("{} {}", m.direction.arrow(), m.direction.label())
1249 .dimmed()
1250 .to_string(),
1251 };
1252
1253 let values = format!("{prev_str:>8} {cur_str:<8}");
1254 lines.push(format!(
1255 " {label} {values} {delta_str:<10} {direction_str}"
1256 ));
1257 }
1258}
1259
1260fn render_vital_signs(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
1261 if report.health_trend.is_some() {
1262 return;
1263 }
1264 let Some(ref vs) = report.vital_signs else {
1265 return;
1266 };
1267
1268 let mut parts = Vec::new();
1269 push_vital_core_parts(&mut parts, vs);
1270 push_vital_signal_parts(&mut parts, vs, report);
1271 lines.push(format!(
1272 "{} {} {}",
1273 "\u{25a0}".dimmed(),
1274 "Metrics:".dimmed(),
1275 parts.join(" \u{00b7} ").dimmed()
1276 ));
1277 lines.push(String::new());
1278}
1279
1280fn push_vital_core_parts(parts: &mut Vec<String>, vs: &fallow_output::VitalSigns) {
1282 if vs.total_loc > 0 {
1283 parts.push(format!("{} LOC", thousands(vs.total_loc as usize)));
1284 }
1285 if let Some(dfp) = vs.dead_file_pct {
1286 parts.push(format!("dead files {dfp:.1}%"));
1287 }
1288 if let Some(dep) = vs.dead_export_pct {
1289 parts.push(format!("dead exports {dep:.1}%"));
1290 }
1291 parts.push(format!("avg cyclomatic {:.1}", vs.avg_cyclomatic));
1292 parts.push(format!("p90 cyclomatic {}", vs.p90_cyclomatic));
1293 if let Some(mi) = vs.maintainability_avg {
1294 let label = if mi >= 85.0 {
1295 "good"
1296 } else if mi >= 65.0 {
1297 "moderate"
1298 } else {
1299 "low"
1300 };
1301 parts.push(format!("maintainability {mi:.1} ({label})"));
1302 }
1303}
1304
1305fn push_vital_signal_parts(
1307 parts: &mut Vec<String>,
1308 vs: &fallow_output::VitalSigns,
1309 report: &fallow_output::HealthReport,
1310) {
1311 if let Some(hc) = vs.hotspot_count {
1312 let since_suffix = report
1313 .hotspot_summary
1314 .as_ref()
1315 .map(|s| format!(" (since {})", s.since))
1316 .unwrap_or_default();
1317 parts.push(format!(
1318 "{hc} churn hotspot{}{since_suffix}",
1319 plural(hc as usize)
1320 ));
1321 }
1322 if let Some(cd) = vs.circular_dep_count
1323 && cd > 0
1324 {
1325 parts.push(format!(
1326 "{cd} circular {}",
1327 if cd == 1 { "dep" } else { "deps" }
1328 ));
1329 }
1330 if let Some(ud) = vs.unused_dep_count
1331 && ud > 0
1332 {
1333 parts.push(format!(
1334 "{ud} unused {}",
1335 if ud == 1 { "dep" } else { "deps" }
1336 ));
1337 }
1338 if let Some(dp) = vs.duplication_pct {
1339 parts.push(format!("duplication {dp:.1}%"));
1340 }
1341}
1342
1343fn render_risk_profiles(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
1344 let Some(ref vs) = report.vital_signs else {
1345 return;
1346 };
1347
1348 let format_profile = |profile: &fallow_output::RiskProfile| -> String {
1349 format!(
1350 "{:.0}% low \u{00b7} {:.0}% medium \u{00b7} {:.0}% high \u{00b7} {:.0}% very high",
1351 profile.low_risk, profile.medium_risk, profile.high_risk, profile.very_high_risk
1352 )
1353 };
1354
1355 let before = lines.len();
1356
1357 if let Some(ref profile) = vs.unit_size_profile
1358 && profile.very_high_risk >= 3.0
1359 {
1360 lines.push(format!(
1361 " {} {} {}",
1362 "Function size:".dimmed(),
1363 format_profile(profile).dimmed(),
1364 "(1-15 / 16-30 / 31-60 / >60 LOC)".dimmed()
1365 ));
1366 }
1367
1368 if let Some(ref profile) = vs.unit_interfacing_profile
1369 && (profile.very_high_risk > 0.0 || profile.high_risk > 1.0)
1370 {
1371 lines.push(format!(
1372 " {} {} {}",
1373 "Parameters:".dimmed(),
1374 format_profile(profile).dimmed(),
1375 "(0-2 / 3-4 / 5-6 / >=7 params)".dimmed()
1376 ));
1377 }
1378
1379 if lines.len() > before {
1380 lines.push(String::new());
1381 }
1382}
1383
1384fn render_render_fan_in(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
1395 let Some(ref vs) = report.vital_signs else {
1396 return;
1397 };
1398 if vs.top_render_fan_in.is_empty() {
1399 return;
1400 }
1401
1402 const MAX_SHOWN: usize = 5;
1403 let shown: Vec<String> = vs
1404 .top_render_fan_in
1405 .iter()
1406 .take(MAX_SHOWN)
1407 .map(|c| {
1408 format!(
1409 "<{}> {} parent{} ({} incl. repeats)",
1410 c.component,
1411 c.distinct_parents,
1412 plural(c.distinct_parents as usize),
1413 c.render_sites,
1414 )
1415 })
1416 .collect();
1417
1418 lines.push(format!(
1419 " {} {}",
1420 "Render fan-in:".dimmed(),
1421 shown.join(" \u{00b7} ").dimmed()
1422 ));
1423 lines.push(String::new());
1424}
1425
1426fn render_large_functions(
1427 lines: &mut Vec<String>,
1428 report: &fallow_output::HealthReport,
1429 root: &Path,
1430) {
1431 if report.large_functions.is_empty() {
1432 return;
1433 }
1434
1435 let total = report.large_functions.len();
1436 let shown = total.min(MAX_FLAT_ITEMS);
1437 lines.push(format!(
1438 "{} {}",
1439 "\u{25cf}".red(),
1440 if shown < total {
1441 format!("Large functions ({shown} shown, {total} total)")
1442 } else {
1443 format!("Large functions ({total})")
1444 }
1445 .red()
1446 .bold()
1447 ));
1448
1449 let mut last_file = String::new();
1450 for entry in report.large_functions.iter().take(MAX_FLAT_ITEMS) {
1451 let file_str = relative_path(&entry.path, root).display().to_string();
1452 if file_str != last_file {
1453 lines.push(format!(" {}", format_path(&file_str)));
1454 last_file = file_str;
1455 }
1456 lines.push(format!(
1457 " {} {} {} lines",
1458 format!(":{}", entry.line).dimmed(),
1459 entry.name.bold(),
1460 format!("{:>3}", entry.line_count).red().bold(),
1461 ));
1462 }
1463 lines.push(format!(
1464 " {}",
1465 format!("Functions exceeding 60 lines of code (very high risk): {DOCS_HEALTH}#unit-size")
1466 .dimmed()
1467 ));
1468 if shown < total {
1469 lines.push(format!(
1470 " {}",
1471 format!("use --top {total} to see all").dimmed()
1472 ));
1473 }
1474 lines.push(String::new());
1475}
1476
1477fn append_suppression_hints(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
1487 let has_html_template = report.findings.iter().any(|finding| {
1488 finding.name == "<template>"
1489 && finding
1490 .path
1491 .extension()
1492 .and_then(|ext| ext.to_str())
1493 .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
1494 });
1495 let has_inline_template = report.findings.iter().any(|finding| {
1496 finding.name == "<template>"
1497 && finding
1498 .path
1499 .extension()
1500 .and_then(|ext| ext.to_str())
1501 .is_none_or(|ext| !ext.eq_ignore_ascii_case("html"))
1502 });
1503 let has_component_rollup = report
1504 .findings
1505 .iter()
1506 .any(|finding| finding.name == "<component>");
1507 let has_function_finding = report
1508 .findings
1509 .iter()
1510 .any(|finding| finding.name != "<template>" && finding.name != "<component>");
1511 if has_html_template {
1512 lines.push(format!(
1513 " {}",
1514 "To suppress HTML templates: <!-- fallow-ignore-file complexity -->".dimmed()
1515 ));
1516 }
1517 if has_inline_template {
1518 lines.push(format!(
1519 " {}",
1520 "To suppress inline templates: // fallow-ignore-next-line complexity (above @Component)"
1521 .dimmed()
1522 ));
1523 }
1524 if has_component_rollup {
1525 lines.push(format!(
1526 " {}",
1527 "To suppress a <component> rollup: suppress the worst class method (// fallow-ignore-next-line complexity above it hides both)"
1528 .dimmed()
1529 ));
1530 }
1531 if has_function_finding && report.findings.len() >= 3 {
1532 lines.push(format!(
1533 " {}",
1534 "To suppress: // fallow-ignore-next-line complexity".dimmed()
1535 ));
1536 }
1537}
1538
1539fn render_component_rollup_breakdown(
1552 finding: &fallow_output::ComplexityViolation,
1553 root: &Path,
1554) -> Option<String> {
1555 let rollup = finding.component_rollup.as_ref()?;
1556 let template_display = crate::report::format_display_path(&rollup.template_path, root);
1557 Some(format!(
1558 " {}",
1559 format!(
1560 "rolled up: {}cyc {}cog on `{}.{}` + {}cyc {}cog on {}",
1561 rollup.class_cyclomatic,
1562 rollup.class_cognitive,
1563 rollup.component,
1564 rollup.class_worst_function,
1565 rollup.template_cyclomatic,
1566 rollup.template_cognitive,
1567 template_display,
1568 )
1569 .dimmed(),
1570 ))
1571}
1572
1573fn render_findings(lines: &mut Vec<String>, report: &fallow_output::HealthReport, root: &Path) {
1574 if report.findings.is_empty() {
1575 return;
1576 }
1577
1578 push_findings_header(lines, report);
1579 if let Some(note) = crap_coverage_note(report) {
1580 lines.push(format!(" {}", note.dimmed()));
1581 }
1582
1583 let mut last_file = String::new();
1584 for finding in &report.findings {
1585 push_finding_file_header(lines, finding, root, &mut last_file);
1586 push_finding_metric_rows(lines, finding, report, root);
1587 }
1588 let scope = if has_synthetic_complexity_entries(report) {
1589 "Functions and synthetic template or component entries"
1590 } else {
1591 "Functions"
1592 };
1593 lines.push(format!(
1594 " {}",
1595 format!("{scope} exceeding cyclomatic, cognitive, or CRAP thresholds ({DOCS_HEALTH}#complexity-metrics)")
1596 .dimmed()
1597 ));
1598 append_suppression_hints(lines, report);
1599 if report.findings.len() < report.summary.functions_above_threshold {
1600 let total = report.summary.functions_above_threshold;
1601 lines.push(format!(
1602 " {}",
1603 format!("use --top {total} to see all").dimmed()
1604 ));
1605 }
1606 lines.push(String::new());
1607}
1608
1609fn push_findings_header(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
1610 let subject = if has_synthetic_complexity_entries(report) {
1611 "High complexity findings"
1612 } else {
1613 "High complexity functions"
1614 };
1615 let title = if report.findings.len() < report.summary.functions_above_threshold {
1616 format!(
1617 "{subject} ({} shown, {} total)",
1618 report.findings.len(),
1619 report.summary.functions_above_threshold
1620 )
1621 } else {
1622 format!("{subject} ({})", report.summary.functions_above_threshold)
1623 };
1624 lines.push(format!("{} {}", "\u{25cf}".red(), title.red().bold()));
1625}
1626
1627fn has_synthetic_complexity_entries(report: &fallow_output::HealthReport) -> bool {
1628 report
1629 .findings
1630 .iter()
1631 .any(|finding| matches!(finding.name.as_str(), "<template>" | "<component>"))
1632}
1633
1634fn display_complexity_entry_name(name: &str) -> Cow<'_, str> {
1635 match name {
1636 "<template>" => Cow::Borrowed("<template> (template complexity)"),
1637 "<component>" => Cow::Borrowed("<component> (component rollup)"),
1638 _ => Cow::Borrowed(name),
1639 }
1640}
1641
1642fn push_finding_file_header(
1643 lines: &mut Vec<String>,
1644 finding: &fallow_output::ComplexityViolation,
1645 root: &Path,
1646 last_file: &mut String,
1647) {
1648 let file_str = crate::report::format_display_path(&finding.path, root);
1649 if file_str != *last_file {
1650 lines.push(format!(" {}", format_path(&file_str)));
1651 *last_file = file_str;
1652 }
1653}
1654
1655fn push_finding_metric_rows(
1656 lines: &mut Vec<String>,
1657 finding: &fallow_output::ComplexityViolation,
1658 report: &fallow_output::HealthReport,
1659 root: &Path,
1660) {
1661 let thresholds = finding_thresholds(finding, report);
1662 lines.push(format!(
1663 " {} {}{}{}",
1664 format!(":{}", finding.line).dimmed(),
1665 display_complexity_entry_name(&finding.name).as_ref().bold(),
1666 finding_severity_tag(finding),
1667 finding_generated_tag(finding),
1668 ));
1669 lines.push(format!(
1670 " {} cyclomatic {} cognitive {} lines",
1671 threshold_colored(finding.cyclomatic, thresholds.max_cyclomatic),
1672 threshold_colored(finding.cognitive, thresholds.max_cognitive),
1673 format!("{:>3}", finding.line_count).dimmed(),
1674 ));
1675 if let Some(line) = render_react_context(finding) {
1676 lines.push(line);
1677 }
1678 if let Some(line) = render_blast_radius_context(finding, report) {
1679 lines.push(line);
1680 }
1681 if let Some(line) = render_component_rollup_breakdown(finding, root) {
1682 lines.push(line);
1683 }
1684 if let Some(line) = finding_crap_line(finding, root) {
1685 lines.push(line);
1686 }
1687}
1688
1689fn render_react_context(finding: &fallow_output::ComplexityViolation) -> Option<String> {
1699 if finding.react_prop_count == 0
1700 && finding.react_hook_count == 0
1701 && finding.react_jsx_max_depth == 0
1702 {
1703 return None;
1704 }
1705 let mut parts: Vec<String> = Vec::new();
1706 if finding.react_prop_count > 0 {
1707 parts.push(format!("{} props", finding.react_prop_count));
1708 }
1709 if finding.react_hook_count > 0 {
1710 let breakdown = finding
1711 .react_hook_profile
1712 .as_ref()
1713 .map(hook_breakdown_fragment)
1714 .filter(|b| !b.is_empty());
1715 match breakdown {
1716 Some(breakdown) => {
1717 parts.push(format!("{} hooks ({breakdown})", finding.react_hook_count));
1718 }
1719 None => parts.push(format!("{} hooks", finding.react_hook_count)),
1720 }
1721 }
1722 if let Some(arity) = finding
1723 .react_hook_profile
1724 .as_ref()
1725 .and_then(|p| p.max_effect_dep_arity)
1726 {
1727 parts.push(format!("max effect deps {arity}"));
1728 }
1729 if finding.react_jsx_max_depth > 0 {
1730 parts.push(format!("JSX depth {}", finding.react_jsx_max_depth));
1731 }
1732 Some(format!(
1733 " {}",
1734 format!("react: {}", parts.join(", ")).dimmed()
1735 ))
1736}
1737
1738const BLAST_RADIUS_MIN_SITES: u32 = 2;
1743
1744fn render_blast_radius_context(
1753 finding: &fallow_output::ComplexityViolation,
1754 report: &fallow_output::HealthReport,
1755) -> Option<String> {
1756 let (component, render_sites) = report.render_fan_in_top.get(&finding.path)?;
1757 if *render_sites < BLAST_RADIUS_MIN_SITES {
1758 return None;
1759 }
1760 Some(format!(
1761 " {}",
1762 format!("blast radius: <{component}> rendered in {render_sites} places").dimmed()
1763 ))
1764}
1765
1766fn hook_breakdown_fragment(profile: &fallow_output::ReactHookProfile) -> String {
1770 let mut segments: Vec<String> = Vec::new();
1771 if profile.state > 0 {
1772 segments.push(format!("{} state", profile.state));
1773 }
1774 if profile.effect > 0 {
1775 segments.push(format!("{} effect", profile.effect));
1776 }
1777 if profile.memo > 0 {
1778 segments.push(format!("{} memo", profile.memo));
1779 }
1780 if profile.callback > 0 {
1781 segments.push(format!("{} callback", profile.callback));
1782 }
1783 if profile.custom > 0 {
1784 segments.push(format!("{} custom", profile.custom));
1785 }
1786 segments.join(", ")
1787}
1788
1789fn finding_thresholds(
1790 finding: &fallow_output::ComplexityViolation,
1791 report: &fallow_output::HealthReport,
1792) -> fallow_output::HealthEffectiveThresholds {
1793 finding
1794 .effective_thresholds
1795 .unwrap_or(fallow_output::HealthEffectiveThresholds {
1796 max_cyclomatic: report.summary.max_cyclomatic_threshold,
1797 max_cognitive: report.summary.max_cognitive_threshold,
1798 max_crap: report.summary.max_crap_threshold,
1799 })
1800}
1801
1802fn threshold_colored(value: u16, threshold: u16) -> String {
1803 let formatted = format!("{value:>3}");
1804 if value > threshold {
1805 formatted.red().bold().to_string()
1806 } else {
1807 formatted.dimmed().to_string()
1808 }
1809}
1810
1811fn finding_severity_tag(finding: &fallow_output::ComplexityViolation) -> String {
1812 match finding.severity {
1813 fallow_output::FindingSeverity::Critical => format!(" {}", "CRITICAL".red().bold()),
1814 fallow_output::FindingSeverity::High => format!(" {}", "HIGH".yellow().bold()),
1815 fallow_output::FindingSeverity::Moderate => String::new(),
1816 }
1817}
1818
1819fn finding_generated_tag(finding: &fallow_output::ComplexityViolation) -> String {
1820 if is_likely_generated(&finding.name, finding.cyclomatic) {
1821 format!(" {}", "(generated)".dimmed())
1822 } else {
1823 String::new()
1824 }
1825}
1826
1827fn finding_crap_line(finding: &fallow_output::ComplexityViolation, root: &Path) -> Option<String> {
1828 let crap = finding.crap?;
1829 let crap_colored = format!("{crap:>5.1}").red().bold().to_string();
1830 let coverage_suffix = if let Some(pct) = finding.coverage_pct {
1831 format!(" ({pct:.0}% tested)")
1832 } else if matches!(
1833 finding.coverage_source,
1834 Some(fallow_output::CoverageSource::EstimatedComponentInherited)
1835 ) && let Some(ref owner) = finding.inherited_from
1836 {
1837 let owner_display = crate::report::format_display_path(owner, root);
1838 format!(" (inherited from {owner_display})")
1839 } else {
1840 String::new()
1841 };
1842 Some(format!(
1843 " {crap_colored} CRAP{}",
1844 coverage_suffix.dimmed(),
1845 ))
1846}
1847
1848fn render_threshold_overrides(
1849 lines: &mut Vec<String>,
1850 report: &fallow_output::HealthReport,
1851 root: &Path,
1852) {
1853 if report.threshold_overrides.is_empty() {
1854 return;
1855 }
1856
1857 lines.push(format!(
1858 "{} {}",
1859 "\u{25cf}".yellow(),
1860 format!(
1861 "Health threshold overrides ({})",
1862 report.threshold_overrides.len()
1863 )
1864 .yellow()
1865 .bold()
1866 ));
1867 for entry in &report.threshold_overrides {
1868 let status = match entry.status {
1869 fallow_output::ThresholdOverrideStatus::Active => "active",
1870 fallow_output::ThresholdOverrideStatus::Stale => "stale",
1871 fallow_output::ThresholdOverrideStatus::NoMatch => "no_match",
1872 };
1873 let target = entry.path.as_ref().map_or_else(
1874 || "<no matching file or function>".to_string(),
1875 |path| {
1876 let display = crate::report::format_display_path(path, root);
1877 entry
1878 .function
1879 .as_ref()
1880 .map_or_else(|| display.clone(), |name| format!("{display}:{name}"))
1881 },
1882 );
1883 let metrics = entry.metrics.map_or(String::new(), |metrics| {
1884 let crap = metrics
1885 .crap
1886 .map_or(String::new(), |value| format!(" crap={value:.1}"));
1887 format!(
1888 " cyclomatic={} cognitive={}{}",
1889 metrics.cyclomatic, metrics.cognitive, crap
1890 )
1891 });
1892 lines.push(format!(
1893 " #{idx} {status} {target}{metrics}",
1894 idx = entry.override_index
1895 ));
1896 }
1897 lines.push(String::new());
1898}
1899
1900fn crap_coverage_note(report: &fallow_output::HealthReport) -> Option<String> {
1901 if !report.findings.iter().any(|finding| finding.crap.is_some()) {
1902 return None;
1903 }
1904
1905 let istanbul_counts = (
1906 report.summary.istanbul_matched,
1907 report.summary.istanbul_total,
1908 );
1909 let has_istanbul_counts = matches!(istanbul_counts, (Some(_), Some(total)) if total > 0);
1910
1911 if matches!(
1912 report.summary.coverage_model,
1913 Some(fallow_output::CoverageModel::Istanbul)
1914 ) || has_istanbul_counts
1915 {
1916 let match_info = match (
1917 report.summary.istanbul_matched,
1918 report.summary.istanbul_total,
1919 ) {
1920 (Some(matched), Some(total)) if total > 0 && matched < total => {
1921 return Some(format!(
1922 "CRAP scores use Istanbul coverage where matched ({matched}/{total} functions); unmatched functions are estimated from export references."
1923 ));
1924 }
1925 (Some(matched), Some(total)) if total > 0 => {
1926 format!(" ({matched}/{total} functions matched)")
1927 }
1928 _ => String::new(),
1929 };
1930 return Some(format!(
1931 "CRAP scores use Istanbul coverage data{match_info}."
1932 ));
1933 }
1934
1935 Some(
1936 "CRAP scores are estimated from export references; run `fallow health --coverage <coverage-final.json>` for exact scores."
1937 .to_string(),
1938 )
1939}
1940
1941fn is_likely_generated(name: &str, cyclomatic: u16) -> bool {
1943 if name.starts_with("validate")
1944 && name.len() > 8
1945 && name[8..].chars().all(|c| c.is_ascii_digit())
1946 {
1947 return true;
1948 }
1949 if cyclomatic > 200 && (name == "module.exports" || name == "default" || name == "<anonymous>")
1950 {
1951 return true;
1952 }
1953 false
1954}
1955
1956fn render_file_scores(lines: &mut Vec<String>, report: &fallow_output::HealthReport, root: &Path) {
1957 if report.file_scores.is_empty() {
1958 return;
1959 }
1960
1961 push_file_scores_header(lines, report.file_scores.len());
1962
1963 let shown_scores = report.file_scores.len().min(MAX_FLAT_ITEMS);
1964 for score in &report.file_scores[..shown_scores] {
1965 render_file_score_row(lines, score, root);
1966 }
1967 push_file_scores_overflow(lines, report.file_scores.len());
1968 let crap_note = file_scores_crap_note(report);
1969 lines.push(format!(
1970 " {}",
1971 format!("Sorted by triage concern: the larger of low-MI concern and CRAP risk. The risk / structure tag marks which one placed each file. MI reflects complexity, coupling, and dead code; risk reflects untested complexity (CRAP) and can diverge from MI. Risk: low <15, moderate 15-30, high >=30. {crap_note} {DOCS_HEALTH}#file-health-scores").dimmed()
1972 ));
1973 lines.push(String::new());
1974}
1975
1976fn push_file_scores_header(lines: &mut Vec<String>, score_count: usize) {
1977 lines.push(format!(
1978 "{} {} {}",
1979 "\u{25cf}".cyan(),
1980 format!("File health scores ({score_count} files)")
1981 .cyan()
1982 .bold(),
1983 "\u{b7} sorted by triage concern".dimmed(),
1984 ));
1985 lines.push(String::new());
1986}
1987
1988fn render_file_score_row(
1989 lines: &mut Vec<String>,
1990 score: &fallow_output::FileHealthScore,
1991 root: &Path,
1992) {
1993 let file_str = relative_path(&score.path, root).display().to_string();
1994 let (dir, filename) = split_dir_filename(&file_str);
1995 const CONCERN_TAG_COLUMN: usize = 48;
1996 let pad = CONCERN_TAG_COLUMN
1997 .saturating_sub(file_str.chars().count())
1998 .max(2);
1999 lines.push(format!(
2000 " {} {}{}{}{}",
2001 maintainability_colored(score.maintainability_index),
2002 dir.dimmed(),
2003 filename,
2004 " ".repeat(pad),
2005 file_score_concern_colored(score),
2006 ));
2007 lines.push(format!(
2008 " {} LOC {} fan-in {} fan-out {} dead {} density{}",
2009 format!("{:>6}", score.lines).dimmed(),
2010 format!("{:>3}", score.fan_in).dimmed(),
2011 format!("{:>3}", score.fan_out).dimmed(),
2012 format!("{:>3.0}%", score.dead_code_ratio * 100.0).dimmed(),
2013 format!("{:.2}", score.complexity_density).dimmed(),
2014 file_score_risk_suffix(score),
2015 ));
2016 lines.push(String::new());
2017}
2018
2019fn maintainability_colored(mi: f64) -> String {
2020 let mi_str = format!("{mi:>5.1}");
2021 if mi >= 80.0 {
2022 mi_str.green().to_string()
2023 } else if mi >= 50.0 {
2024 mi_str.yellow().to_string()
2025 } else {
2026 mi_str.red().bold().to_string()
2027 }
2028}
2029
2030fn file_score_concern_colored(score: &fallow_output::FileHealthScore) -> String {
2031 let label = file_score_concern_axis(score).label();
2032 match file_score_concern_axis(score) {
2033 FileScoreConcern::Risk => {
2034 if score.crap_max >= 30.0 {
2035 label.red().bold().to_string()
2036 } else if score.crap_max >= 15.0 {
2037 label.yellow().to_string()
2038 } else {
2039 label.dimmed().to_string()
2040 }
2041 }
2042 FileScoreConcern::Structural => {
2043 if score.maintainability_index < 50.0 {
2044 label.red().bold().to_string()
2045 } else if score.maintainability_index < 80.0 {
2046 label.yellow().to_string()
2047 } else {
2048 label.dimmed().to_string()
2049 }
2050 }
2051 }
2052}
2053
2054fn file_score_risk_suffix(score: &fallow_output::FileHealthScore) -> String {
2055 if score.crap_max <= 0.0 {
2056 return String::new();
2057 }
2058 let risk_str = if score.crap_max > 999.0 {
2059 ">999".to_string()
2060 } else {
2061 format!("{:.1}", score.crap_max)
2062 };
2063 let risk_colored = if score.crap_max >= 30.0 {
2064 risk_str.red().bold().to_string()
2065 } else if score.crap_max >= 15.0 {
2066 risk_str.yellow().to_string()
2067 } else {
2068 risk_str.dimmed().to_string()
2069 };
2070 format!(" {risk_colored} risk")
2071}
2072
2073fn push_file_scores_overflow(lines: &mut Vec<String>, score_count: usize) {
2074 if score_count <= MAX_FLAT_ITEMS {
2075 return;
2076 }
2077 lines.push(format!(
2078 " {}",
2079 format!(
2080 "... and {} more files (--format json for full list)",
2081 score_count - MAX_FLAT_ITEMS
2082 )
2083 .dimmed()
2084 ));
2085 lines.push(String::new());
2086}
2087
2088fn file_scores_crap_note(report: &fallow_output::HealthReport) -> String {
2089 if matches!(
2090 report.summary.coverage_model,
2091 Some(fallow_output::CoverageModel::Istanbul)
2092 ) {
2093 let match_info = match (
2094 report.summary.istanbul_matched,
2095 report.summary.istanbul_total,
2096 ) {
2097 (Some(m), Some(t)) if t > 0 => format!(" ({m}/{t} functions matched)"),
2098 _ => String::new(),
2099 };
2100 format!("CRAP from Istanbul coverage data{match_info}.")
2101 } else {
2102 "CRAP estimated from export references (85% direct, 40% indirect, 0% untested). Run `fallow health --coverage <coverage-final.json>` for exact scores.".to_string()
2103 }
2104}
2105
2106fn render_coverage_gaps(
2107 lines: &mut Vec<String>,
2108 report: &fallow_output::HealthReport,
2109 root: &Path,
2110) {
2111 let Some(ref gaps) = report.coverage_gaps else {
2112 return;
2113 };
2114
2115 push_coverage_gaps_header(lines, gaps);
2116 push_coverage_gap_files(lines, gaps, root);
2117 push_coverage_gap_exports(lines, gaps, root);
2118 lines.push(format!(
2119 " {}",
2120 format!(
2121 "Static test dependency gaps (not line-level coverage): {DOCS_HEALTH}#coverage-gaps"
2122 )
2123 .dimmed()
2124 ));
2125 lines.push(String::new());
2126}
2127
2128fn push_coverage_gaps_header(lines: &mut Vec<String>, gaps: &fallow_output::CoverageGaps) {
2129 lines.push(format!(
2130 "{} {}",
2131 "\u{25cf}".yellow(),
2132 format!(
2133 "Coverage gaps ({} untested {}, {} untested {}, {:.1}% file coverage)",
2134 gaps.summary.untested_files,
2135 if gaps.summary.untested_files == 1 {
2136 "file"
2137 } else {
2138 "files"
2139 },
2140 gaps.summary.untested_exports,
2141 if gaps.summary.untested_exports == 1 {
2142 "export"
2143 } else {
2144 "exports"
2145 },
2146 gaps.summary.file_coverage_pct,
2147 )
2148 .yellow()
2149 .bold()
2150 ));
2151 lines.push(String::new());
2152}
2153
2154fn push_coverage_gap_files(
2155 lines: &mut Vec<String>,
2156 gaps: &fallow_output::CoverageGaps,
2157 root: &Path,
2158) {
2159 if gaps.files.is_empty() {
2160 return;
2161 }
2162 let shown_files = gaps.files.len().min(MAX_FLAT_ITEMS);
2163 lines.push(format!(" {}", "Files".dimmed()));
2164 for item in &gaps.files[..shown_files] {
2165 let file_str = relative_path(&item.file.path, root).display().to_string();
2166 let (dir, filename) = split_dir_filename(&file_str);
2167 lines.push(format!(" {}{}", dir.dimmed(), filename));
2168 }
2169 if gaps.files.len() > MAX_FLAT_ITEMS {
2170 lines.push(format!(
2171 " {}",
2172 format!(
2173 "... and {} more files (--format json for full list)",
2174 gaps.files.len() - MAX_FLAT_ITEMS
2175 )
2176 .dimmed()
2177 ));
2178 }
2179 lines.push(String::new());
2180}
2181
2182fn push_coverage_gap_exports(
2183 lines: &mut Vec<String>,
2184 gaps: &fallow_output::CoverageGaps,
2185 root: &Path,
2186) {
2187 if gaps.exports.is_empty() {
2188 return;
2189 }
2190 lines.push(format!(" {}", "Exports".dimmed()));
2191
2192 let by_file = group_coverage_gap_exports_by_file(&gaps.exports);
2193 let shown = push_coverage_gap_export_rows(lines, &by_file, root);
2194 let total_exports = gaps.exports.len();
2195 if total_exports > shown {
2196 lines.push(format!(
2197 " {}",
2198 format!(
2199 "... and {} more exports (--format json for full list)",
2200 total_exports - shown
2201 )
2202 .dimmed()
2203 ));
2204 }
2205 lines.push(String::new());
2206}
2207
2208fn group_coverage_gap_exports_by_file(
2209 exports: &[fallow_output::UntestedExportFinding],
2210) -> Vec<(&std::path::Path, Vec<&fallow_output::UntestedExportFinding>)> {
2211 let mut by_file: Vec<(&std::path::Path, Vec<&fallow_output::UntestedExportFinding>)> =
2212 Vec::new();
2213 for item in exports {
2214 match by_file.last_mut() {
2215 Some((path, items)) if *path == item.export.path.as_path() => items.push(item),
2216 _ => by_file.push((item.export.path.as_path(), vec![item])),
2217 }
2218 }
2219 by_file
2220}
2221
2222fn push_coverage_gap_export_rows(
2223 lines: &mut Vec<String>,
2224 by_file: &[(&std::path::Path, Vec<&fallow_output::UntestedExportFinding>)],
2225 root: &Path,
2226) -> usize {
2227 let mut shown = 0;
2228 for (file_path, exports) in by_file {
2229 if shown >= MAX_FLAT_ITEMS {
2230 break;
2231 }
2232 let file_str = relative_path(file_path, root).display().to_string();
2233 if exports.len() > 10 {
2234 lines.push(format!(
2235 " {} ({} untested re-exports)",
2236 file_str.dimmed(),
2237 exports.len(),
2238 ));
2239 shown += 1;
2240 } else {
2241 shown += push_coverage_gap_export_names(lines, exports, &file_str, shown);
2242 }
2243 }
2244 shown
2245}
2246
2247fn push_coverage_gap_export_names(
2248 lines: &mut Vec<String>,
2249 exports: &[&fallow_output::UntestedExportFinding],
2250 file_str: &str,
2251 mut shown: usize,
2252) -> usize {
2253 let before = shown;
2254 for item in exports {
2255 if shown >= MAX_FLAT_ITEMS {
2256 break;
2257 }
2258 lines.push(format!(
2259 " {}:{} `{}`",
2260 file_str.dimmed(),
2261 item.export.line,
2262 item.export.export_name,
2263 ));
2264 shown += 1;
2265 }
2266 shown - before
2267}
2268
2269pub(in crate::report) fn print_health_summary(
2271 report: &fallow_output::HealthReport,
2272 elapsed: Duration,
2273 quiet: bool,
2274 heading: bool,
2275) {
2276 let s = &report.summary;
2277
2278 if heading {
2279 outln!("{}", "Health Summary".bold());
2280 outln!();
2281 }
2282 print_health_summary_metrics(report);
2283 print_health_summary_coverage(report);
2284
2285 if !quiet {
2286 eprintln!(
2287 "{}",
2288 format!(
2289 "\u{2713} {} functions analyzed ({:.2}s)",
2290 s.functions_analyzed,
2291 elapsed.as_secs_f64()
2292 )
2293 .green()
2294 .bold()
2295 );
2296 }
2297}
2298
2299fn print_health_summary_metrics(report: &fallow_output::HealthReport) {
2301 let s = &report.summary;
2302 outln!(" {:>6} Functions analyzed", s.functions_analyzed);
2303 outln!(" {:>6} Above threshold", s.functions_above_threshold);
2304 if let Some(mi) = s.average_maintainability {
2305 let label = if mi >= 85.0 {
2306 "good"
2307 } else if mi >= 65.0 {
2308 "moderate"
2309 } else {
2310 "low"
2311 };
2312 outln!(" {mi:>5.1} Average maintainability ({label})");
2313 }
2314 if let Some(ref score) = report.health_score {
2315 outln!(" {:>5.0} {} Health score", score.score, score.grade);
2316 }
2317}
2318
2319fn print_health_summary_coverage(report: &fallow_output::HealthReport) {
2321 if let Some(ref gaps) = report.coverage_gaps {
2322 outln!(
2323 " {:>6} Untested {} ({:.1}% file coverage)",
2324 gaps.summary.untested_files,
2325 if gaps.summary.untested_files == 1 {
2326 "file"
2327 } else {
2328 "files"
2329 },
2330 gaps.summary.file_coverage_pct,
2331 );
2332 outln!(
2333 " {:>6} Untested {}",
2334 gaps.summary.untested_exports,
2335 if gaps.summary.untested_exports == 1 {
2336 "export"
2337 } else {
2338 "exports"
2339 },
2340 );
2341 }
2342 if let Some(ref production) = report.runtime_coverage {
2343 outln!(
2344 " {:>6} Unhit in production",
2345 production.summary.functions_unhit,
2346 );
2347 outln!(
2348 " {:>6} Untracked by V8 (lazy-parsed / worker / dynamic)",
2349 production.summary.functions_untracked,
2350 );
2351 }
2352}
2353
2354pub(in crate::report) fn print_health_grouping(
2375 grouping: &fallow_output::HealthGrouping,
2376 _root: &Path,
2377 quiet: bool,
2378) {
2379 if grouping.groups.is_empty() {
2380 return;
2381 }
2382 if !quiet {
2383 eprintln!();
2384 }
2385 outln!(
2386 "{} {}",
2387 "\u{25cf}".cyan(),
2388 format!("Per-{} health", grouping.mode).cyan().bold()
2389 );
2390 let key_width = grouping
2391 .groups
2392 .iter()
2393 .map(|g| g.key.len())
2394 .max()
2395 .unwrap_or(0)
2396 .max(8);
2397 let any_score = grouping.groups.iter().any(|g| g.health_score.is_some());
2398 let any_vitals = grouping.groups.iter().any(|g| g.vital_signs.is_some());
2399
2400 let mut ordered: Vec<&fallow_output::HealthGroup> = grouping.groups.iter().collect();
2401 if any_score {
2402 ordered.sort_by(|a, b| {
2403 let a_score = a.health_score.as_ref().map_or(f64::INFINITY, |hs| hs.score);
2404 let b_score = b.health_score.as_ref().map_or(f64::INFINITY, |hs| hs.score);
2405 a_score
2406 .partial_cmp(&b_score)
2407 .unwrap_or(std::cmp::Ordering::Equal)
2408 });
2409 }
2410
2411 outln!(
2412 "{}",
2413 grouping_header(key_width, any_score, any_vitals).dimmed()
2414 );
2415
2416 let mut has_root_bucket = false;
2417 for group in ordered {
2418 if group.key == "(root)" {
2419 has_root_bucket = true;
2420 }
2421 outln!("{}", grouping_row(group, key_width, any_score, any_vitals));
2422 }
2423 if !quiet {
2424 if has_root_bucket {
2425 eprintln!(
2426 " {}",
2427 "(root) = files outside any workspace package".dimmed()
2428 );
2429 }
2430 eprintln!(
2431 " {}",
2432 "per-group summary only; --format json includes per-group findings, file scores, and hotspots"
2433 .dimmed()
2434 );
2435 }
2436}
2437
2438fn grouping_header(key_width: usize, any_score: bool, any_vitals: bool) -> String {
2441 let mut header = format!(" {:<width$}", "", width = key_width);
2442 if any_score {
2443 let _ = write!(header, " {:>9} grade", "score");
2444 }
2445 let _ = write!(header, " {:>5}", "files");
2446 let _ = write!(header, " {:>3}", "hot");
2447 if any_vitals {
2448 let _ = write!(header, " {:>3}", "p90");
2449 }
2450 header
2451}
2452
2453fn grouping_row(
2455 group: &fallow_output::HealthGroup,
2456 key_width: usize,
2457 any_score: bool,
2458 any_vitals: bool,
2459) -> String {
2460 let mut row = format!(" {:<width$}", group.key, width = key_width);
2461 if any_score {
2462 if let Some(ref hs) = group.health_score {
2463 let grade_colored = colorize_grade(hs.grade);
2464 let _ = write!(row, " {:>9.1} {}", hs.score, grade_colored);
2465 } else {
2466 row.push_str(" ");
2467 }
2468 }
2469 let _ = write!(row, " {:>5}", group.files_analyzed);
2470 let _ = write!(row, " {:>3}", group.hotspots.len());
2471 if any_vitals {
2472 if let Some(ref vs) = group.vital_signs {
2473 let _ = write!(row, " {:>3}", vs.p90_cyclomatic);
2474 } else {
2475 row.push_str(" ");
2476 }
2477 }
2478 row
2479}
2480
2481fn colorize_grade(grade: &str) -> String {
2483 match grade {
2484 "A" | "B" => grade.green().to_string(),
2485 "C" => grade.yellow().to_string(),
2486 _ => grade.red().to_string(),
2487 }
2488}
2489
2490#[cfg(test)]
2491mod tests {
2492 use super::super::{plain, strip_ansi};
2493 use super::*;
2494 use std::path::PathBuf;
2495
2496 #[test]
2497 fn health_empty_findings_produces_no_header() {
2498 let root = PathBuf::from("/project");
2499 let report = fallow_output::HealthReport {
2500 summary: fallow_output::HealthSummary {
2501 files_analyzed: 10,
2502 functions_analyzed: 50,
2503 ..Default::default()
2504 },
2505 ..Default::default()
2506 };
2507 let lines = build_health_human_lines(&report, &root);
2508 let text = plain(&lines);
2509 assert!(!text.contains("High complexity functions"));
2510 }
2511
2512 #[test]
2513 fn health_findings_show_function_details() {
2514 let root = PathBuf::from("/project");
2515 let report = fallow_output::HealthReport {
2516 findings: vec![
2517 fallow_output::ComplexityViolation {
2518 path: root.join("src/parser.ts"),
2519 name: "parseExpression".to_string(),
2520 line: 42,
2521 col: 0,
2522 cyclomatic: 25,
2523 cognitive: 30,
2524 line_count: 80,
2525 param_count: 0,
2526 react_hook_count: 0,
2527 react_jsx_max_depth: 0,
2528 react_prop_count: 0,
2529 react_hook_profile: None,
2530 exceeded: fallow_output::ExceededThreshold::Both,
2531 severity: fallow_output::FindingSeverity::High,
2532 crap: None,
2533 coverage_pct: None,
2534 coverage_tier: None,
2535 coverage_source: None,
2536 inherited_from: None,
2537 component_rollup: None,
2538 contributions: Vec::new(),
2539 effective_thresholds: None,
2540 threshold_source: None,
2541 }
2542 .into(),
2543 ],
2544 summary: fallow_output::HealthSummary {
2545 files_analyzed: 10,
2546 functions_analyzed: 50,
2547 functions_above_threshold: 1,
2548 ..Default::default()
2549 },
2550 ..Default::default()
2551 };
2552 let lines = build_health_human_lines(&report, &root);
2553 let text = plain(&lines);
2554 assert!(text.contains("High complexity functions (1)"));
2555 assert!(text.contains("src/parser.ts"));
2556 assert!(text.contains(":42"));
2557 assert!(text.contains("parseExpression"));
2558 assert!(text.contains("25 cyclomatic"));
2559 assert!(text.contains("30 cognitive"));
2560 assert!(text.contains("80 lines"));
2561 }
2562
2563 #[test]
2564 fn health_findings_label_template_complexity_entries() {
2565 let root = PathBuf::from("/project");
2566 let report = fallow_output::HealthReport {
2567 findings: vec![
2568 fallow_output::ComplexityViolation {
2569 path: root.join("src/Card.vue"),
2570 name: "<template>".to_string(),
2571 line: 1,
2572 col: 0,
2573 cyclomatic: 8,
2574 cognitive: 12,
2575 line_count: 40,
2576 param_count: 0,
2577 react_hook_count: 0,
2578 react_jsx_max_depth: 0,
2579 react_prop_count: 0,
2580 react_hook_profile: None,
2581 exceeded: fallow_output::ExceededThreshold::Cognitive,
2582 severity: fallow_output::FindingSeverity::Moderate,
2583 crap: None,
2584 coverage_pct: None,
2585 coverage_tier: None,
2586 coverage_source: None,
2587 inherited_from: None,
2588 component_rollup: None,
2589 contributions: Vec::new(),
2590 effective_thresholds: None,
2591 threshold_source: None,
2592 }
2593 .into(),
2594 ],
2595 summary: fallow_output::HealthSummary {
2596 files_analyzed: 1,
2597 functions_analyzed: 1,
2598 functions_above_threshold: 1,
2599 ..Default::default()
2600 },
2601 ..Default::default()
2602 };
2603 let lines = build_health_human_lines(&report, &root);
2604 let text = plain(&lines);
2605 assert!(text.contains("High complexity findings (1)"));
2606 assert!(text.contains("<template> (template complexity)"));
2607 assert!(text.contains("Functions and synthetic template or component entries"));
2608 }
2609
2610 #[test]
2611 fn health_shown_vs_total_when_truncated() {
2612 let root = PathBuf::from("/project");
2613 let report = fallow_output::HealthReport {
2614 findings: vec![
2615 fallow_output::ComplexityViolation {
2616 path: root.join("src/a.ts"),
2617 name: "fn1".to_string(),
2618 line: 1,
2619 col: 0,
2620 cyclomatic: 25,
2621 cognitive: 20,
2622 line_count: 50,
2623 param_count: 0,
2624 react_hook_count: 0,
2625 react_jsx_max_depth: 0,
2626 react_prop_count: 0,
2627 react_hook_profile: None,
2628 exceeded: fallow_output::ExceededThreshold::Both,
2629 severity: fallow_output::FindingSeverity::High,
2630 crap: None,
2631 coverage_pct: None,
2632 coverage_tier: None,
2633 coverage_source: None,
2634 inherited_from: None,
2635 component_rollup: None,
2636 contributions: Vec::new(),
2637 effective_thresholds: None,
2638 threshold_source: None,
2639 }
2640 .into(),
2641 ],
2642 summary: fallow_output::HealthSummary {
2643 files_analyzed: 100,
2644 functions_analyzed: 500,
2645 functions_above_threshold: 10,
2646 ..Default::default()
2647 },
2648 ..Default::default()
2649 };
2650 let lines = build_health_human_lines(&report, &root);
2651 let text = plain(&lines);
2652 assert!(text.contains("1 shown, 10 total"));
2653 }
2654
2655 #[test]
2656 fn health_findings_explain_estimated_crap_scores() {
2657 let root = PathBuf::from("/project");
2658 let report = fallow_output::HealthReport {
2659 findings: vec![
2660 fallow_output::ComplexityViolation {
2661 path: root.join("src/risky.ts"),
2662 name: "risky".to_string(),
2663 line: 7,
2664 col: 0,
2665 cyclomatic: 25,
2666 cognitive: 20,
2667 line_count: 80,
2668 param_count: 0,
2669 react_hook_count: 0,
2670 react_jsx_max_depth: 0,
2671 react_prop_count: 0,
2672 react_hook_profile: None,
2673 exceeded: fallow_output::ExceededThreshold::Crap,
2674 severity: fallow_output::FindingSeverity::High,
2675 crap: Some(650.0),
2676 coverage_pct: None,
2677 coverage_tier: Some(fallow_output::CoverageTier::None),
2678 coverage_source: Some(fallow_output::CoverageSource::Estimated),
2679 inherited_from: None,
2680 component_rollup: None,
2681 contributions: Vec::new(),
2682 effective_thresholds: None,
2683 threshold_source: None,
2684 }
2685 .into(),
2686 ],
2687 summary: fallow_output::HealthSummary {
2688 files_analyzed: 1,
2689 functions_analyzed: 1,
2690 functions_above_threshold: 1,
2691 coverage_model: Some(fallow_output::CoverageModel::StaticEstimated),
2692 coverage_source_consistency: None,
2693 ..Default::default()
2694 },
2695 ..Default::default()
2696 };
2697 let text = plain(&build_health_human_lines(&report, &root));
2698 assert!(text.contains("CRAP scores are estimated from export references"));
2699 assert!(text.contains("fallow health --coverage <coverage-final.json>"));
2700 }
2701
2702 #[test]
2703 fn health_findings_explain_mixed_istanbul_crap_scores() {
2704 let root = PathBuf::from("/project");
2705 let report = fallow_output::HealthReport {
2706 findings: vec![
2707 fallow_output::ComplexityViolation {
2708 path: root.join("src/risky.ts"),
2709 name: "risky".to_string(),
2710 line: 7,
2711 col: 0,
2712 cyclomatic: 25,
2713 cognitive: 20,
2714 line_count: 80,
2715 param_count: 0,
2716 react_hook_count: 0,
2717 react_jsx_max_depth: 0,
2718 react_prop_count: 0,
2719 react_hook_profile: None,
2720 exceeded: fallow_output::ExceededThreshold::Crap,
2721 severity: fallow_output::FindingSeverity::High,
2722 crap: Some(45.0),
2723 coverage_pct: Some(40.0),
2724 coverage_tier: Some(fallow_output::CoverageTier::Partial),
2725 coverage_source: Some(fallow_output::CoverageSource::Istanbul),
2726 inherited_from: None,
2727 component_rollup: None,
2728 contributions: Vec::new(),
2729 effective_thresholds: None,
2730 threshold_source: None,
2731 }
2732 .into(),
2733 ],
2734 summary: fallow_output::HealthSummary {
2735 files_analyzed: 1,
2736 functions_analyzed: 2,
2737 functions_above_threshold: 1,
2738 coverage_model: Some(fallow_output::CoverageModel::Istanbul),
2739 coverage_source_consistency: None,
2740 istanbul_matched: Some(1),
2741 istanbul_total: Some(2),
2742 ..Default::default()
2743 },
2744 ..Default::default()
2745 };
2746 let text = plain(&build_health_human_lines(&report, &root));
2747 assert!(
2748 text.contains(
2749 "CRAP scores use Istanbul coverage where matched (1/2 functions); unmatched functions are estimated"
2750 ),
2751 "mixed Istanbul note missing from output: {text}"
2752 );
2753 }
2754
2755 #[test]
2756 fn health_findings_explain_istanbul_counts_without_summary_model() {
2757 let root = PathBuf::from("/project");
2758 let report = fallow_output::HealthReport {
2759 findings: vec![
2760 fallow_output::ComplexityViolation {
2761 path: root.join("src/risky.ts"),
2762 name: "risky".to_string(),
2763 line: 7,
2764 col: 0,
2765 cyclomatic: 25,
2766 cognitive: 20,
2767 line_count: 80,
2768 param_count: 0,
2769 react_hook_count: 0,
2770 react_jsx_max_depth: 0,
2771 react_prop_count: 0,
2772 react_hook_profile: None,
2773 exceeded: fallow_output::ExceededThreshold::Crap,
2774 severity: fallow_output::FindingSeverity::High,
2775 crap: Some(45.0),
2776 coverage_pct: None,
2777 coverage_tier: Some(fallow_output::CoverageTier::None),
2778 coverage_source: Some(fallow_output::CoverageSource::Estimated),
2779 inherited_from: None,
2780 component_rollup: None,
2781 contributions: Vec::new(),
2782 effective_thresholds: None,
2783 threshold_source: None,
2784 }
2785 .into(),
2786 ],
2787 summary: fallow_output::HealthSummary {
2788 files_analyzed: 1,
2789 functions_analyzed: 2,
2790 functions_above_threshold: 1,
2791 coverage_model: None,
2792 coverage_source_consistency: None,
2793 istanbul_matched: Some(1),
2794 istanbul_total: Some(2),
2795 ..Default::default()
2796 },
2797 ..Default::default()
2798 };
2799 let text = plain(&build_health_human_lines(&report, &root));
2800 assert!(
2801 text.contains(
2802 "CRAP scores use Istanbul coverage where matched (1/2 functions); unmatched functions are estimated"
2803 ),
2804 "Istanbul counts should drive the note even when coverage_model is omitted: {text}"
2805 );
2806 }
2807
2808 #[test]
2809 fn health_findings_grouped_by_file() {
2810 let root = PathBuf::from("/project");
2811 let report = fallow_output::HealthReport {
2812 findings: vec![
2813 fallow_output::ComplexityViolation {
2814 path: root.join("src/parser.ts"),
2815 name: "fn1".to_string(),
2816 line: 10,
2817 col: 0,
2818 cyclomatic: 25,
2819 cognitive: 20,
2820 line_count: 40,
2821 param_count: 0,
2822 react_hook_count: 0,
2823 react_jsx_max_depth: 0,
2824 react_prop_count: 0,
2825 react_hook_profile: None,
2826 exceeded: fallow_output::ExceededThreshold::Both,
2827 severity: fallow_output::FindingSeverity::High,
2828 crap: None,
2829 coverage_pct: None,
2830 coverage_tier: None,
2831 coverage_source: None,
2832 inherited_from: None,
2833 component_rollup: None,
2834 contributions: Vec::new(),
2835 effective_thresholds: None,
2836 threshold_source: None,
2837 }
2838 .into(),
2839 fallow_output::ComplexityViolation {
2840 path: root.join("src/parser.ts"),
2841 name: "fn2".to_string(),
2842 line: 60,
2843 col: 0,
2844 cyclomatic: 22,
2845 cognitive: 18,
2846 line_count: 30,
2847 param_count: 0,
2848 react_hook_count: 0,
2849 react_jsx_max_depth: 0,
2850 react_prop_count: 0,
2851 react_hook_profile: None,
2852 exceeded: fallow_output::ExceededThreshold::Both,
2853 severity: fallow_output::FindingSeverity::High,
2854 crap: None,
2855 coverage_pct: None,
2856 coverage_tier: None,
2857 coverage_source: None,
2858 inherited_from: None,
2859 component_rollup: None,
2860 contributions: Vec::new(),
2861 effective_thresholds: None,
2862 threshold_source: None,
2863 }
2864 .into(),
2865 ],
2866 summary: fallow_output::HealthSummary {
2867 files_analyzed: 10,
2868 functions_analyzed: 50,
2869 functions_above_threshold: 2,
2870 ..Default::default()
2871 },
2872 ..Default::default()
2873 };
2874 let lines = build_health_human_lines(&report, &root);
2875 let text = plain(&lines);
2876 let count = text.matches("src/parser.ts").count();
2877 assert_eq!(count, 1, "File header should appear once for grouped items");
2878 }
2879
2880 fn empty_report() -> fallow_output::HealthReport {
2881 fallow_output::HealthReport {
2882 summary: fallow_output::HealthSummary {
2883 files_analyzed: 10,
2884 functions_analyzed: 50,
2885 ..Default::default()
2886 },
2887 ..Default::default()
2888 }
2889 }
2890
2891 #[test]
2892 fn health_runtime_coverage_renders_section() {
2893 let root = PathBuf::from("/project");
2894 let mut report = empty_report();
2895 report.runtime_coverage = Some(fallow_output::RuntimeCoverageReport {
2896 schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
2897 verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
2898 signals: Vec::new(),
2899 summary: fallow_output::RuntimeCoverageSummary {
2900 data_source: fallow_output::RuntimeCoverageDataSource::Local,
2901 last_received_at: None,
2902 functions_tracked: 4,
2903 functions_hit: 2,
2904 functions_unhit: 1,
2905 functions_untracked: 1,
2906 coverage_percent: 50.0,
2907 trace_count: 2_847_291,
2908 period_days: 30,
2909 deployments_seen: 14,
2910 capture_quality: None,
2911 },
2912 findings: vec![fallow_output::RuntimeCoverageFinding {
2913 id: "fallow:prod:deadbeef".to_owned(),
2914 stable_id: None,
2915 path: root.join("src/cold.ts"),
2916 function: "coldPath".to_owned(),
2917 line: 14,
2918 verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
2919 invocations: Some(0),
2920 confidence: fallow_output::RuntimeCoverageConfidence::Medium,
2921 evidence: fallow_output::RuntimeCoverageEvidence {
2922 static_status: "used".to_owned(),
2923 test_coverage: "not_covered".to_owned(),
2924 v8_tracking: "tracked".to_owned(),
2925 untracked_reason: None,
2926 observation_days: 30,
2927 deployments_observed: 14,
2928 },
2929 actions: vec![],
2930 source_hash: None,
2931 discriminators: None,
2932 }],
2933 hot_paths: vec![fallow_output::RuntimeCoverageHotPath {
2934 id: "fallow:hot:cafebabe".to_owned(),
2935 stable_id: None,
2936 path: root.join("src/hot.ts"),
2937 function: "hotPath".to_owned(),
2938 line: 3,
2939 end_line: 9,
2940 invocations: 250,
2941 percentile: 99,
2942 actions: vec![],
2943 }],
2944 blast_radius: vec![],
2945 importance: vec![],
2946 watermark: Some(fallow_output::RuntimeCoverageWatermark::LicenseExpiredGrace),
2947 warnings: vec![],
2948 actionable: true,
2949 actionability_reason: None,
2950 actionability_verdict: None,
2951 provenance: fallow_output::RuntimeCoverageProvenance::default(),
2952 });
2953
2954 let text = plain(&build_health_human_lines(&report, &root));
2955 assert!(text.contains("Runtime coverage: cold code detected"));
2956 assert!(text.contains("src/cold.ts:14 coldPath [0 invocations, review required]"));
2957 assert!(text.contains("license expired grace active"));
2958 assert!(text.contains("hot paths:"));
2959 assert!(text.contains("src/hot.ts:3 hotPath (250 invocations, p99)"));
2960 assert!(!text.contains("short capture:"));
2961 assert!(!text.contains("start a trial"));
2962 }
2963
2964 #[test]
2965 fn health_coverage_intelligence_renders_findings_and_ambiguity_summary() {
2966 use fallow_output::{
2967 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2968 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2969 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2970 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2971 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2972 };
2973
2974 let root = PathBuf::from("/project");
2975 let mut report = empty_report();
2976 report.coverage_intelligence = Some(CoverageIntelligenceReport {
2977 schema_version: CoverageIntelligenceSchemaVersion::V1,
2978 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2979 summary: CoverageIntelligenceSummary {
2980 findings: 1,
2981 high_confidence_deletes: 1,
2982 ..Default::default()
2983 },
2984 findings: vec![CoverageIntelligenceFinding {
2985 id: "fallow:coverage-intel:abc123".to_owned(),
2986 path: root.join("src/dead.ts"),
2987 identity: Some("deadPath".to_owned()),
2988 line: 9,
2989 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2990 signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2991 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2992 confidence: CoverageIntelligenceConfidence::High,
2993 related_ids: vec![],
2994 evidence: CoverageIntelligenceEvidence {
2995 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2996 ..Default::default()
2997 },
2998 actions: vec![CoverageIntelligenceAction {
2999 kind: "delete-after-confirming-owner".to_owned(),
3000 description: "Confirm ownership before deleting".to_owned(),
3001 auto_fixable: false,
3002 }],
3003 }],
3004 });
3005
3006 let text = plain(&build_health_human_lines(&report, &root));
3007 assert!(text.contains("Coverage intelligence"));
3008 assert!(text.contains("src/dead.ts:9 deadPath high-confidence-delete"));
3009 assert!(text.contains("Confirm ownership before deleting"));
3010
3011 report.coverage_intelligence = Some(CoverageIntelligenceReport {
3012 schema_version: CoverageIntelligenceSchemaVersion::V1,
3013 verdict: CoverageIntelligenceVerdict::Clean,
3014 summary: CoverageIntelligenceSummary {
3015 skipped_ambiguous_matches: 2,
3016 ..Default::default()
3017 },
3018 findings: vec![],
3019 });
3020 let text = plain(&build_health_human_lines(&report, &root));
3021 assert!(text.contains("skipped 2 ambiguous evidence matches"));
3022 }
3023
3024 fn runtime_coverage_report_with_quality(
3025 quality: Option<fallow_output::RuntimeCoverageCaptureQuality>,
3026 ) -> fallow_output::RuntimeCoverageReport {
3027 fallow_output::RuntimeCoverageReport {
3028 schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
3029 verdict: fallow_output::RuntimeCoverageReportVerdict::Clean,
3030 signals: Vec::new(),
3031 summary: fallow_output::RuntimeCoverageSummary {
3032 data_source: fallow_output::RuntimeCoverageDataSource::Local,
3033 last_received_at: None,
3034 functions_tracked: 10,
3035 functions_hit: 7,
3036 functions_unhit: 0,
3037 functions_untracked: 3,
3038 coverage_percent: 70.0,
3039 trace_count: 1_000,
3040 period_days: 1,
3041 deployments_seen: 1,
3042 capture_quality: quality,
3043 },
3044 findings: vec![],
3045 hot_paths: vec![],
3046 blast_radius: vec![],
3047 importance: vec![],
3048 watermark: None,
3049 warnings: vec![],
3050 actionable: true,
3051 actionability_reason: None,
3052 actionability_verdict: None,
3053 provenance: fallow_output::RuntimeCoverageProvenance::default(),
3054 }
3055 }
3056
3057 #[test]
3058 fn health_runtime_coverage_short_capture_shows_warning_and_prompt() {
3059 let root = PathBuf::from("/project");
3060 let mut report = empty_report();
3061 report.runtime_coverage = Some(runtime_coverage_report_with_quality(Some(
3062 fallow_output::RuntimeCoverageCaptureQuality {
3063 window_seconds: 720, instances_observed: 1,
3065 lazy_parse_warning: true,
3066 untracked_ratio_percent: 42.5,
3067 },
3068 )));
3069 let text = plain(&build_health_human_lines(&report, &root));
3070 assert!(
3071 text.contains(
3072 "note: short capture (12 min from 1 instance); 42.5% of functions untracked, lazy-parsed scripts may not appear."
3073 ),
3074 "warning banner missing or malformed in:\n{text}"
3075 );
3076 assert!(
3077 text.contains("extend the capture or switch to continuous monitoring"),
3078 "warning follow-up line missing in:\n{text}"
3079 );
3080 assert!(
3081 text.contains("captured 12 min from 1 instance."),
3082 "upgrade prompt header missing in:\n{text}"
3083 );
3084 assert!(
3085 text.contains("continuous monitoring over 30 days evaluates more paths"),
3086 "upgrade prompt body missing in:\n{text}"
3087 );
3088 assert!(
3089 text.contains("fallow license activate --trial --email you@company.com"),
3090 "trial CTA command missing in:\n{text}"
3091 );
3092 }
3093
3094 #[test]
3095 fn health_runtime_coverage_long_capture_shows_neither_warning_nor_prompt() {
3096 let root = PathBuf::from("/project");
3097 let mut report = empty_report();
3098 report.runtime_coverage = Some(runtime_coverage_report_with_quality(Some(
3099 fallow_output::RuntimeCoverageCaptureQuality {
3100 window_seconds: 7 * 24 * 3600, instances_observed: 4,
3102 lazy_parse_warning: false,
3103 untracked_ratio_percent: 3.1,
3104 },
3105 )));
3106 let text = plain(&build_health_human_lines(&report, &root));
3107 assert!(
3108 !text.contains("short capture"),
3109 "long capture should not emit short-capture warning:\n{text}"
3110 );
3111 assert!(
3112 !text.contains("start a trial"),
3113 "long capture should not emit trial CTA:\n{text}"
3114 );
3115 }
3116
3117 #[test]
3118 fn format_window_labels() {
3119 assert_eq!(super::format_window(30), "30 s");
3120 assert_eq!(super::format_window(60), "1 min");
3121 assert_eq!(super::format_window(720), "12 min");
3122 assert_eq!(super::format_window(3600 * 3), "3 h");
3123 assert_eq!(super::format_window(3600 * 24 * 3), "3 d");
3124 }
3125
3126 #[test]
3127 fn health_coverage_gaps_render_section() {
3128 use fallow_output::*;
3129
3130 let root = PathBuf::from("/project");
3131 let mut report = empty_report();
3132 report.coverage_gaps = Some(CoverageGaps {
3133 summary: CoverageGapSummary {
3134 runtime_files: 1,
3135 covered_files: 0,
3136 file_coverage_pct: 0.0,
3137 untested_files: 1,
3138 untested_exports: 1,
3139 },
3140 files: vec![UntestedFileFinding::with_actions(
3141 UntestedFile {
3142 path: root.join("src/app.ts"),
3143 value_export_count: 2,
3144 },
3145 &root,
3146 )],
3147 exports: vec![UntestedExportFinding::with_actions(
3148 UntestedExport {
3149 path: root.join("src/app.ts"),
3150 export_name: "loader".into(),
3151 line: 12,
3152 col: 4,
3153 },
3154 &root,
3155 )],
3156 });
3157
3158 let text = plain(&build_health_human_lines(&report, &root));
3159 assert!(
3160 text.contains("Coverage gaps (1 untested file, 1 untested export, 0.0% file coverage)")
3161 );
3162 assert!(text.contains("src/app.ts"));
3163 assert!(text.contains("loader"));
3164 }
3165
3166 #[test]
3167 fn fmt_trend_val_percentage() {
3168 assert_eq!(fmt_trend_val(15.5, "%"), "15.5%");
3169 assert_eq!(fmt_trend_val(0.0, "%"), "0.0%");
3170 }
3171
3172 #[test]
3173 fn fmt_trend_val_integer_when_round() {
3174 assert_eq!(fmt_trend_val(72.0, ""), "72");
3175 assert_eq!(fmt_trend_val(5.0, "pts"), "5");
3176 }
3177
3178 #[test]
3179 fn fmt_trend_val_decimal_when_fractional() {
3180 assert_eq!(fmt_trend_val(4.7, ""), "4.7");
3181 assert_eq!(fmt_trend_val(1.3, "pts"), "1.3");
3182 }
3183
3184 #[test]
3185 fn fmt_trend_delta_percentage() {
3186 assert_eq!(fmt_trend_delta(2.5, "%"), "+2.5%");
3187 assert_eq!(fmt_trend_delta(-1.3, "%"), "-1.3%");
3188 }
3189
3190 #[test]
3191 fn fmt_trend_delta_integer_when_round() {
3192 assert_eq!(fmt_trend_delta(5.0, ""), "+5");
3193 assert_eq!(fmt_trend_delta(-3.0, "pts"), "-3");
3194 }
3195
3196 #[test]
3197 fn fmt_trend_delta_decimal_when_fractional() {
3198 assert_eq!(fmt_trend_delta(4.9, ""), "+4.9");
3199 assert_eq!(fmt_trend_delta(-0.7, "pts"), "-0.7");
3200 }
3201
3202 #[test]
3203 fn health_score_grade_a_display() {
3204 let root = PathBuf::from("/project");
3205 let mut report = empty_report();
3206 report.health_score = Some(fallow_output::HealthScore {
3207 formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
3208 score: 92.0,
3209 grade: "A",
3210 penalties: fallow_output::HealthScorePenalties {
3211 dead_files: Some(3.0),
3212 dead_exports: Some(2.0),
3213 complexity: 1.5,
3214 p90_complexity: 1.5,
3215 maintainability: Some(0.0),
3216 hotspots: Some(0.0),
3217 unused_deps: Some(0.0),
3218 circular_deps: Some(0.0),
3219 unit_size: None,
3220 coupling: None,
3221 duplication: None,
3222 prop_drilling: None,
3223 },
3224 });
3225 let lines = build_health_human_lines(&report, &root);
3226 let text = plain(&lines);
3227 assert!(text.contains("Health score:"));
3228 assert!(text.contains("92 A"));
3229 assert!(text.contains("dead files -3.0"));
3230 assert!(text.contains("dead exports -2.0"));
3231 assert!(text.contains("complexity -1.5"));
3232 assert!(text.contains("p90 -1.5"));
3233 }
3234
3235 #[test]
3236 fn health_score_grade_b_display() {
3237 let root = PathBuf::from("/project");
3238 let mut report = empty_report();
3239 report.health_score = Some(fallow_output::HealthScore {
3240 formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
3241 score: 76.0,
3242 grade: "B",
3243 penalties: fallow_output::HealthScorePenalties {
3244 dead_files: Some(5.0),
3245 dead_exports: Some(6.0),
3246 complexity: 3.0,
3247 p90_complexity: 2.0,
3248 maintainability: Some(4.0),
3249 hotspots: Some(2.0),
3250 unused_deps: Some(1.0),
3251 circular_deps: Some(1.0),
3252 unit_size: None,
3253 coupling: None,
3254 duplication: None,
3255 prop_drilling: None,
3256 },
3257 });
3258 let lines = build_health_human_lines(&report, &root);
3259 let text = plain(&lines);
3260 assert!(text.contains("76 B"));
3261 assert!(text.contains("dead exports -6.0"));
3262 assert!(text.contains("maintainability -4.0"));
3263 assert!(text.contains("hotspots -2.0"));
3264 assert!(text.contains("unused deps -1.0"));
3265 assert!(text.contains("circular deps -1.0"));
3266 }
3267
3268 #[test]
3269 fn health_score_grade_c_display() {
3270 let root = PathBuf::from("/project");
3271 let mut report = empty_report();
3272 report.health_score = Some(fallow_output::HealthScore {
3273 formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
3274 score: 60.0,
3275 grade: "C",
3276 penalties: fallow_output::HealthScorePenalties {
3277 dead_files: Some(10.0),
3278 dead_exports: Some(10.0),
3279 complexity: 10.0,
3280 p90_complexity: 5.0,
3281 maintainability: Some(5.0),
3282 hotspots: None,
3283 unused_deps: None,
3284 circular_deps: None,
3285 unit_size: None,
3286 coupling: None,
3287 duplication: None,
3288 prop_drilling: None,
3289 },
3290 });
3291 let lines = build_health_human_lines(&report, &root);
3292 let text = plain(&lines);
3293 assert!(text.contains("60 C"));
3294 }
3295
3296 #[test]
3297 fn health_score_grade_f_display() {
3298 let root = PathBuf::from("/project");
3299 let mut report = empty_report();
3300 report.health_score = Some(fallow_output::HealthScore {
3301 formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
3302 score: 30.0,
3303 grade: "F",
3304 penalties: fallow_output::HealthScorePenalties {
3305 dead_files: Some(15.0),
3306 dead_exports: Some(15.0),
3307 complexity: 20.0,
3308 p90_complexity: 10.0,
3309 maintainability: Some(10.0),
3310 hotspots: None,
3311 unused_deps: None,
3312 circular_deps: None,
3313 unit_size: None,
3314 coupling: None,
3315 duplication: None,
3316 prop_drilling: None,
3317 },
3318 });
3319 let lines = build_health_human_lines(&report, &root);
3320 let text = plain(&lines);
3321 assert!(text.contains("30 F"));
3322 }
3323
3324 #[test]
3325 fn health_score_na_components_shown() {
3326 let root = PathBuf::from("/project");
3327 let mut report = empty_report();
3328 report.health_score = Some(fallow_output::HealthScore {
3329 formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
3330 score: 90.0,
3331 grade: "A",
3332 penalties: fallow_output::HealthScorePenalties {
3333 dead_files: None,
3334 dead_exports: None,
3335 complexity: 0.0,
3336 p90_complexity: 0.0,
3337 maintainability: None,
3338 hotspots: None,
3339 unused_deps: None,
3340 circular_deps: None,
3341 unit_size: None,
3342 coupling: None,
3343 duplication: None,
3344 prop_drilling: None,
3345 },
3346 });
3347 let lines = build_health_human_lines(&report, &root);
3348 let text = plain(&lines);
3349 assert!(text.contains("N/A: dead code, maintainability, hotspots"));
3350 assert!(text.contains("enable the corresponding analysis flags"));
3351 }
3352
3353 #[test]
3354 fn health_score_no_na_when_all_present() {
3355 let root = PathBuf::from("/project");
3356 let mut report = empty_report();
3357 report.health_score = Some(fallow_output::HealthScore {
3358 formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
3359 score: 85.0,
3360 grade: "A",
3361 penalties: fallow_output::HealthScorePenalties {
3362 dead_files: Some(0.0),
3363 dead_exports: Some(0.0),
3364 complexity: 0.0,
3365 p90_complexity: 0.0,
3366 maintainability: Some(0.0),
3367 hotspots: Some(0.0),
3368 unused_deps: Some(0.0),
3369 circular_deps: Some(0.0),
3370 unit_size: None,
3371 coupling: None,
3372 duplication: None,
3373 prop_drilling: None,
3374 },
3375 });
3376 let lines = build_health_human_lines(&report, &root);
3377 let text = plain(&lines);
3378 assert!(!text.contains("N/A:"));
3379 }
3380
3381 #[test]
3382 fn health_score_zero_penalties_suppressed() {
3383 let root = PathBuf::from("/project");
3384 let mut report = empty_report();
3385 report.health_score = Some(fallow_output::HealthScore {
3386 formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
3387 score: 100.0,
3388 grade: "A",
3389 penalties: fallow_output::HealthScorePenalties {
3390 dead_files: Some(0.0),
3391 dead_exports: Some(0.0),
3392 complexity: 0.0,
3393 p90_complexity: 0.0,
3394 maintainability: Some(0.0),
3395 hotspots: Some(0.0),
3396 unused_deps: Some(0.0),
3397 circular_deps: Some(0.0),
3398 unit_size: None,
3399 coupling: None,
3400 duplication: None,
3401 prop_drilling: None,
3402 },
3403 });
3404 let lines = build_health_human_lines(&report, &root);
3405 let text = plain(&lines);
3406 assert!(!text.contains("dead files"));
3407 assert!(!text.contains("complexity -"));
3408 }
3409
3410 #[test]
3411 fn health_trend_improving_display() {
3412 let root = PathBuf::from("/project");
3413 let mut report = empty_report();
3414 report.health_trend = Some(fallow_output::HealthTrend {
3415 compared_to: fallow_output::TrendPoint {
3416 timestamp: "2026-03-25T14:30:00Z".into(),
3417 git_sha: Some("abc1234".into()),
3418 score: Some(72.0),
3419 grade: Some("B".into()),
3420 coverage_model: None,
3421 snapshot_schema_version: None,
3422 },
3423 metrics: vec![
3424 fallow_output::TrendMetric {
3425 name: "score",
3426 label: "Health Score",
3427 previous: 72.0,
3428 current: 85.0,
3429 delta: 13.0,
3430 direction: fallow_output::TrendDirection::Improving,
3431 unit: "",
3432 previous_count: None,
3433 current_count: None,
3434 },
3435 fallow_output::TrendMetric {
3436 name: "dead_file_pct",
3437 label: "Dead Files",
3438 previous: 10.0,
3439 current: 5.0,
3440 delta: -5.0,
3441 direction: fallow_output::TrendDirection::Improving,
3442 unit: "%",
3443 previous_count: None,
3444 current_count: None,
3445 },
3446 ],
3447 snapshots_loaded: 2,
3448 overall_direction: fallow_output::TrendDirection::Improving,
3449 });
3450 let lines = build_health_human_lines(&report, &root);
3451 let text = plain(&lines);
3452 assert!(text.contains("Trend:"));
3453 assert!(text.contains("improving"));
3454 assert!(text.contains("vs 2026-03-25"));
3455 assert!(text.contains("abc1234"));
3456 assert!(text.contains("Health Score"));
3457 assert!(text.contains("+13"));
3458 assert!(text.contains("Dead Files"));
3459 assert!(text.contains("-5.0%"));
3460 }
3461
3462 #[test]
3463 fn health_trend_declining_display() {
3464 let root = PathBuf::from("/project");
3465 let mut report = empty_report();
3466 report.health_trend = Some(fallow_output::HealthTrend {
3467 compared_to: fallow_output::TrendPoint {
3468 timestamp: "2026-03-20T10:00:00Z".into(),
3469 git_sha: None,
3470 score: None,
3471 grade: None,
3472 coverage_model: None,
3473 snapshot_schema_version: None,
3474 },
3475 metrics: vec![fallow_output::TrendMetric {
3476 name: "unused_deps",
3477 label: "Unused Deps",
3478 previous: 5.0,
3479 current: 10.0,
3480 delta: 5.0,
3481 direction: fallow_output::TrendDirection::Declining,
3482 unit: "",
3483 previous_count: None,
3484 current_count: None,
3485 }],
3486 snapshots_loaded: 1,
3487 overall_direction: fallow_output::TrendDirection::Declining,
3488 });
3489 let lines = build_health_human_lines(&report, &root);
3490 let text = plain(&lines);
3491 assert!(text.contains("declining"));
3492 assert!(text.contains("Unused Deps"));
3493 }
3494
3495 #[test]
3496 fn health_trend_all_stable_collapsed() {
3497 let root = PathBuf::from("/project");
3498 let mut report = empty_report();
3499 report.health_trend = Some(fallow_output::HealthTrend {
3500 compared_to: fallow_output::TrendPoint {
3501 timestamp: "2026-03-25T14:30:00Z".into(),
3502 git_sha: Some("def5678".into()),
3503 score: Some(80.0),
3504 grade: Some("B".into()),
3505 coverage_model: None,
3506 snapshot_schema_version: None,
3507 },
3508 metrics: vec![
3509 fallow_output::TrendMetric {
3510 name: "score",
3511 label: "Health Score",
3512 previous: 80.0,
3513 current: 80.0,
3514 delta: 0.0,
3515 direction: fallow_output::TrendDirection::Stable,
3516 unit: "",
3517 previous_count: None,
3518 current_count: None,
3519 },
3520 fallow_output::TrendMetric {
3521 name: "avg_cyclomatic",
3522 label: "Avg Cyclomatic",
3523 previous: 2.0,
3524 current: 2.0,
3525 delta: 0.0,
3526 direction: fallow_output::TrendDirection::Stable,
3527 unit: "",
3528 previous_count: None,
3529 current_count: None,
3530 },
3531 ],
3532 snapshots_loaded: 3,
3533 overall_direction: fallow_output::TrendDirection::Stable,
3534 });
3535 let lines = build_health_human_lines(&report, &root);
3536 let text = plain(&lines);
3537 assert!(text.contains("stable"));
3538 assert!(text.contains("All 2 metrics unchanged"));
3539 assert!(!text.contains("Health Score"));
3540 }
3541
3542 #[test]
3543 fn health_trend_without_sha() {
3544 let root = PathBuf::from("/project");
3545 let mut report = empty_report();
3546 report.health_trend = Some(fallow_output::HealthTrend {
3547 compared_to: fallow_output::TrendPoint {
3548 timestamp: "2026-03-20T10:00:00Z".into(),
3549 git_sha: None,
3550 score: None,
3551 grade: None,
3552 coverage_model: None,
3553 snapshot_schema_version: None,
3554 },
3555 metrics: vec![fallow_output::TrendMetric {
3556 name: "score",
3557 label: "Health Score",
3558 previous: 80.0,
3559 current: 82.0,
3560 delta: 2.0,
3561 direction: fallow_output::TrendDirection::Improving,
3562 unit: "",
3563 previous_count: None,
3564 current_count: None,
3565 }],
3566 snapshots_loaded: 1,
3567 overall_direction: fallow_output::TrendDirection::Improving,
3568 });
3569 let lines = build_health_human_lines(&report, &root);
3570 let text = plain(&lines);
3571 assert!(text.contains("vs 2026-03-20"));
3572 assert!(!text.contains("\u{00b7}"));
3573 }
3574
3575 #[test]
3576 fn vital_signs_shown_without_trend() {
3577 let root = PathBuf::from("/project");
3578 let mut report = empty_report();
3579 report.vital_signs = Some(fallow_output::VitalSigns {
3580 dead_file_pct: Some(3.2),
3581 dead_export_pct: Some(8.1),
3582 avg_cyclomatic: 4.7,
3583 p90_cyclomatic: 12,
3584 duplication_pct: None,
3585 hotspot_count: Some(2),
3586 maintainability_avg: Some(72.4),
3587 unused_dep_count: Some(3),
3588 circular_dep_count: Some(1),
3589 counts: None,
3590 unit_size_profile: None,
3591 unit_interfacing_profile: None,
3592 p95_fan_in: None,
3593 coupling_high_pct: None,
3594 total_loc: 42_381,
3595 ..Default::default()
3596 });
3597 report.hotspot_summary = Some(fallow_output::HotspotSummary {
3598 since: "6 months".to_string(),
3599 min_commits: 3,
3600 files_analyzed: 50,
3601 files_excluded: 20,
3602 shallow_clone: false,
3603 });
3604 let lines = build_health_human_lines(&report, &root);
3605 let text = plain(&lines);
3606 assert!(text.contains("42,381 LOC"));
3607 assert!(text.contains("dead files 3.2%"));
3608 assert!(text.contains("dead exports 8.1%"));
3609 assert!(text.contains("avg cyclomatic 4.7"));
3610 assert!(text.contains("p90 cyclomatic 12"));
3611 assert!(text.contains("maintainability 72.4"));
3612 assert!(text.contains("2 churn hotspots (since 6 months)"));
3613 assert!(text.contains("3 unused deps"));
3614 assert!(text.contains("1 circular dep"));
3615 }
3616
3617 #[test]
3618 fn vital_signs_zero_hotspots_still_show_window() {
3619 let root = PathBuf::from("/project");
3620 let mut report = empty_report();
3621 report.vital_signs = Some(fallow_output::VitalSigns {
3622 avg_cyclomatic: 2.0,
3623 p90_cyclomatic: 5,
3624 hotspot_count: Some(0),
3625 total_loc: 1_000,
3626 ..Default::default()
3627 });
3628 report.hotspot_summary = Some(fallow_output::HotspotSummary {
3629 since: "90 days".to_string(),
3630 min_commits: 3,
3631 files_analyzed: 10,
3632 files_excluded: 0,
3633 shallow_clone: false,
3634 });
3635 let lines = build_health_human_lines(&report, &root);
3636 let text = plain(&lines);
3637 assert!(text.contains("0 churn hotspots (since 90 days)"));
3638 assert!(!text.contains("Hotspots ("));
3639 }
3640
3641 #[test]
3642 fn vital_signs_hotspot_count_without_summary_omits_window() {
3643 let root = PathBuf::from("/project");
3644 let mut report = empty_report();
3645 report.vital_signs = Some(fallow_output::VitalSigns {
3646 avg_cyclomatic: 2.0,
3647 p90_cyclomatic: 5,
3648 hotspot_count: Some(1),
3649 total_loc: 1_000,
3650 ..Default::default()
3651 });
3652 report.hotspot_summary = None;
3653 let lines = build_health_human_lines(&report, &root);
3654 let text = plain(&lines);
3655 assert!(text.contains("1 churn hotspot"));
3656 assert!(!text.contains("(since"));
3657 }
3658
3659 #[test]
3660 fn vital_signs_suppressed_when_trend_active() {
3661 let root = PathBuf::from("/project");
3662 let mut report = empty_report();
3663 report.vital_signs = Some(fallow_output::VitalSigns {
3664 dead_file_pct: Some(3.2),
3665 dead_export_pct: Some(8.1),
3666 avg_cyclomatic: 4.7,
3667 p90_cyclomatic: 12,
3668 duplication_pct: None,
3669 hotspot_count: Some(2),
3670 maintainability_avg: Some(72.4),
3671 unused_dep_count: None,
3672 circular_dep_count: None,
3673 counts: None,
3674 unit_size_profile: None,
3675 unit_interfacing_profile: None,
3676 p95_fan_in: None,
3677 coupling_high_pct: None,
3678 total_loc: 0,
3679 ..Default::default()
3680 });
3681 report.health_trend = Some(fallow_output::HealthTrend {
3682 compared_to: fallow_output::TrendPoint {
3683 timestamp: "2026-03-25T14:30:00Z".into(),
3684 git_sha: None,
3685 score: None,
3686 grade: None,
3687 coverage_model: None,
3688 snapshot_schema_version: None,
3689 },
3690 metrics: vec![],
3691 snapshots_loaded: 1,
3692 overall_direction: fallow_output::TrendDirection::Stable,
3693 });
3694 let lines = build_health_human_lines(&report, &root);
3695 let text = plain(&lines);
3696 assert!(!text.contains("dead files"));
3697 assert!(!text.contains("avg cyclomatic"));
3698 }
3699
3700 #[test]
3701 fn vital_signs_optional_fields_omitted_when_none() {
3702 let root = PathBuf::from("/project");
3703 let mut report = empty_report();
3704 report.vital_signs = Some(fallow_output::VitalSigns {
3705 dead_file_pct: None,
3706 dead_export_pct: None,
3707 avg_cyclomatic: 2.0,
3708 p90_cyclomatic: 5,
3709 duplication_pct: None,
3710 hotspot_count: None,
3711 maintainability_avg: None,
3712 unused_dep_count: None,
3713 circular_dep_count: None,
3714 counts: None,
3715 unit_size_profile: None,
3716 unit_interfacing_profile: None,
3717 p95_fan_in: None,
3718 coupling_high_pct: None,
3719 total_loc: 0,
3720 ..Default::default()
3721 });
3722 let lines = build_health_human_lines(&report, &root);
3723 let text = plain(&lines);
3724 assert!(!text.contains("dead files"));
3725 assert!(!text.contains("dead exports"));
3726 assert!(!text.contains("maintainability "));
3727 assert!(!text.contains("hotspot"));
3728 assert!(text.contains("avg cyclomatic 2.0"));
3729 assert!(text.contains("p90 cyclomatic 5"));
3730 }
3731
3732 #[test]
3733 fn vital_signs_zero_counts_suppressed() {
3734 let root = PathBuf::from("/project");
3735 let mut report = empty_report();
3736 report.vital_signs = Some(fallow_output::VitalSigns {
3737 dead_file_pct: None,
3738 dead_export_pct: None,
3739 avg_cyclomatic: 1.0,
3740 p90_cyclomatic: 2,
3741 duplication_pct: None,
3742 hotspot_count: None,
3743 maintainability_avg: None,
3744 unused_dep_count: Some(0),
3745 circular_dep_count: Some(0),
3746 counts: None,
3747 unit_size_profile: None,
3748 unit_interfacing_profile: None,
3749 p95_fan_in: None,
3750 coupling_high_pct: None,
3751 total_loc: 0,
3752 ..Default::default()
3753 });
3754 let lines = build_health_human_lines(&report, &root);
3755 let text = plain(&lines);
3756 assert!(!text.contains("unused dep"));
3757 assert!(!text.contains("circular dep"));
3758 }
3759
3760 #[test]
3761 fn vital_signs_plural_vs_singular() {
3762 let root = PathBuf::from("/project");
3763 let mut report = empty_report();
3764 report.vital_signs = Some(fallow_output::VitalSigns {
3765 dead_file_pct: None,
3766 dead_export_pct: None,
3767 avg_cyclomatic: 1.0,
3768 p90_cyclomatic: 2,
3769 duplication_pct: None,
3770 hotspot_count: Some(1),
3771 maintainability_avg: None,
3772 unused_dep_count: Some(1),
3773 circular_dep_count: Some(2),
3774 counts: None,
3775 unit_size_profile: None,
3776 unit_interfacing_profile: None,
3777 p95_fan_in: None,
3778 coupling_high_pct: None,
3779 total_loc: 0,
3780 ..Default::default()
3781 });
3782 let lines = build_health_human_lines(&report, &root);
3783 let text = plain(&lines);
3784 assert!(text.contains("1 churn hotspot"));
3785 assert!(!text.contains("1 churn hotspots"));
3786 assert!(text.contains("1 unused dep"));
3787 assert!(!text.contains("1 unused deps"));
3788 assert!(text.contains("2 circular deps"));
3789 }
3790
3791 #[test]
3792 fn file_scores_single_entry() {
3793 let root = PathBuf::from("/project");
3794 let mut report = empty_report();
3795 report.file_scores = vec![fallow_output::FileHealthScore {
3796 path: root.join("src/utils.ts"),
3797 fan_in: 5,
3798 fan_out: 3,
3799 dead_code_ratio: 0.15,
3800 complexity_density: 0.42,
3801 maintainability_index: 85.3,
3802 total_cyclomatic: 12,
3803 total_cognitive: 8,
3804 function_count: 4,
3805 lines: 200,
3806 crap_max: 0.0,
3807 crap_above_threshold: 0,
3808 }];
3809 let lines = build_health_human_lines(&report, &root);
3810 let text = plain(&lines);
3811 assert!(text.contains("File health scores (1 files)"));
3812 assert!(text.contains("85.3"));
3813 assert!(text.contains("src/utils.ts"));
3814 assert!(text.contains("200 LOC"));
3815 assert!(text.contains("5 fan-in"));
3816 assert!(text.contains("3 fan-out"));
3817 assert!(text.contains("15% dead"));
3818 assert!(text.contains("0.42 density"));
3819 }
3820
3821 #[test]
3822 fn file_scores_concern_tag_marks_risk_vs_structure() {
3823 let root = PathBuf::from("/project");
3824 let mut report = empty_report();
3825 report.file_scores = vec![
3826 fallow_output::FileHealthScore {
3827 path: root.join("src/risky.ts"),
3828 fan_in: 0,
3829 fan_out: 0,
3830 dead_code_ratio: 0.0,
3831 complexity_density: 0.2,
3832 maintainability_index: 85.0,
3833 total_cyclomatic: 10,
3834 total_cognitive: 8,
3835 function_count: 1,
3836 lines: 100,
3837 crap_max: 552.0,
3838 crap_above_threshold: 1,
3839 },
3840 fallow_output::FileHealthScore {
3841 path: root.join("src/messy.ts"),
3842 fan_in: 0,
3843 fan_out: 0,
3844 dead_code_ratio: 0.0,
3845 complexity_density: 0.3,
3846 maintainability_index: 30.0,
3847 total_cyclomatic: 5,
3848 total_cognitive: 3,
3849 function_count: 1,
3850 lines: 100,
3851 crap_max: 2.0,
3852 crap_above_threshold: 0,
3853 },
3854 ];
3855 let text = plain(&build_health_human_lines(&report, &root));
3856 let risky_line = text
3857 .lines()
3858 .find(|l| l.contains("risky.ts"))
3859 .expect("risky path line");
3860 assert!(
3861 risky_line.trim_end().ends_with("risk"),
3862 "expected risk tag, got: {risky_line:?}"
3863 );
3864 let messy_line = text
3865 .lines()
3866 .find(|l| l.contains("messy.ts"))
3867 .expect("messy path line");
3868 assert!(
3869 messy_line.trim_end().ends_with("structure"),
3870 "expected structure tag, got: {messy_line:?}"
3871 );
3872 }
3873
3874 #[test]
3875 fn file_scores_mi_color_thresholds() {
3876 let root = PathBuf::from("/project");
3877 let mut report = empty_report();
3878 report.file_scores = vec![
3879 fallow_output::FileHealthScore {
3880 path: root.join("src/good.ts"),
3881 fan_in: 1,
3882 fan_out: 1,
3883 dead_code_ratio: 0.0,
3884 complexity_density: 0.1,
3885 maintainability_index: 90.0, total_cyclomatic: 2,
3887 total_cognitive: 1,
3888 function_count: 1,
3889 lines: 50,
3890 crap_max: 0.0,
3891 crap_above_threshold: 0,
3892 },
3893 fallow_output::FileHealthScore {
3894 path: root.join("src/okay.ts"),
3895 fan_in: 2,
3896 fan_out: 3,
3897 dead_code_ratio: 0.1,
3898 complexity_density: 0.3,
3899 maintainability_index: 65.0, total_cyclomatic: 8,
3901 total_cognitive: 5,
3902 function_count: 3,
3903 lines: 100,
3904 crap_max: 0.0,
3905 crap_above_threshold: 0,
3906 },
3907 fallow_output::FileHealthScore {
3908 path: root.join("src/bad.ts"),
3909 fan_in: 8,
3910 fan_out: 12,
3911 dead_code_ratio: 0.5,
3912 complexity_density: 0.9,
3913 maintainability_index: 30.0, total_cyclomatic: 40,
3915 total_cognitive: 30,
3916 function_count: 10,
3917 lines: 500,
3918 crap_max: 0.0,
3919 crap_above_threshold: 0,
3920 },
3921 ];
3922 let lines = build_health_human_lines(&report, &root);
3923 let text = plain(&lines);
3924 assert!(text.contains("File health scores (3 files)"));
3925 assert!(text.contains("90.0"));
3926 assert!(text.contains("65.0"));
3927 assert!(text.contains("30.0"));
3928 }
3929
3930 #[test]
3931 fn file_scores_truncation_above_max_flat_items() {
3932 let root = PathBuf::from("/project");
3933 let mut report = empty_report();
3934 for i in 0..12 {
3935 report.file_scores.push(fallow_output::FileHealthScore {
3936 path: root.join(format!("src/file{i}.ts")),
3937 fan_in: 1,
3938 fan_out: 1,
3939 dead_code_ratio: 0.0,
3940 complexity_density: 0.1,
3941 maintainability_index: 80.0,
3942 total_cyclomatic: 2,
3943 total_cognitive: 1,
3944 function_count: 1,
3945 lines: 50,
3946 crap_max: 0.0,
3947 crap_above_threshold: 0,
3948 });
3949 }
3950 let lines = build_health_human_lines(&report, &root);
3951 let text = plain(&lines);
3952 assert!(text.contains("File health scores (12 files)"));
3953 assert!(text.contains("... and 2 more files"));
3954 assert!(text.contains("file0.ts"));
3955 assert!(text.contains("file9.ts"));
3956 assert!(!text.contains("file10.ts"));
3957 assert!(!text.contains("file11.ts"));
3958 }
3959
3960 #[test]
3961 fn file_scores_docs_link() {
3962 let root = PathBuf::from("/project");
3963 let mut report = empty_report();
3964 report.file_scores = vec![fallow_output::FileHealthScore {
3965 path: root.join("src/a.ts"),
3966 fan_in: 1,
3967 fan_out: 1,
3968 dead_code_ratio: 0.0,
3969 complexity_density: 0.1,
3970 maintainability_index: 80.0,
3971 total_cyclomatic: 2,
3972 total_cognitive: 1,
3973 function_count: 1,
3974 lines: 50,
3975 crap_max: 0.0,
3976 crap_above_threshold: 0,
3977 }];
3978 let lines = build_health_human_lines(&report, &root);
3979 let text = plain(&lines);
3980 assert!(text.contains("docs.fallow.tools/explanations/health#file-health-scores"));
3981 }
3982
3983 #[test]
3984 fn hotspots_accelerating_trend() {
3985 let root = PathBuf::from("/project");
3986 let mut report = empty_report();
3987 report.hotspots = vec![
3988 fallow_output::HotspotEntry {
3989 path: root.join("src/core.ts"),
3990 score: 75.0,
3991 commits: 42,
3992 weighted_commits: 30.0,
3993 lines_added: 500,
3994 lines_deleted: 200,
3995 complexity_density: 0.85,
3996 fan_in: 10,
3997 trend: fallow_engine::churn::ChurnTrend::Accelerating,
3998 ownership: None,
3999 is_test_path: false,
4000 }
4001 .into(),
4002 ];
4003 let lines = build_health_human_lines(&report, &root);
4004 let text = plain(&lines);
4005 assert!(text.contains("Hotspots (1 files)"));
4006 assert!(text.contains("75.0"));
4007 assert!(text.contains("src/core.ts"));
4008 assert!(text.contains("42 commits"));
4009 assert!(text.contains("700 churn"));
4010 assert!(text.contains("0.85 density"));
4011 assert!(text.contains("10 fan-in"));
4012 assert!(text.contains("accelerating"));
4013 }
4014
4015 #[test]
4016 fn hotspots_cooling_trend() {
4017 let root = PathBuf::from("/project");
4018 let mut report = empty_report();
4019 report.hotspots = vec![
4020 fallow_output::HotspotEntry {
4021 path: root.join("src/old.ts"),
4022 score: 20.0,
4023 commits: 5,
4024 weighted_commits: 2.0,
4025 lines_added: 50,
4026 lines_deleted: 30,
4027 complexity_density: 0.3,
4028 fan_in: 2,
4029 trend: fallow_engine::churn::ChurnTrend::Cooling,
4030 ownership: None,
4031 is_test_path: false,
4032 }
4033 .into(),
4034 ];
4035 let lines = build_health_human_lines(&report, &root);
4036 let text = plain(&lines);
4037 assert!(text.contains("20.0"));
4038 assert!(text.contains("cooling"));
4039 }
4040
4041 #[test]
4042 fn hotspots_stable_trend() {
4043 let root = PathBuf::from("/project");
4044 let mut report = empty_report();
4045 report.hotspots = vec![
4046 fallow_output::HotspotEntry {
4047 path: root.join("src/mid.ts"),
4048 score: 45.0,
4049 commits: 15,
4050 weighted_commits: 10.0,
4051 lines_added: 200,
4052 lines_deleted: 100,
4053 complexity_density: 0.5,
4054 fan_in: 5,
4055 trend: fallow_engine::churn::ChurnTrend::Stable,
4056 ownership: None,
4057 is_test_path: false,
4058 }
4059 .into(),
4060 ];
4061 let lines = build_health_human_lines(&report, &root);
4062 let text = plain(&lines);
4063 assert!(text.contains("45.0"));
4064 assert!(text.contains("stable"));
4065 }
4066
4067 #[test]
4068 fn hotspots_with_summary_and_since() {
4069 let root = PathBuf::from("/project");
4070 let mut report = empty_report();
4071 report.hotspots = vec![
4072 fallow_output::HotspotEntry {
4073 path: root.join("src/a.ts"),
4074 score: 50.0,
4075 commits: 10,
4076 weighted_commits: 8.0,
4077 lines_added: 100,
4078 lines_deleted: 50,
4079 complexity_density: 0.4,
4080 fan_in: 3,
4081 trend: fallow_engine::churn::ChurnTrend::Stable,
4082 ownership: None,
4083 is_test_path: false,
4084 }
4085 .into(),
4086 ];
4087 report.hotspot_summary = Some(fallow_output::HotspotSummary {
4088 since: "6 months".to_string(),
4089 min_commits: 3,
4090 files_analyzed: 50,
4091 files_excluded: 20,
4092 shallow_clone: false,
4093 });
4094 let lines = build_health_human_lines(&report, &root);
4095 let text = plain(&lines);
4096 assert!(text.contains("Hotspots (1 files, since 6 months)"));
4097 assert!(text.contains("20 files excluded (< 3 commits)"));
4098 }
4099
4100 #[test]
4101 fn hotspots_summary_no_exclusions() {
4102 let root = PathBuf::from("/project");
4103 let mut report = empty_report();
4104 report.hotspots = vec![
4105 fallow_output::HotspotEntry {
4106 path: root.join("src/a.ts"),
4107 score: 50.0,
4108 commits: 10,
4109 weighted_commits: 8.0,
4110 lines_added: 100,
4111 lines_deleted: 50,
4112 complexity_density: 0.4,
4113 fan_in: 3,
4114 trend: fallow_engine::churn::ChurnTrend::Stable,
4115 ownership: None,
4116 is_test_path: false,
4117 }
4118 .into(),
4119 ];
4120 report.hotspot_summary = Some(fallow_output::HotspotSummary {
4121 since: "3 months".to_string(),
4122 min_commits: 2,
4123 files_analyzed: 50,
4124 files_excluded: 0,
4125 shallow_clone: false,
4126 });
4127 let lines = build_health_human_lines(&report, &root);
4128 let text = plain(&lines);
4129 assert!(!text.contains("files excluded"));
4130 }
4131
4132 #[test]
4133 fn hotspots_docs_link() {
4134 let root = PathBuf::from("/project");
4135 let mut report = empty_report();
4136 report.hotspots = vec![
4137 fallow_output::HotspotEntry {
4138 path: root.join("src/a.ts"),
4139 score: 50.0,
4140 commits: 10,
4141 weighted_commits: 8.0,
4142 lines_added: 100,
4143 lines_deleted: 50,
4144 complexity_density: 0.4,
4145 fan_in: 3,
4146 trend: fallow_engine::churn::ChurnTrend::Stable,
4147 ownership: None,
4148 is_test_path: false,
4149 }
4150 .into(),
4151 ];
4152 let lines = build_health_human_lines(&report, &root);
4153 let text = plain(&lines);
4154 assert!(text.contains("docs.fallow.tools/explanations/health#hotspot-metrics"));
4155 }
4156
4157 #[test]
4158 fn refactoring_targets_single_low_effort() {
4159 let root = PathBuf::from("/project");
4160 let mut report = empty_report();
4161 report.targets = vec![
4162 fallow_output::RefactoringTarget {
4163 path: root.join("src/legacy.ts"),
4164 priority: 65.0,
4165 efficiency: 65.0,
4166 recommendation: "Extract complex logic into helper functions".to_string(),
4167 category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
4168 effort: fallow_output::EffortEstimate::Low,
4169 confidence: fallow_output::Confidence::High,
4170 factors: vec![],
4171 evidence: None,
4172 }
4173 .into(),
4174 ];
4175 let lines = build_health_human_lines(&report, &root);
4176 let text = plain(&lines);
4177 assert!(text.contains("Refactoring targets (1)"));
4178 assert!(text.contains("1 low effort"));
4179 assert!(text.contains("65.0"));
4180 assert!(text.contains("pri:65.0"));
4181 assert!(text.contains("src/legacy.ts"));
4182 assert!(text.contains("complexity"));
4183 assert!(text.contains("effort:low"));
4184 assert!(text.contains("confidence:high"));
4185 assert!(text.contains("Extract complex logic into helper functions"));
4186 }
4187
4188 #[test]
4189 fn refactoring_targets_render_non_empty_relation_evidence() {
4190 let root = PathBuf::from("/project");
4191 let mut report = empty_report();
4192 report.targets = vec![
4193 fallow_output::RefactoringTarget {
4194 path: root.join("src/legacy.ts"),
4195 priority: 65.0,
4196 efficiency: 65.0,
4197 recommendation: "Extract complex logic into helper functions".to_string(),
4198 category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
4199 effort: fallow_output::EffortEstimate::Low,
4200 confidence: fallow_output::Confidence::High,
4201 factors: vec![],
4202 evidence: Some(fallow_output::TargetEvidence {
4203 direct_callers: vec![fallow_output::DirectCallerEvidence {
4204 path: root.join("src/consumer.ts"),
4205 symbols: vec![
4206 fallow_output::DirectCallerSymbolEvidence {
4207 imported: "loadLegacy".into(),
4208 local: "load".into(),
4209 type_only: false,
4210 },
4211 fallow_output::DirectCallerSymbolEvidence {
4212 imported: "side-effect".into(),
4213 local: String::new(),
4214 type_only: false,
4215 },
4216 ],
4217 }],
4218 clone_siblings: vec![fallow_output::CloneSiblingEvidence {
4219 path: root.join("src/peer.ts"),
4220 start_line: 12,
4221 end_line: 20,
4222 fingerprint: "dup:12345678".into(),
4223 }],
4224 ..Default::default()
4225 }),
4226 }
4227 .into(),
4228 ];
4229 let lines = build_health_human_lines(&report, &root);
4230 let text = plain(&lines);
4231 assert!(text.contains("importers: src/consumer.ts (loadLegacy as load, side effect)"));
4232 assert!(!text.contains("side-effect"));
4233 assert!(text.contains("clones: src/peer.ts:12-20 dup:12345678"));
4234 }
4235
4236 #[test]
4237 fn refactoring_targets_mixed_effort() {
4238 let root = PathBuf::from("/project");
4239 let mut report = empty_report();
4240 report.targets = vec![
4241 fallow_output::RefactoringTarget {
4242 path: root.join("src/a.ts"),
4243 priority: 80.0,
4244 efficiency: 80.0,
4245 recommendation: "Remove dead exports".to_string(),
4246 category: fallow_output::RecommendationCategory::RemoveDeadCode,
4247 effort: fallow_output::EffortEstimate::Low,
4248 confidence: fallow_output::Confidence::High,
4249 factors: vec![],
4250 evidence: None,
4251 }
4252 .into(),
4253 fallow_output::RefactoringTarget {
4254 path: root.join("src/b.ts"),
4255 priority: 60.0,
4256 efficiency: 30.0,
4257 recommendation: "Split into smaller modules".to_string(),
4258 category: fallow_output::RecommendationCategory::SplitHighImpact,
4259 effort: fallow_output::EffortEstimate::Medium,
4260 confidence: fallow_output::Confidence::Medium,
4261 factors: vec![],
4262 evidence: None,
4263 }
4264 .into(),
4265 fallow_output::RefactoringTarget {
4266 path: root.join("src/c.ts"),
4267 priority: 50.0,
4268 efficiency: 16.7,
4269 recommendation: "Break circular dependency".to_string(),
4270 category: fallow_output::RecommendationCategory::BreakCircularDependency,
4271 effort: fallow_output::EffortEstimate::High,
4272 confidence: fallow_output::Confidence::Low,
4273 factors: vec![],
4274 evidence: None,
4275 }
4276 .into(),
4277 ];
4278 let lines = build_health_human_lines(&report, &root);
4279 let text = plain(&lines);
4280 assert!(text.contains("Refactoring targets (3)"));
4281 assert!(text.contains("1 low effort"));
4282 assert!(text.contains("1 medium"));
4283 assert!(text.contains("1 high"));
4284 assert!(text.contains("effort:low"));
4285 assert!(text.contains("effort:medium"));
4286 assert!(text.contains("effort:high"));
4287 assert!(text.contains("confidence:high"));
4288 assert!(text.contains("confidence:medium"));
4289 assert!(text.contains("confidence:low"));
4290 }
4291
4292 #[test]
4293 fn refactoring_targets_truncation_above_max_flat_items() {
4294 let root = PathBuf::from("/project");
4295 let mut report = empty_report();
4296 for i in 0..12 {
4297 report.targets.push(
4298 fallow_output::RefactoringTarget {
4299 path: root.join(format!("src/target{i}.ts")),
4300 priority: 50.0,
4301 efficiency: 25.0,
4302 recommendation: format!("Fix target {i}"),
4303 category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
4304 effort: fallow_output::EffortEstimate::Medium,
4305 confidence: fallow_output::Confidence::Medium,
4306 factors: vec![],
4307 evidence: None,
4308 }
4309 .into(),
4310 );
4311 }
4312 let lines = build_health_human_lines(&report, &root);
4313 let text = plain(&lines);
4314 assert!(text.contains("Refactoring targets (12)"));
4315 assert!(text.contains("... and 2 more targets"));
4316 assert!(text.contains("target0.ts"));
4317 assert!(text.contains("target9.ts"));
4318 assert!(!text.contains("target10.ts"));
4319 }
4320
4321 #[test]
4322 fn refactoring_targets_docs_link() {
4323 let root = PathBuf::from("/project");
4324 let mut report = empty_report();
4325 report.targets = vec![
4326 fallow_output::RefactoringTarget {
4327 path: root.join("src/a.ts"),
4328 priority: 50.0,
4329 efficiency: 50.0,
4330 recommendation: "Fix it".to_string(),
4331 category: fallow_output::RecommendationCategory::ExtractDependencies,
4332 effort: fallow_output::EffortEstimate::Low,
4333 confidence: fallow_output::Confidence::High,
4334 factors: vec![],
4335 evidence: None,
4336 }
4337 .into(),
4338 ];
4339 let lines = build_health_human_lines(&report, &root);
4340 let text = plain(&lines);
4341 assert!(text.contains("docs.fallow.tools/explanations/health#refactoring-targets"));
4342 }
4343
4344 #[test]
4345 fn refactoring_targets_all_categories() {
4346 let root = PathBuf::from("/project");
4347 let mut report = empty_report();
4348 let categories = [
4349 (
4350 fallow_output::RecommendationCategory::UrgentChurnComplexity,
4351 "churn+complexity",
4352 ),
4353 (
4354 fallow_output::RecommendationCategory::BreakCircularDependency,
4355 "circular dependency",
4356 ),
4357 (
4358 fallow_output::RecommendationCategory::SplitHighImpact,
4359 "high impact",
4360 ),
4361 (
4362 fallow_output::RecommendationCategory::RemoveDeadCode,
4363 "dead code",
4364 ),
4365 (
4366 fallow_output::RecommendationCategory::ExtractComplexFunctions,
4367 "complexity",
4368 ),
4369 (
4370 fallow_output::RecommendationCategory::ExtractDependencies,
4371 "coupling",
4372 ),
4373 (
4374 fallow_output::RecommendationCategory::AddTestCoverage,
4375 "untested risk",
4376 ),
4377 ];
4378 for (i, (cat, _label)) in categories.iter().enumerate() {
4379 report.targets.push(
4380 fallow_output::RefactoringTarget {
4381 path: root.join(format!("src/cat{i}.ts")),
4382 priority: 50.0,
4383 efficiency: 50.0,
4384 recommendation: format!("Fix cat{i}"),
4385 category: cat.clone(),
4386 effort: fallow_output::EffortEstimate::Low,
4387 confidence: fallow_output::Confidence::High,
4388 factors: vec![],
4389 evidence: None,
4390 }
4391 .into(),
4392 );
4393 }
4394 let lines = build_health_human_lines(&report, &root);
4395 let text = plain(&lines);
4396 for (_cat, label) in &categories {
4397 assert!(
4398 text.contains(label),
4399 "Expected category label '{label}' in output"
4400 );
4401 }
4402 }
4403
4404 #[test]
4405 fn refactoring_targets_efficiency_color_thresholds() {
4406 let root = PathBuf::from("/project");
4407 let mut report = empty_report();
4408 report.targets = vec![
4409 fallow_output::RefactoringTarget {
4410 path: root.join("src/high.ts"),
4411 priority: 50.0,
4412 efficiency: 50.0, recommendation: "High eff".to_string(),
4414 category: fallow_output::RecommendationCategory::RemoveDeadCode,
4415 effort: fallow_output::EffortEstimate::Low,
4416 confidence: fallow_output::Confidence::High,
4417 factors: vec![],
4418 evidence: None,
4419 }
4420 .into(),
4421 fallow_output::RefactoringTarget {
4422 path: root.join("src/mid.ts"),
4423 priority: 50.0,
4424 efficiency: 25.0, recommendation: "Mid eff".to_string(),
4426 category: fallow_output::RecommendationCategory::RemoveDeadCode,
4427 effort: fallow_output::EffortEstimate::Medium,
4428 confidence: fallow_output::Confidence::Medium,
4429 factors: vec![],
4430 evidence: None,
4431 }
4432 .into(),
4433 fallow_output::RefactoringTarget {
4434 path: root.join("src/low.ts"),
4435 priority: 50.0,
4436 efficiency: 10.0, recommendation: "Low eff".to_string(),
4438 category: fallow_output::RecommendationCategory::RemoveDeadCode,
4439 effort: fallow_output::EffortEstimate::High,
4440 confidence: fallow_output::Confidence::Low,
4441 factors: vec![],
4442 evidence: None,
4443 }
4444 .into(),
4445 ];
4446 let lines = build_health_human_lines(&report, &root);
4447 let text = plain(&lines);
4448 assert!(text.contains("50.0"));
4449 assert!(text.contains("25.0"));
4450 assert!(text.contains("10.0"));
4451 }
4452
4453 #[test]
4454 #[expect(
4455 clippy::too_many_lines,
4456 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
4457 )]
4458 fn all_sections_combined() {
4459 let root = PathBuf::from("/project");
4460 let mut report = empty_report();
4461 report.summary.functions_above_threshold = 1;
4462 report.findings = vec![
4463 fallow_output::ComplexityViolation {
4464 path: root.join("src/complex.ts"),
4465 name: "bigFn".to_string(),
4466 line: 10,
4467 col: 0,
4468 cyclomatic: 25,
4469 cognitive: 20,
4470 line_count: 80,
4471 param_count: 0,
4472 react_hook_count: 0,
4473 react_jsx_max_depth: 0,
4474 react_prop_count: 0,
4475 react_hook_profile: None,
4476 exceeded: fallow_output::ExceededThreshold::Both,
4477 severity: fallow_output::FindingSeverity::Moderate,
4478 crap: None,
4479 coverage_pct: None,
4480 coverage_tier: None,
4481 coverage_source: None,
4482 inherited_from: None,
4483 component_rollup: None,
4484 contributions: Vec::new(),
4485 effective_thresholds: None,
4486 threshold_source: None,
4487 }
4488 .into(),
4489 ];
4490 report.health_score = Some(fallow_output::HealthScore {
4491 formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
4492 score: 75.0,
4493 grade: "B",
4494 penalties: fallow_output::HealthScorePenalties {
4495 dead_files: Some(5.0),
4496 dead_exports: Some(5.0),
4497 complexity: 5.0,
4498 p90_complexity: 2.0,
4499 maintainability: Some(3.0),
4500 hotspots: Some(2.0),
4501 unused_deps: Some(2.0),
4502 circular_deps: Some(1.0),
4503 unit_size: None,
4504 coupling: None,
4505 duplication: None,
4506 prop_drilling: None,
4507 },
4508 });
4509 report.file_scores = vec![fallow_output::FileHealthScore {
4510 path: root.join("src/complex.ts"),
4511 fan_in: 5,
4512 fan_out: 3,
4513 dead_code_ratio: 0.1,
4514 complexity_density: 0.5,
4515 maintainability_index: 60.0,
4516 total_cyclomatic: 15,
4517 total_cognitive: 10,
4518 function_count: 3,
4519 lines: 200,
4520 crap_max: 0.0,
4521 crap_above_threshold: 0,
4522 }];
4523 report.hotspots = vec![
4524 fallow_output::HotspotEntry {
4525 path: root.join("src/complex.ts"),
4526 score: 65.0,
4527 commits: 20,
4528 weighted_commits: 15.0,
4529 lines_added: 300,
4530 lines_deleted: 100,
4531 complexity_density: 0.5,
4532 fan_in: 5,
4533 trend: fallow_engine::churn::ChurnTrend::Accelerating,
4534 ownership: None,
4535 is_test_path: false,
4536 }
4537 .into(),
4538 ];
4539 report.targets = vec![
4540 fallow_output::RefactoringTarget {
4541 path: root.join("src/complex.ts"),
4542 priority: 70.0,
4543 efficiency: 70.0,
4544 recommendation: "Extract complex functions".to_string(),
4545 category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
4546 effort: fallow_output::EffortEstimate::Low,
4547 confidence: fallow_output::Confidence::High,
4548 factors: vec![],
4549 evidence: None,
4550 }
4551 .into(),
4552 ];
4553 let lines = build_health_human_lines(&report, &root);
4554 let text = plain(&lines);
4555 assert!(text.contains("Health score:"));
4556 assert!(text.contains("High complexity functions"));
4557 assert!(text.contains("File health scores"));
4558 assert!(text.contains("Hotspots"));
4559 assert!(text.contains("Refactoring targets"));
4560 }
4561
4562 #[test]
4563 fn completely_empty_report_produces_no_lines() {
4564 let root = PathBuf::from("/project");
4565 let report = empty_report();
4566 let lines = build_health_human_lines(&report, &root);
4567 assert!(lines.is_empty());
4568 }
4569
4570 #[test]
4571 fn finding_only_cyclomatic_exceeds() {
4572 let root = PathBuf::from("/project");
4573 let mut report = empty_report();
4574 report.summary.functions_above_threshold = 1;
4575 report.findings = vec![
4576 fallow_output::ComplexityViolation {
4577 path: root.join("src/a.ts"),
4578 name: "fn1".to_string(),
4579 line: 1,
4580 col: 0,
4581 cyclomatic: 25, cognitive: 10, line_count: 50,
4584 param_count: 0,
4585 react_hook_count: 0,
4586 react_jsx_max_depth: 0,
4587 react_prop_count: 0,
4588 react_hook_profile: None,
4589 exceeded: fallow_output::ExceededThreshold::Cyclomatic,
4590 severity: fallow_output::FindingSeverity::Moderate,
4591 crap: None,
4592 coverage_pct: None,
4593 coverage_tier: None,
4594 coverage_source: None,
4595 inherited_from: None,
4596 component_rollup: None,
4597 contributions: Vec::new(),
4598 effective_thresholds: None,
4599 threshold_source: None,
4600 }
4601 .into(),
4602 ];
4603 let lines = build_health_human_lines(&report, &root);
4604 let text = plain(&lines);
4605 assert!(text.contains("25 cyclomatic"));
4606 assert!(text.contains("10 cognitive"));
4607 }
4608
4609 #[test]
4610 fn finding_only_cognitive_exceeds() {
4611 let root = PathBuf::from("/project");
4612 let mut report = empty_report();
4613 report.summary.functions_above_threshold = 1;
4614 report.findings = vec![
4615 fallow_output::ComplexityViolation {
4616 path: root.join("src/a.ts"),
4617 name: "fn1".to_string(),
4618 line: 1,
4619 col: 0,
4620 cyclomatic: 10, cognitive: 25, line_count: 50,
4623 param_count: 0,
4624 react_hook_count: 0,
4625 react_jsx_max_depth: 0,
4626 react_prop_count: 0,
4627 react_hook_profile: None,
4628 exceeded: fallow_output::ExceededThreshold::Cognitive,
4629 severity: fallow_output::FindingSeverity::High,
4630 crap: None,
4631 coverage_pct: None,
4632 coverage_tier: None,
4633 coverage_source: None,
4634 inherited_from: None,
4635 component_rollup: None,
4636 contributions: Vec::new(),
4637 effective_thresholds: None,
4638 threshold_source: None,
4639 }
4640 .into(),
4641 ];
4642 let lines = build_health_human_lines(&report, &root);
4643 let text = plain(&lines);
4644 assert!(text.contains("10 cyclomatic"));
4645 assert!(text.contains("25 cognitive"));
4646 }
4647
4648 #[test]
4649 fn findings_across_multiple_files() {
4650 let root = PathBuf::from("/project");
4651 let mut report = empty_report();
4652 report.summary.functions_above_threshold = 2;
4653 report.findings = vec![
4654 fallow_output::ComplexityViolation {
4655 path: root.join("src/a.ts"),
4656 name: "fn1".to_string(),
4657 line: 1,
4658 col: 0,
4659 cyclomatic: 25,
4660 cognitive: 20,
4661 line_count: 50,
4662 param_count: 0,
4663 react_hook_count: 0,
4664 react_jsx_max_depth: 0,
4665 react_prop_count: 0,
4666 react_hook_profile: None,
4667 exceeded: fallow_output::ExceededThreshold::Both,
4668 severity: fallow_output::FindingSeverity::Moderate,
4669 crap: None,
4670 coverage_pct: None,
4671 coverage_tier: None,
4672 coverage_source: None,
4673 inherited_from: None,
4674 component_rollup: None,
4675 contributions: Vec::new(),
4676 effective_thresholds: None,
4677 threshold_source: None,
4678 }
4679 .into(),
4680 fallow_output::ComplexityViolation {
4681 path: root.join("src/b.ts"),
4682 name: "fn2".to_string(),
4683 line: 5,
4684 col: 0,
4685 cyclomatic: 22,
4686 cognitive: 18,
4687 line_count: 40,
4688 param_count: 0,
4689 react_hook_count: 0,
4690 react_jsx_max_depth: 0,
4691 react_prop_count: 0,
4692 react_hook_profile: None,
4693 exceeded: fallow_output::ExceededThreshold::Both,
4694 severity: fallow_output::FindingSeverity::Moderate,
4695 crap: None,
4696 coverage_pct: None,
4697 coverage_tier: None,
4698 coverage_source: None,
4699 inherited_from: None,
4700 component_rollup: None,
4701 contributions: Vec::new(),
4702 effective_thresholds: None,
4703 threshold_source: None,
4704 }
4705 .into(),
4706 ];
4707 let lines = build_health_human_lines(&report, &root);
4708 let text = plain(&lines);
4709 assert!(text.contains("src/a.ts"));
4710 assert!(text.contains("src/b.ts"));
4711 }
4712
4713 #[test]
4714 fn findings_docs_link() {
4715 let root = PathBuf::from("/project");
4716 let mut report = empty_report();
4717 report.summary.functions_above_threshold = 1;
4718 report.findings = vec![
4719 fallow_output::ComplexityViolation {
4720 path: root.join("src/a.ts"),
4721 name: "fn1".to_string(),
4722 line: 1,
4723 col: 0,
4724 cyclomatic: 25,
4725 cognitive: 20,
4726 line_count: 50,
4727 param_count: 0,
4728 react_hook_count: 0,
4729 react_jsx_max_depth: 0,
4730 react_prop_count: 0,
4731 react_hook_profile: None,
4732 exceeded: fallow_output::ExceededThreshold::Both,
4733 severity: fallow_output::FindingSeverity::Moderate,
4734 crap: None,
4735 coverage_pct: None,
4736 coverage_tier: None,
4737 coverage_source: None,
4738 inherited_from: None,
4739 component_rollup: None,
4740 contributions: Vec::new(),
4741 effective_thresholds: None,
4742 threshold_source: None,
4743 }
4744 .into(),
4745 ];
4746 let lines = build_health_human_lines(&report, &root);
4747 let text = plain(&lines);
4748 assert!(text.contains("docs.fallow.tools/explanations/health#complexity-metrics"));
4749 }
4750
4751 #[test]
4752 fn hotspot_score_high_medium_low() {
4753 let root = PathBuf::from("/project");
4754 let mut report = empty_report();
4755 report.hotspots = vec![
4756 fallow_output::HotspotEntry {
4757 path: root.join("src/high.ts"),
4758 score: 80.0, commits: 30,
4760 weighted_commits: 25.0,
4761 lines_added: 400,
4762 lines_deleted: 200,
4763 complexity_density: 0.9,
4764 fan_in: 8,
4765 trend: fallow_engine::churn::ChurnTrend::Accelerating,
4766 ownership: None,
4767 is_test_path: false,
4768 }
4769 .into(),
4770 fallow_output::HotspotEntry {
4771 path: root.join("src/medium.ts"),
4772 score: 45.0, commits: 15,
4774 weighted_commits: 10.0,
4775 lines_added: 200,
4776 lines_deleted: 100,
4777 complexity_density: 0.5,
4778 fan_in: 4,
4779 trend: fallow_engine::churn::ChurnTrend::Stable,
4780 ownership: None,
4781 is_test_path: false,
4782 }
4783 .into(),
4784 fallow_output::HotspotEntry {
4785 path: root.join("src/low.ts"),
4786 score: 15.0, commits: 5,
4788 weighted_commits: 3.0,
4789 lines_added: 50,
4790 lines_deleted: 20,
4791 complexity_density: 0.2,
4792 fan_in: 1,
4793 trend: fallow_engine::churn::ChurnTrend::Cooling,
4794 ownership: None,
4795 is_test_path: false,
4796 }
4797 .into(),
4798 ];
4799 let lines = build_health_human_lines(&report, &root);
4800 let text = plain(&lines);
4801 assert!(text.contains("80.0"));
4802 assert!(text.contains("45.0"));
4803 assert!(text.contains("15.0"));
4804 assert!(text.contains("Hotspots (3 files)"));
4805 }
4806
4807 #[test]
4808 fn rollup_breakdown_renders_workspace_relative_template_path() {
4809 let root = PathBuf::from("/project");
4810 let template =
4811 root.join("apps/admin/src/app/payments/payment-list/payment-list.component.html");
4812 let finding = fallow_output::ComplexityViolation {
4813 path: root.join("apps/admin/src/app/payments/payment-list/payment-list.component.ts"),
4814 name: "<component>".to_string(),
4815 line: 1,
4816 col: 0,
4817 cyclomatic: 25,
4818 cognitive: 28,
4819 line_count: 0,
4820 param_count: 0,
4821 react_hook_count: 0,
4822 react_jsx_max_depth: 0,
4823 react_prop_count: 0,
4824 react_hook_profile: None,
4825 exceeded: fallow_output::ExceededThreshold::Both,
4826 severity: fallow_output::FindingSeverity::High,
4827 crap: None,
4828 coverage_pct: None,
4829 coverage_tier: None,
4830 coverage_source: None,
4831 inherited_from: None,
4832 component_rollup: Some(fallow_output::ComponentRollup {
4833 component: "PaymentListComponent".to_string(),
4834 class_worst_function: "ngOnInit".to_string(),
4835 class_cyclomatic: 12,
4836 class_cognitive: 16,
4837 template_path: template,
4838 template_cyclomatic: 13,
4839 template_cognitive: 12,
4840 }),
4841 contributions: Vec::new(),
4842 effective_thresholds: None,
4843 threshold_source: None,
4844 };
4845 let line = render_component_rollup_breakdown(&finding, &root)
4846 .expect("rollup payload should render a breakdown line");
4847 assert!(
4848 line.contains("apps/admin/src/app/payments/payment-list/payment-list.component.html"),
4849 "breakdown must include workspace-relative template path: {line}"
4850 );
4851 assert!(
4852 !line.contains(" payment-list.component.html"),
4853 "bare basename token must not be the rendered template: {line}"
4854 );
4855 }
4856
4857 #[test]
4858 fn inherited_from_renders_workspace_relative_owner_path() {
4859 let root = PathBuf::from("/project");
4860 let owner = root.join("apps/admin/src/app/auth/permissions/permissions.component.ts");
4861 let template_path =
4862 root.join("apps/admin/src/app/auth/permissions/permissions.component.html");
4863 let report = fallow_output::HealthReport {
4864 findings: vec![
4865 fallow_output::ComplexityViolation {
4866 path: template_path,
4867 name: "<template>".to_string(),
4868 line: 1,
4869 col: 0,
4870 cyclomatic: 12,
4871 cognitive: 14,
4872 line_count: 0,
4873 param_count: 0,
4874 react_hook_count: 0,
4875 react_jsx_max_depth: 0,
4876 react_prop_count: 0,
4877 react_hook_profile: None,
4878 exceeded: fallow_output::ExceededThreshold::Both,
4879 severity: fallow_output::FindingSeverity::High,
4880 crap: Some(45.0),
4881 coverage_pct: None,
4882 coverage_tier: Some(fallow_output::CoverageTier::Partial),
4883 coverage_source: Some(
4884 fallow_output::CoverageSource::EstimatedComponentInherited,
4885 ),
4886 inherited_from: Some(owner),
4887 component_rollup: None,
4888 contributions: Vec::new(),
4889 effective_thresholds: None,
4890 threshold_source: None,
4891 }
4892 .into(),
4893 ],
4894 summary: fallow_output::HealthSummary {
4895 files_analyzed: 1,
4896 functions_analyzed: 1,
4897 functions_above_threshold: 1,
4898 ..Default::default()
4899 },
4900 ..Default::default()
4901 };
4902 let lines = build_health_human_lines(&report, &root);
4903 let text = plain(&lines);
4904 assert!(
4905 text.contains(
4906 "(inherited from apps/admin/src/app/auth/permissions/permissions.component.ts)"
4907 ),
4908 "inherited-from suffix must use workspace-relative path: {text}"
4909 );
4910 assert!(
4911 !text.contains("(inherited from permissions.component.ts)"),
4912 "bare basename suffix must not be rendered: {text}"
4913 );
4914 }
4915
4916 fn react_finding(
4917 react_hook_count: u16,
4918 react_prop_count: u16,
4919 react_jsx_max_depth: u16,
4920 profile: Option<fallow_output::ReactHookProfile>,
4921 ) -> fallow_output::ComplexityViolation {
4922 fallow_output::ComplexityViolation {
4923 path: PathBuf::from("src/Dashboard.tsx"),
4924 name: "Dashboard".to_string(),
4925 line: 1,
4926 col: 0,
4927 cyclomatic: 40,
4928 cognitive: 30,
4929 line_count: 90,
4930 param_count: 1,
4931 react_hook_count,
4932 react_jsx_max_depth,
4933 react_prop_count,
4934 react_hook_profile: profile,
4935 exceeded: fallow_output::ExceededThreshold::Both,
4936 severity: fallow_output::FindingSeverity::High,
4937 crap: None,
4938 coverage_pct: None,
4939 coverage_tier: None,
4940 coverage_source: None,
4941 inherited_from: None,
4942 component_rollup: None,
4943 contributions: Vec::new(),
4944 effective_thresholds: None,
4945 threshold_source: None,
4946 }
4947 }
4948
4949 #[test]
4950 fn react_context_renders_hook_breakdown_and_max_effect_deps() {
4951 let finding = react_finding(
4952 9,
4953 14,
4954 7,
4955 Some(fallow_output::ReactHookProfile {
4956 state: 3,
4957 effect: 4,
4958 memo: 2,
4959 callback: 0,
4960 custom: 0,
4961 max_effect_dep_arity: Some(5),
4962 }),
4963 );
4964 let line = render_react_context(&finding).expect("react context line");
4965 let plain_line = strip_ansi(&line);
4966 assert!(
4967 plain_line
4968 .contains("react: 14 props, 9 hooks (3 state, 4 effect, 2 memo), max effect deps 5, JSX depth 7"),
4969 "breakdown line: {plain_line}"
4970 );
4971 }
4972
4973 #[test]
4974 fn react_context_without_profile_keeps_bare_hook_count() {
4975 let finding = react_finding(5, 0, 0, None);
4976 let line = render_react_context(&finding).expect("react context line");
4977 let plain_line = strip_ansi(&line);
4978 assert!(plain_line.contains("react: 5 hooks"), "{plain_line}");
4979 assert!(
4980 !plain_line.contains('('),
4981 "no breakdown parenthetical without a profile: {plain_line}"
4982 );
4983 assert!(
4984 !plain_line.contains("max effect deps"),
4985 "no effect-deps segment without a profile: {plain_line}"
4986 );
4987 }
4988
4989 #[test]
4990 fn react_context_omits_max_effect_deps_when_arity_absent() {
4991 let finding = react_finding(
4992 2,
4993 0,
4994 0,
4995 Some(fallow_output::ReactHookProfile {
4996 state: 1,
4997 effect: 1,
4998 memo: 0,
4999 callback: 0,
5000 custom: 0,
5001 max_effect_dep_arity: None,
5002 }),
5003 );
5004 let line = render_react_context(&finding).expect("react context line");
5005 let plain_line = strip_ansi(&line);
5006 assert!(
5007 plain_line.contains("2 hooks (1 state, 1 effect)"),
5008 "{plain_line}"
5009 );
5010 assert!(
5011 !plain_line.contains("max effect deps"),
5012 "absent arity must omit the effect-deps segment: {plain_line}"
5013 );
5014 }
5015
5016 #[test]
5017 fn react_context_none_for_non_react_finding() {
5018 let finding = react_finding(0, 0, 0, None);
5019 assert!(render_react_context(&finding).is_none());
5020 }
5021}