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 let unit_ceiling = report.summary.max_unit_size_threshold;
1464 let unit_word = if unit_ceiling == 1 { "line" } else { "lines" };
1465 lines.push(format!(
1466 " {}",
1467 format!(
1468 "Functions exceeding {unit_ceiling} {unit_word} of code (very high risk): {DOCS_HEALTH}#unit-size"
1469 )
1470 .dimmed()
1471 ));
1472 if shown < total {
1473 lines.push(format!(
1474 " {}",
1475 format!("use --top {total} to see all").dimmed()
1476 ));
1477 }
1478 lines.push(String::new());
1479}
1480
1481fn append_suppression_hints(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
1491 let has_html_template = report.findings.iter().any(|finding| {
1492 finding.name == "<template>"
1493 && finding
1494 .path
1495 .extension()
1496 .and_then(|ext| ext.to_str())
1497 .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
1498 });
1499 let has_inline_template = report.findings.iter().any(|finding| {
1500 finding.name == "<template>"
1501 && finding
1502 .path
1503 .extension()
1504 .and_then(|ext| ext.to_str())
1505 .is_none_or(|ext| !ext.eq_ignore_ascii_case("html"))
1506 });
1507 let has_component_rollup = report
1508 .findings
1509 .iter()
1510 .any(|finding| finding.name == "<component>");
1511 let has_function_finding = report
1512 .findings
1513 .iter()
1514 .any(|finding| finding.name != "<template>" && finding.name != "<component>");
1515 if has_html_template {
1516 lines.push(format!(
1517 " {}",
1518 "To suppress HTML templates: <!-- fallow-ignore-file complexity -->".dimmed()
1519 ));
1520 }
1521 if has_inline_template {
1522 lines.push(format!(
1523 " {}",
1524 "To suppress inline templates: // fallow-ignore-next-line complexity (above @Component)"
1525 .dimmed()
1526 ));
1527 }
1528 if has_component_rollup {
1529 lines.push(format!(
1530 " {}",
1531 "To suppress a <component> rollup: suppress the worst class method (// fallow-ignore-next-line complexity above it hides both)"
1532 .dimmed()
1533 ));
1534 }
1535 if has_function_finding && report.findings.len() >= 3 {
1536 lines.push(format!(
1537 " {}",
1538 "To suppress: // fallow-ignore-next-line complexity".dimmed()
1539 ));
1540 }
1541}
1542
1543fn render_component_rollup_breakdown(
1556 finding: &fallow_output::ComplexityViolation,
1557 root: &Path,
1558) -> Option<String> {
1559 let rollup = finding.component_rollup.as_ref()?;
1560 let template_display = crate::report::format_display_path(&rollup.template_path, root);
1561 Some(format!(
1562 " {}",
1563 format!(
1564 "rolled up: {}cyc {}cog on `{}.{}` + {}cyc {}cog on {}",
1565 rollup.class_cyclomatic,
1566 rollup.class_cognitive,
1567 rollup.component,
1568 rollup.class_worst_function,
1569 rollup.template_cyclomatic,
1570 rollup.template_cognitive,
1571 template_display,
1572 )
1573 .dimmed(),
1574 ))
1575}
1576
1577fn render_findings(lines: &mut Vec<String>, report: &fallow_output::HealthReport, root: &Path) {
1578 if report.findings.is_empty() {
1579 return;
1580 }
1581
1582 push_findings_header(lines, report);
1583 if let Some(note) = crap_coverage_note(report) {
1584 lines.push(format!(" {}", note.dimmed()));
1585 }
1586
1587 let mut last_file = String::new();
1588 for finding in &report.findings {
1589 push_finding_file_header(lines, finding, root, &mut last_file);
1590 push_finding_metric_rows(lines, finding, report, root);
1591 }
1592 let scope = if has_synthetic_complexity_entries(report) {
1593 "Functions and synthetic template or component entries"
1594 } else {
1595 "Functions"
1596 };
1597 lines.push(format!(
1598 " {}",
1599 format!("{scope} exceeding cyclomatic, cognitive, or CRAP thresholds ({DOCS_HEALTH}#complexity-metrics)")
1600 .dimmed()
1601 ));
1602 append_suppression_hints(lines, report);
1603 if report.findings.len() < report.summary.functions_above_threshold {
1604 let total = report.summary.functions_above_threshold;
1605 lines.push(format!(
1606 " {}",
1607 format!("use --top {total} to see all").dimmed()
1608 ));
1609 }
1610 lines.push(String::new());
1611}
1612
1613fn push_findings_header(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
1614 let subject = if has_synthetic_complexity_entries(report) {
1615 "High complexity findings"
1616 } else {
1617 "High complexity functions"
1618 };
1619 let title = if report.findings.len() < report.summary.functions_above_threshold {
1620 format!(
1621 "{subject} ({} shown, {} total)",
1622 report.findings.len(),
1623 report.summary.functions_above_threshold
1624 )
1625 } else {
1626 format!("{subject} ({})", report.summary.functions_above_threshold)
1627 };
1628 lines.push(format!("{} {}", "\u{25cf}".red(), title.red().bold()));
1629}
1630
1631fn has_synthetic_complexity_entries(report: &fallow_output::HealthReport) -> bool {
1632 report
1633 .findings
1634 .iter()
1635 .any(|finding| matches!(finding.name.as_str(), "<template>" | "<component>"))
1636}
1637
1638fn display_complexity_entry_name(name: &str) -> Cow<'_, str> {
1639 match name {
1640 "<template>" => Cow::Borrowed("<template> (template complexity)"),
1641 "<component>" => Cow::Borrowed("<component> (component rollup)"),
1642 _ => Cow::Borrowed(name),
1643 }
1644}
1645
1646fn push_finding_file_header(
1647 lines: &mut Vec<String>,
1648 finding: &fallow_output::ComplexityViolation,
1649 root: &Path,
1650 last_file: &mut String,
1651) {
1652 let file_str = crate::report::format_display_path(&finding.path, root);
1653 if file_str != *last_file {
1654 lines.push(format!(" {}", format_path(&file_str)));
1655 *last_file = file_str;
1656 }
1657}
1658
1659fn push_finding_metric_rows(
1660 lines: &mut Vec<String>,
1661 finding: &fallow_output::ComplexityViolation,
1662 report: &fallow_output::HealthReport,
1663 root: &Path,
1664) {
1665 let thresholds = finding_thresholds(finding, report);
1666 lines.push(format!(
1667 " {} {}{}{}",
1668 format!(":{}", finding.line).dimmed(),
1669 display_complexity_entry_name(&finding.name).as_ref().bold(),
1670 finding_severity_tag(finding),
1671 finding_generated_tag(finding),
1672 ));
1673 lines.push(format!(
1674 " {} cyclomatic {} cognitive {} lines",
1675 threshold_colored(finding.cyclomatic, thresholds.max_cyclomatic),
1676 threshold_colored(finding.cognitive, thresholds.max_cognitive),
1677 format!("{:>3}", finding.line_count).dimmed(),
1678 ));
1679 if let Some(line) = render_react_context(finding) {
1680 lines.push(line);
1681 }
1682 if let Some(line) = render_blast_radius_context(finding, report) {
1683 lines.push(line);
1684 }
1685 if let Some(line) = render_component_rollup_breakdown(finding, root) {
1686 lines.push(line);
1687 }
1688 if let Some(line) = finding_crap_line(finding, root) {
1689 lines.push(line);
1690 }
1691}
1692
1693fn render_react_context(finding: &fallow_output::ComplexityViolation) -> Option<String> {
1703 if finding.react_prop_count == 0
1704 && finding.react_hook_count == 0
1705 && finding.react_jsx_max_depth == 0
1706 {
1707 return None;
1708 }
1709 let mut parts: Vec<String> = Vec::new();
1710 if finding.react_prop_count > 0 {
1711 parts.push(format!("{} props", finding.react_prop_count));
1712 }
1713 if finding.react_hook_count > 0 {
1714 let breakdown = finding
1715 .react_hook_profile
1716 .as_ref()
1717 .map(hook_breakdown_fragment)
1718 .filter(|b| !b.is_empty());
1719 match breakdown {
1720 Some(breakdown) => {
1721 parts.push(format!("{} hooks ({breakdown})", finding.react_hook_count));
1722 }
1723 None => parts.push(format!("{} hooks", finding.react_hook_count)),
1724 }
1725 }
1726 if let Some(arity) = finding
1727 .react_hook_profile
1728 .as_ref()
1729 .and_then(|p| p.max_effect_dep_arity)
1730 {
1731 parts.push(format!("max effect deps {arity}"));
1732 }
1733 if finding.react_jsx_max_depth > 0 {
1734 parts.push(format!("JSX depth {}", finding.react_jsx_max_depth));
1735 }
1736 Some(format!(
1737 " {}",
1738 format!("react: {}", parts.join(", ")).dimmed()
1739 ))
1740}
1741
1742const BLAST_RADIUS_MIN_SITES: u32 = 2;
1747
1748fn render_blast_radius_context(
1757 finding: &fallow_output::ComplexityViolation,
1758 report: &fallow_output::HealthReport,
1759) -> Option<String> {
1760 let (component, render_sites) = report.render_fan_in_top.get(&finding.path)?;
1761 if *render_sites < BLAST_RADIUS_MIN_SITES {
1762 return None;
1763 }
1764 Some(format!(
1765 " {}",
1766 format!("blast radius: <{component}> rendered in {render_sites} places").dimmed()
1767 ))
1768}
1769
1770fn hook_breakdown_fragment(profile: &fallow_output::ReactHookProfile) -> String {
1774 let mut segments: Vec<String> = Vec::new();
1775 if profile.state > 0 {
1776 segments.push(format!("{} state", profile.state));
1777 }
1778 if profile.effect > 0 {
1779 segments.push(format!("{} effect", profile.effect));
1780 }
1781 if profile.memo > 0 {
1782 segments.push(format!("{} memo", profile.memo));
1783 }
1784 if profile.callback > 0 {
1785 segments.push(format!("{} callback", profile.callback));
1786 }
1787 if profile.custom > 0 {
1788 segments.push(format!("{} custom", profile.custom));
1789 }
1790 segments.join(", ")
1791}
1792
1793fn finding_thresholds(
1794 finding: &fallow_output::ComplexityViolation,
1795 report: &fallow_output::HealthReport,
1796) -> fallow_output::HealthEffectiveThresholds {
1797 finding
1798 .effective_thresholds
1799 .unwrap_or(fallow_output::HealthEffectiveThresholds {
1800 max_cyclomatic: report.summary.max_cyclomatic_threshold,
1801 max_cognitive: report.summary.max_cognitive_threshold,
1802 max_crap: report.summary.max_crap_threshold,
1803 max_unit_size: report.summary.max_unit_size_threshold,
1807 })
1808}
1809
1810fn threshold_colored(value: u16, threshold: u16) -> String {
1811 let formatted = format!("{value:>3}");
1812 if value > threshold {
1813 formatted.red().bold().to_string()
1814 } else {
1815 formatted.dimmed().to_string()
1816 }
1817}
1818
1819fn finding_severity_tag(finding: &fallow_output::ComplexityViolation) -> String {
1820 match finding.severity {
1821 fallow_output::FindingSeverity::Critical => format!(" {}", "CRITICAL".red().bold()),
1822 fallow_output::FindingSeverity::High => format!(" {}", "HIGH".yellow().bold()),
1823 fallow_output::FindingSeverity::Moderate => String::new(),
1824 }
1825}
1826
1827fn finding_generated_tag(finding: &fallow_output::ComplexityViolation) -> String {
1828 if is_likely_generated(&finding.name, finding.cyclomatic) {
1829 format!(" {}", "(generated)".dimmed())
1830 } else {
1831 String::new()
1832 }
1833}
1834
1835fn finding_crap_line(finding: &fallow_output::ComplexityViolation, root: &Path) -> Option<String> {
1836 let crap = finding.crap?;
1837 let crap_colored = format!("{crap:>5.1}").red().bold().to_string();
1838 let coverage_suffix = if let Some(pct) = finding.coverage_pct {
1839 format!(" ({pct:.0}% tested)")
1840 } else if matches!(
1841 finding.coverage_source,
1842 Some(fallow_output::CoverageSource::EstimatedComponentInherited)
1843 ) && let Some(ref owner) = finding.inherited_from
1844 {
1845 let owner_display = crate::report::format_display_path(owner, root);
1846 format!(" (inherited from {owner_display})")
1847 } else {
1848 String::new()
1849 };
1850 Some(format!(
1851 " {crap_colored} CRAP{}",
1852 coverage_suffix.dimmed(),
1853 ))
1854}
1855
1856fn render_threshold_overrides(
1857 lines: &mut Vec<String>,
1858 report: &fallow_output::HealthReport,
1859 root: &Path,
1860) {
1861 if report.threshold_overrides.is_empty() {
1862 return;
1863 }
1864
1865 lines.push(format!(
1866 "{} {}",
1867 "\u{25cf}".yellow(),
1868 format!(
1869 "Health threshold overrides ({})",
1870 report.threshold_overrides.len()
1871 )
1872 .yellow()
1873 .bold()
1874 ));
1875 for entry in &report.threshold_overrides {
1876 let status = match entry.status {
1877 fallow_output::ThresholdOverrideStatus::Active => "active",
1878 fallow_output::ThresholdOverrideStatus::Stale => "stale",
1879 fallow_output::ThresholdOverrideStatus::NoMatch => "no_match",
1880 };
1881 let target = entry.path.as_ref().map_or_else(
1882 || "<no matching file or function>".to_string(),
1883 |path| {
1884 let display = crate::report::format_display_path(path, root);
1885 entry
1886 .function
1887 .as_ref()
1888 .map_or_else(|| display.clone(), |name| format!("{display}:{name}"))
1889 },
1890 );
1891 let metrics = entry.metrics.map_or(String::new(), |metrics| {
1892 let crap = metrics
1893 .crap
1894 .map_or(String::new(), |value| format!(" crap={value:.1}"));
1895 format!(
1896 " cyclomatic={} cognitive={}{}",
1897 metrics.cyclomatic, metrics.cognitive, crap
1898 )
1899 });
1900 lines.push(format!(
1901 " #{idx} {status} {target}{metrics}",
1902 idx = entry.override_index
1903 ));
1904 }
1905 lines.push(String::new());
1906}
1907
1908fn crap_coverage_note(report: &fallow_output::HealthReport) -> Option<String> {
1909 if !report.findings.iter().any(|finding| finding.crap.is_some()) {
1910 return None;
1911 }
1912
1913 let istanbul_counts = (
1914 report.summary.istanbul_matched,
1915 report.summary.istanbul_total,
1916 );
1917 let has_istanbul_counts = matches!(istanbul_counts, (Some(_), Some(total)) if total > 0);
1918
1919 if matches!(
1920 report.summary.coverage_model,
1921 Some(fallow_output::CoverageModel::Istanbul)
1922 ) || has_istanbul_counts
1923 {
1924 let match_info = match (
1925 report.summary.istanbul_matched,
1926 report.summary.istanbul_total,
1927 ) {
1928 (Some(matched), Some(total)) if total > 0 && matched < total => {
1929 return Some(format!(
1930 "CRAP scores use Istanbul coverage where matched ({matched}/{total} functions); unmatched functions are estimated from export references."
1931 ));
1932 }
1933 (Some(matched), Some(total)) if total > 0 => {
1934 format!(" ({matched}/{total} functions matched)")
1935 }
1936 _ => String::new(),
1937 };
1938 return Some(format!(
1939 "CRAP scores use Istanbul coverage data{match_info}."
1940 ));
1941 }
1942
1943 Some(
1944 "CRAP scores are estimated from export references; run `fallow health --coverage <coverage-final.json>` for exact scores."
1945 .to_string(),
1946 )
1947}
1948
1949fn is_likely_generated(name: &str, cyclomatic: u16) -> bool {
1951 if name.starts_with("validate")
1952 && name.len() > 8
1953 && name[8..].chars().all(|c| c.is_ascii_digit())
1954 {
1955 return true;
1956 }
1957 if cyclomatic > 200 && (name == "module.exports" || name == "default" || name == "<anonymous>")
1958 {
1959 return true;
1960 }
1961 false
1962}
1963
1964fn render_file_scores(lines: &mut Vec<String>, report: &fallow_output::HealthReport, root: &Path) {
1965 if report.file_scores.is_empty() {
1966 return;
1967 }
1968
1969 push_file_scores_header(lines, report.file_scores.len());
1970
1971 let shown_scores = report.file_scores.len().min(MAX_FLAT_ITEMS);
1972 for score in &report.file_scores[..shown_scores] {
1973 render_file_score_row(lines, score, root);
1974 }
1975 push_file_scores_overflow(lines, report.file_scores.len());
1976 let crap_note = file_scores_crap_note(report);
1977 lines.push(format!(
1978 " {}",
1979 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()
1980 ));
1981 lines.push(String::new());
1982}
1983
1984fn push_file_scores_header(lines: &mut Vec<String>, score_count: usize) {
1985 lines.push(format!(
1986 "{} {} {}",
1987 "\u{25cf}".cyan(),
1988 format!("File health scores ({score_count} files)")
1989 .cyan()
1990 .bold(),
1991 "\u{b7} sorted by triage concern".dimmed(),
1992 ));
1993 lines.push(String::new());
1994}
1995
1996fn render_file_score_row(
1997 lines: &mut Vec<String>,
1998 score: &fallow_output::FileHealthScore,
1999 root: &Path,
2000) {
2001 let file_str = relative_path(&score.path, root).display().to_string();
2002 let (dir, filename) = split_dir_filename(&file_str);
2003 const CONCERN_TAG_COLUMN: usize = 48;
2004 let pad = CONCERN_TAG_COLUMN
2005 .saturating_sub(file_str.chars().count())
2006 .max(2);
2007 lines.push(format!(
2008 " {} {}{}{}{}",
2009 maintainability_colored(score.maintainability_index),
2010 dir.dimmed(),
2011 filename,
2012 " ".repeat(pad),
2013 file_score_concern_colored(score),
2014 ));
2015 lines.push(format!(
2016 " {} LOC {} fan-in {} fan-out {} dead {} density{}",
2017 format!("{:>6}", score.lines).dimmed(),
2018 format!("{:>3}", score.fan_in).dimmed(),
2019 format!("{:>3}", score.fan_out).dimmed(),
2020 format!("{:>3.0}%", score.dead_code_ratio * 100.0).dimmed(),
2021 format!("{:.2}", score.complexity_density).dimmed(),
2022 file_score_risk_suffix(score),
2023 ));
2024 lines.push(String::new());
2025}
2026
2027fn maintainability_colored(mi: f64) -> String {
2028 let mi_str = format!("{mi:>5.1}");
2029 if mi >= 80.0 {
2030 mi_str.green().to_string()
2031 } else if mi >= 50.0 {
2032 mi_str.yellow().to_string()
2033 } else {
2034 mi_str.red().bold().to_string()
2035 }
2036}
2037
2038fn file_score_concern_colored(score: &fallow_output::FileHealthScore) -> String {
2039 let label = file_score_concern_axis(score).label();
2040 match file_score_concern_axis(score) {
2041 FileScoreConcern::Risk => {
2042 if score.crap_max >= 30.0 {
2043 label.red().bold().to_string()
2044 } else if score.crap_max >= 15.0 {
2045 label.yellow().to_string()
2046 } else {
2047 label.dimmed().to_string()
2048 }
2049 }
2050 FileScoreConcern::Structural => {
2051 if score.maintainability_index < 50.0 {
2052 label.red().bold().to_string()
2053 } else if score.maintainability_index < 80.0 {
2054 label.yellow().to_string()
2055 } else {
2056 label.dimmed().to_string()
2057 }
2058 }
2059 }
2060}
2061
2062fn file_score_risk_suffix(score: &fallow_output::FileHealthScore) -> String {
2063 if score.crap_max <= 0.0 {
2064 return String::new();
2065 }
2066 let risk_str = if score.crap_max > 999.0 {
2067 ">999".to_string()
2068 } else {
2069 format!("{:.1}", score.crap_max)
2070 };
2071 let risk_colored = if score.crap_max >= 30.0 {
2072 risk_str.red().bold().to_string()
2073 } else if score.crap_max >= 15.0 {
2074 risk_str.yellow().to_string()
2075 } else {
2076 risk_str.dimmed().to_string()
2077 };
2078 format!(" {risk_colored} risk")
2079}
2080
2081fn push_file_scores_overflow(lines: &mut Vec<String>, score_count: usize) {
2082 if score_count <= MAX_FLAT_ITEMS {
2083 return;
2084 }
2085 lines.push(format!(
2086 " {}",
2087 format!(
2088 "... and {} more files (--format json for full list)",
2089 score_count - MAX_FLAT_ITEMS
2090 )
2091 .dimmed()
2092 ));
2093 lines.push(String::new());
2094}
2095
2096fn file_scores_crap_note(report: &fallow_output::HealthReport) -> String {
2097 if matches!(
2098 report.summary.coverage_model,
2099 Some(fallow_output::CoverageModel::Istanbul)
2100 ) {
2101 let match_info = match (
2102 report.summary.istanbul_matched,
2103 report.summary.istanbul_total,
2104 ) {
2105 (Some(m), Some(t)) if t > 0 => format!(" ({m}/{t} functions matched)"),
2106 _ => String::new(),
2107 };
2108 format!("CRAP from Istanbul coverage data{match_info}.")
2109 } else {
2110 "CRAP estimated from export references (85% direct, 40% indirect, 0% untested). Run `fallow health --coverage <coverage-final.json>` for exact scores.".to_string()
2111 }
2112}
2113
2114fn render_coverage_gaps(
2115 lines: &mut Vec<String>,
2116 report: &fallow_output::HealthReport,
2117 root: &Path,
2118) {
2119 let Some(ref gaps) = report.coverage_gaps else {
2120 return;
2121 };
2122
2123 push_coverage_gaps_header(lines, gaps);
2124 push_coverage_gap_files(lines, gaps, root);
2125 push_coverage_gap_exports(lines, gaps, root);
2126 lines.push(format!(
2127 " {}",
2128 format!(
2129 "Static test dependency gaps (not line-level coverage): {DOCS_HEALTH}#coverage-gaps"
2130 )
2131 .dimmed()
2132 ));
2133 lines.push(String::new());
2134}
2135
2136fn push_coverage_gaps_header(lines: &mut Vec<String>, gaps: &fallow_output::CoverageGaps) {
2137 lines.push(format!(
2138 "{} {}",
2139 "\u{25cf}".yellow(),
2140 format!(
2141 "Coverage gaps ({} untested {}, {} untested {}, {:.1}% file coverage)",
2142 gaps.summary.untested_files,
2143 if gaps.summary.untested_files == 1 {
2144 "file"
2145 } else {
2146 "files"
2147 },
2148 gaps.summary.untested_exports,
2149 if gaps.summary.untested_exports == 1 {
2150 "export"
2151 } else {
2152 "exports"
2153 },
2154 gaps.summary.file_coverage_pct,
2155 )
2156 .yellow()
2157 .bold()
2158 ));
2159 lines.push(String::new());
2160}
2161
2162fn push_coverage_gap_files(
2163 lines: &mut Vec<String>,
2164 gaps: &fallow_output::CoverageGaps,
2165 root: &Path,
2166) {
2167 if gaps.files.is_empty() {
2168 return;
2169 }
2170 let shown_files = gaps.files.len().min(MAX_FLAT_ITEMS);
2171 lines.push(format!(" {}", "Files".dimmed()));
2172 for item in &gaps.files[..shown_files] {
2173 let file_str = relative_path(&item.file.path, root).display().to_string();
2174 let (dir, filename) = split_dir_filename(&file_str);
2175 lines.push(format!(" {}{}", dir.dimmed(), filename));
2176 }
2177 if gaps.files.len() > MAX_FLAT_ITEMS {
2178 lines.push(format!(
2179 " {}",
2180 format!(
2181 "... and {} more files (--format json for full list)",
2182 gaps.files.len() - MAX_FLAT_ITEMS
2183 )
2184 .dimmed()
2185 ));
2186 }
2187 lines.push(String::new());
2188}
2189
2190fn push_coverage_gap_exports(
2191 lines: &mut Vec<String>,
2192 gaps: &fallow_output::CoverageGaps,
2193 root: &Path,
2194) {
2195 if gaps.exports.is_empty() {
2196 return;
2197 }
2198 lines.push(format!(" {}", "Exports".dimmed()));
2199
2200 let by_file = group_coverage_gap_exports_by_file(&gaps.exports);
2201 let shown = push_coverage_gap_export_rows(lines, &by_file, root);
2202 let total_exports = gaps.exports.len();
2203 if total_exports > shown {
2204 lines.push(format!(
2205 " {}",
2206 format!(
2207 "... and {} more exports (--format json for full list)",
2208 total_exports - shown
2209 )
2210 .dimmed()
2211 ));
2212 }
2213 lines.push(String::new());
2214}
2215
2216fn group_coverage_gap_exports_by_file(
2217 exports: &[fallow_output::UntestedExportFinding],
2218) -> Vec<(&std::path::Path, Vec<&fallow_output::UntestedExportFinding>)> {
2219 let mut by_file: Vec<(&std::path::Path, Vec<&fallow_output::UntestedExportFinding>)> =
2220 Vec::new();
2221 for item in exports {
2222 match by_file.last_mut() {
2223 Some((path, items)) if *path == item.export.path.as_path() => items.push(item),
2224 _ => by_file.push((item.export.path.as_path(), vec![item])),
2225 }
2226 }
2227 by_file
2228}
2229
2230fn push_coverage_gap_export_rows(
2231 lines: &mut Vec<String>,
2232 by_file: &[(&std::path::Path, Vec<&fallow_output::UntestedExportFinding>)],
2233 root: &Path,
2234) -> usize {
2235 let mut shown = 0;
2236 for (file_path, exports) in by_file {
2237 if shown >= MAX_FLAT_ITEMS {
2238 break;
2239 }
2240 let file_str = relative_path(file_path, root).display().to_string();
2241 if exports.len() > 10 {
2242 lines.push(format!(
2243 " {} ({} untested re-exports)",
2244 file_str.dimmed(),
2245 exports.len(),
2246 ));
2247 shown += 1;
2248 } else {
2249 shown += push_coverage_gap_export_names(lines, exports, &file_str, shown);
2250 }
2251 }
2252 shown
2253}
2254
2255fn push_coverage_gap_export_names(
2256 lines: &mut Vec<String>,
2257 exports: &[&fallow_output::UntestedExportFinding],
2258 file_str: &str,
2259 mut shown: usize,
2260) -> usize {
2261 let before = shown;
2262 for item in exports {
2263 if shown >= MAX_FLAT_ITEMS {
2264 break;
2265 }
2266 lines.push(format!(
2267 " {}:{} `{}`",
2268 file_str.dimmed(),
2269 item.export.line,
2270 item.export.export_name,
2271 ));
2272 shown += 1;
2273 }
2274 shown - before
2275}
2276
2277pub(in crate::report) fn print_health_summary(
2279 report: &fallow_output::HealthReport,
2280 elapsed: Duration,
2281 quiet: bool,
2282 heading: bool,
2283) {
2284 let s = &report.summary;
2285
2286 if heading {
2287 outln!("{}", "Health Summary".bold());
2288 outln!();
2289 }
2290 print_health_summary_metrics(report);
2291 print_health_summary_coverage(report);
2292
2293 if !quiet {
2294 eprintln!(
2295 "{}",
2296 format!(
2297 "\u{2713} {} functions analyzed ({:.2}s)",
2298 s.functions_analyzed,
2299 elapsed.as_secs_f64()
2300 )
2301 .green()
2302 .bold()
2303 );
2304 }
2305}
2306
2307fn print_health_summary_metrics(report: &fallow_output::HealthReport) {
2309 let s = &report.summary;
2310 outln!(" {:>6} Functions analyzed", s.functions_analyzed);
2311 outln!(" {:>6} Above threshold", s.functions_above_threshold);
2312 if let Some(mi) = s.average_maintainability {
2313 let label = if mi >= 85.0 {
2314 "good"
2315 } else if mi >= 65.0 {
2316 "moderate"
2317 } else {
2318 "low"
2319 };
2320 outln!(" {mi:>5.1} Average maintainability ({label})");
2321 }
2322 if let Some(ref score) = report.health_score {
2323 outln!(" {:>5.0} {} Health score", score.score, score.grade);
2324 }
2325}
2326
2327fn print_health_summary_coverage(report: &fallow_output::HealthReport) {
2329 if let Some(ref gaps) = report.coverage_gaps {
2330 outln!(
2331 " {:>6} Untested {} ({:.1}% file coverage)",
2332 gaps.summary.untested_files,
2333 if gaps.summary.untested_files == 1 {
2334 "file"
2335 } else {
2336 "files"
2337 },
2338 gaps.summary.file_coverage_pct,
2339 );
2340 outln!(
2341 " {:>6} Untested {}",
2342 gaps.summary.untested_exports,
2343 if gaps.summary.untested_exports == 1 {
2344 "export"
2345 } else {
2346 "exports"
2347 },
2348 );
2349 }
2350 if let Some(ref production) = report.runtime_coverage {
2351 outln!(
2352 " {:>6} Unhit in production",
2353 production.summary.functions_unhit,
2354 );
2355 outln!(
2356 " {:>6} Untracked by V8 (lazy-parsed / worker / dynamic)",
2357 production.summary.functions_untracked,
2358 );
2359 }
2360}
2361
2362pub(in crate::report) fn print_health_grouping(
2383 grouping: &fallow_output::HealthGrouping,
2384 _root: &Path,
2385 quiet: bool,
2386) {
2387 if grouping.groups.is_empty() {
2388 return;
2389 }
2390 if !quiet {
2391 eprintln!();
2392 }
2393 outln!(
2394 "{} {}",
2395 "\u{25cf}".cyan(),
2396 format!("Per-{} health", grouping.mode).cyan().bold()
2397 );
2398 let key_width = grouping
2399 .groups
2400 .iter()
2401 .map(|g| g.key.len())
2402 .max()
2403 .unwrap_or(0)
2404 .max(8);
2405 let any_score = grouping.groups.iter().any(|g| g.health_score.is_some());
2406 let any_vitals = grouping.groups.iter().any(|g| g.vital_signs.is_some());
2407
2408 let mut ordered: Vec<&fallow_output::HealthGroup> = grouping.groups.iter().collect();
2409 if any_score {
2410 ordered.sort_by(|a, b| {
2411 let a_score = a.health_score.as_ref().map_or(f64::INFINITY, |hs| hs.score);
2412 let b_score = b.health_score.as_ref().map_or(f64::INFINITY, |hs| hs.score);
2413 a_score
2414 .partial_cmp(&b_score)
2415 .unwrap_or(std::cmp::Ordering::Equal)
2416 });
2417 }
2418
2419 outln!(
2420 "{}",
2421 grouping_header(key_width, any_score, any_vitals).dimmed()
2422 );
2423
2424 let mut has_root_bucket = false;
2425 for group in ordered {
2426 if group.key == "(root)" {
2427 has_root_bucket = true;
2428 }
2429 outln!("{}", grouping_row(group, key_width, any_score, any_vitals));
2430 }
2431 if !quiet {
2432 if has_root_bucket {
2433 eprintln!(
2434 " {}",
2435 "(root) = files outside any workspace package".dimmed()
2436 );
2437 }
2438 eprintln!(
2439 " {}",
2440 "per-group summary only; --format json includes per-group findings, file scores, and hotspots"
2441 .dimmed()
2442 );
2443 }
2444}
2445
2446fn grouping_header(key_width: usize, any_score: bool, any_vitals: bool) -> String {
2449 let mut header = format!(" {:<width$}", "", width = key_width);
2450 if any_score {
2451 let _ = write!(header, " {:>9} grade", "score");
2452 }
2453 let _ = write!(header, " {:>5}", "files");
2454 let _ = write!(header, " {:>3}", "hot");
2455 if any_vitals {
2456 let _ = write!(header, " {:>3}", "p90");
2457 }
2458 header
2459}
2460
2461fn grouping_row(
2463 group: &fallow_output::HealthGroup,
2464 key_width: usize,
2465 any_score: bool,
2466 any_vitals: bool,
2467) -> String {
2468 let mut row = format!(" {:<width$}", group.key, width = key_width);
2469 if any_score {
2470 if let Some(ref hs) = group.health_score {
2471 let grade_colored = colorize_grade(hs.grade);
2472 let _ = write!(row, " {:>9.1} {}", hs.score, grade_colored);
2473 } else {
2474 row.push_str(" ");
2475 }
2476 }
2477 let _ = write!(row, " {:>5}", group.files_analyzed);
2478 let _ = write!(row, " {:>3}", group.hotspots.len());
2479 if any_vitals {
2480 if let Some(ref vs) = group.vital_signs {
2481 let _ = write!(row, " {:>3}", vs.p90_cyclomatic);
2482 } else {
2483 row.push_str(" ");
2484 }
2485 }
2486 row
2487}
2488
2489fn colorize_grade(grade: &str) -> String {
2491 match grade {
2492 "A" | "B" => grade.green().to_string(),
2493 "C" => grade.yellow().to_string(),
2494 _ => grade.red().to_string(),
2495 }
2496}
2497
2498#[cfg(test)]
2499mod tests {
2500 use super::super::{plain, strip_ansi};
2501 use super::*;
2502 use std::path::PathBuf;
2503
2504 #[test]
2505 fn health_empty_findings_produces_no_header() {
2506 let root = PathBuf::from("/project");
2507 let report = fallow_output::HealthReport {
2508 summary: fallow_output::HealthSummary {
2509 files_analyzed: 10,
2510 functions_analyzed: 50,
2511 ..Default::default()
2512 },
2513 ..Default::default()
2514 };
2515 let lines = build_health_human_lines(&report, &root);
2516 let text = plain(&lines);
2517 assert!(!text.contains("High complexity functions"));
2518 }
2519
2520 #[test]
2521 fn health_findings_show_function_details() {
2522 let root = PathBuf::from("/project");
2523 let report = fallow_output::HealthReport {
2524 findings: vec![
2525 fallow_output::ComplexityViolation {
2526 path: root.join("src/parser.ts"),
2527 name: "parseExpression".to_string(),
2528 line: 42,
2529 col: 0,
2530 cyclomatic: 25,
2531 cognitive: 30,
2532 line_count: 80,
2533 param_count: 0,
2534 react_hook_count: 0,
2535 react_jsx_max_depth: 0,
2536 react_prop_count: 0,
2537 react_hook_profile: None,
2538 exceeded: fallow_output::ExceededThreshold::Both,
2539 severity: fallow_output::FindingSeverity::High,
2540 crap: None,
2541 coverage_pct: None,
2542 coverage_tier: None,
2543 coverage_source: None,
2544 inherited_from: None,
2545 component_rollup: None,
2546 contributions: Vec::new(),
2547 effective_thresholds: None,
2548 threshold_source: None,
2549 }
2550 .into(),
2551 ],
2552 summary: fallow_output::HealthSummary {
2553 files_analyzed: 10,
2554 functions_analyzed: 50,
2555 functions_above_threshold: 1,
2556 ..Default::default()
2557 },
2558 ..Default::default()
2559 };
2560 let lines = build_health_human_lines(&report, &root);
2561 let text = plain(&lines);
2562 assert!(text.contains("High complexity functions (1)"));
2563 assert!(text.contains("src/parser.ts"));
2564 assert!(text.contains(":42"));
2565 assert!(text.contains("parseExpression"));
2566 assert!(text.contains("25 cyclomatic"));
2567 assert!(text.contains("30 cognitive"));
2568 assert!(text.contains("80 lines"));
2569 }
2570
2571 #[test]
2572 fn health_findings_label_template_complexity_entries() {
2573 let root = PathBuf::from("/project");
2574 let report = fallow_output::HealthReport {
2575 findings: vec![
2576 fallow_output::ComplexityViolation {
2577 path: root.join("src/Card.vue"),
2578 name: "<template>".to_string(),
2579 line: 1,
2580 col: 0,
2581 cyclomatic: 8,
2582 cognitive: 12,
2583 line_count: 40,
2584 param_count: 0,
2585 react_hook_count: 0,
2586 react_jsx_max_depth: 0,
2587 react_prop_count: 0,
2588 react_hook_profile: None,
2589 exceeded: fallow_output::ExceededThreshold::Cognitive,
2590 severity: fallow_output::FindingSeverity::Moderate,
2591 crap: None,
2592 coverage_pct: None,
2593 coverage_tier: None,
2594 coverage_source: None,
2595 inherited_from: None,
2596 component_rollup: None,
2597 contributions: Vec::new(),
2598 effective_thresholds: None,
2599 threshold_source: None,
2600 }
2601 .into(),
2602 ],
2603 summary: fallow_output::HealthSummary {
2604 files_analyzed: 1,
2605 functions_analyzed: 1,
2606 functions_above_threshold: 1,
2607 ..Default::default()
2608 },
2609 ..Default::default()
2610 };
2611 let lines = build_health_human_lines(&report, &root);
2612 let text = plain(&lines);
2613 assert!(text.contains("High complexity findings (1)"));
2614 assert!(text.contains("<template> (template complexity)"));
2615 assert!(text.contains("Functions and synthetic template or component entries"));
2616 }
2617
2618 #[test]
2619 fn health_shown_vs_total_when_truncated() {
2620 let root = PathBuf::from("/project");
2621 let report = fallow_output::HealthReport {
2622 findings: vec![
2623 fallow_output::ComplexityViolation {
2624 path: root.join("src/a.ts"),
2625 name: "fn1".to_string(),
2626 line: 1,
2627 col: 0,
2628 cyclomatic: 25,
2629 cognitive: 20,
2630 line_count: 50,
2631 param_count: 0,
2632 react_hook_count: 0,
2633 react_jsx_max_depth: 0,
2634 react_prop_count: 0,
2635 react_hook_profile: None,
2636 exceeded: fallow_output::ExceededThreshold::Both,
2637 severity: fallow_output::FindingSeverity::High,
2638 crap: None,
2639 coverage_pct: None,
2640 coverage_tier: None,
2641 coverage_source: None,
2642 inherited_from: None,
2643 component_rollup: None,
2644 contributions: Vec::new(),
2645 effective_thresholds: None,
2646 threshold_source: None,
2647 }
2648 .into(),
2649 ],
2650 summary: fallow_output::HealthSummary {
2651 files_analyzed: 100,
2652 functions_analyzed: 500,
2653 functions_above_threshold: 10,
2654 ..Default::default()
2655 },
2656 ..Default::default()
2657 };
2658 let lines = build_health_human_lines(&report, &root);
2659 let text = plain(&lines);
2660 assert!(text.contains("1 shown, 10 total"));
2661 }
2662
2663 #[test]
2664 fn health_findings_explain_estimated_crap_scores() {
2665 let root = PathBuf::from("/project");
2666 let report = fallow_output::HealthReport {
2667 findings: vec![
2668 fallow_output::ComplexityViolation {
2669 path: root.join("src/risky.ts"),
2670 name: "risky".to_string(),
2671 line: 7,
2672 col: 0,
2673 cyclomatic: 25,
2674 cognitive: 20,
2675 line_count: 80,
2676 param_count: 0,
2677 react_hook_count: 0,
2678 react_jsx_max_depth: 0,
2679 react_prop_count: 0,
2680 react_hook_profile: None,
2681 exceeded: fallow_output::ExceededThreshold::Crap,
2682 severity: fallow_output::FindingSeverity::High,
2683 crap: Some(650.0),
2684 coverage_pct: None,
2685 coverage_tier: Some(fallow_output::CoverageTier::None),
2686 coverage_source: Some(fallow_output::CoverageSource::Estimated),
2687 inherited_from: None,
2688 component_rollup: None,
2689 contributions: Vec::new(),
2690 effective_thresholds: None,
2691 threshold_source: None,
2692 }
2693 .into(),
2694 ],
2695 summary: fallow_output::HealthSummary {
2696 files_analyzed: 1,
2697 functions_analyzed: 1,
2698 functions_above_threshold: 1,
2699 coverage_model: Some(fallow_output::CoverageModel::StaticEstimated),
2700 coverage_source_consistency: None,
2701 ..Default::default()
2702 },
2703 ..Default::default()
2704 };
2705 let text = plain(&build_health_human_lines(&report, &root));
2706 assert!(text.contains("CRAP scores are estimated from export references"));
2707 assert!(text.contains("fallow health --coverage <coverage-final.json>"));
2708 }
2709
2710 #[test]
2711 fn health_findings_explain_mixed_istanbul_crap_scores() {
2712 let root = PathBuf::from("/project");
2713 let report = fallow_output::HealthReport {
2714 findings: vec![
2715 fallow_output::ComplexityViolation {
2716 path: root.join("src/risky.ts"),
2717 name: "risky".to_string(),
2718 line: 7,
2719 col: 0,
2720 cyclomatic: 25,
2721 cognitive: 20,
2722 line_count: 80,
2723 param_count: 0,
2724 react_hook_count: 0,
2725 react_jsx_max_depth: 0,
2726 react_prop_count: 0,
2727 react_hook_profile: None,
2728 exceeded: fallow_output::ExceededThreshold::Crap,
2729 severity: fallow_output::FindingSeverity::High,
2730 crap: Some(45.0),
2731 coverage_pct: Some(40.0),
2732 coverage_tier: Some(fallow_output::CoverageTier::Partial),
2733 coverage_source: Some(fallow_output::CoverageSource::Istanbul),
2734 inherited_from: None,
2735 component_rollup: None,
2736 contributions: Vec::new(),
2737 effective_thresholds: None,
2738 threshold_source: None,
2739 }
2740 .into(),
2741 ],
2742 summary: fallow_output::HealthSummary {
2743 files_analyzed: 1,
2744 functions_analyzed: 2,
2745 functions_above_threshold: 1,
2746 coverage_model: Some(fallow_output::CoverageModel::Istanbul),
2747 coverage_source_consistency: None,
2748 istanbul_matched: Some(1),
2749 istanbul_total: Some(2),
2750 ..Default::default()
2751 },
2752 ..Default::default()
2753 };
2754 let text = plain(&build_health_human_lines(&report, &root));
2755 assert!(
2756 text.contains(
2757 "CRAP scores use Istanbul coverage where matched (1/2 functions); unmatched functions are estimated"
2758 ),
2759 "mixed Istanbul note missing from output: {text}"
2760 );
2761 }
2762
2763 #[test]
2764 fn health_findings_explain_istanbul_counts_without_summary_model() {
2765 let root = PathBuf::from("/project");
2766 let report = fallow_output::HealthReport {
2767 findings: vec![
2768 fallow_output::ComplexityViolation {
2769 path: root.join("src/risky.ts"),
2770 name: "risky".to_string(),
2771 line: 7,
2772 col: 0,
2773 cyclomatic: 25,
2774 cognitive: 20,
2775 line_count: 80,
2776 param_count: 0,
2777 react_hook_count: 0,
2778 react_jsx_max_depth: 0,
2779 react_prop_count: 0,
2780 react_hook_profile: None,
2781 exceeded: fallow_output::ExceededThreshold::Crap,
2782 severity: fallow_output::FindingSeverity::High,
2783 crap: Some(45.0),
2784 coverage_pct: None,
2785 coverage_tier: Some(fallow_output::CoverageTier::None),
2786 coverage_source: Some(fallow_output::CoverageSource::Estimated),
2787 inherited_from: None,
2788 component_rollup: None,
2789 contributions: Vec::new(),
2790 effective_thresholds: None,
2791 threshold_source: None,
2792 }
2793 .into(),
2794 ],
2795 summary: fallow_output::HealthSummary {
2796 files_analyzed: 1,
2797 functions_analyzed: 2,
2798 functions_above_threshold: 1,
2799 coverage_model: None,
2800 coverage_source_consistency: None,
2801 istanbul_matched: Some(1),
2802 istanbul_total: Some(2),
2803 ..Default::default()
2804 },
2805 ..Default::default()
2806 };
2807 let text = plain(&build_health_human_lines(&report, &root));
2808 assert!(
2809 text.contains(
2810 "CRAP scores use Istanbul coverage where matched (1/2 functions); unmatched functions are estimated"
2811 ),
2812 "Istanbul counts should drive the note even when coverage_model is omitted: {text}"
2813 );
2814 }
2815
2816 #[test]
2817 fn health_findings_grouped_by_file() {
2818 let root = PathBuf::from("/project");
2819 let report = fallow_output::HealthReport {
2820 findings: vec![
2821 fallow_output::ComplexityViolation {
2822 path: root.join("src/parser.ts"),
2823 name: "fn1".to_string(),
2824 line: 10,
2825 col: 0,
2826 cyclomatic: 25,
2827 cognitive: 20,
2828 line_count: 40,
2829 param_count: 0,
2830 react_hook_count: 0,
2831 react_jsx_max_depth: 0,
2832 react_prop_count: 0,
2833 react_hook_profile: None,
2834 exceeded: fallow_output::ExceededThreshold::Both,
2835 severity: fallow_output::FindingSeverity::High,
2836 crap: None,
2837 coverage_pct: None,
2838 coverage_tier: None,
2839 coverage_source: None,
2840 inherited_from: None,
2841 component_rollup: None,
2842 contributions: Vec::new(),
2843 effective_thresholds: None,
2844 threshold_source: None,
2845 }
2846 .into(),
2847 fallow_output::ComplexityViolation {
2848 path: root.join("src/parser.ts"),
2849 name: "fn2".to_string(),
2850 line: 60,
2851 col: 0,
2852 cyclomatic: 22,
2853 cognitive: 18,
2854 line_count: 30,
2855 param_count: 0,
2856 react_hook_count: 0,
2857 react_jsx_max_depth: 0,
2858 react_prop_count: 0,
2859 react_hook_profile: None,
2860 exceeded: fallow_output::ExceededThreshold::Both,
2861 severity: fallow_output::FindingSeverity::High,
2862 crap: None,
2863 coverage_pct: None,
2864 coverage_tier: None,
2865 coverage_source: None,
2866 inherited_from: None,
2867 component_rollup: None,
2868 contributions: Vec::new(),
2869 effective_thresholds: None,
2870 threshold_source: None,
2871 }
2872 .into(),
2873 ],
2874 summary: fallow_output::HealthSummary {
2875 files_analyzed: 10,
2876 functions_analyzed: 50,
2877 functions_above_threshold: 2,
2878 ..Default::default()
2879 },
2880 ..Default::default()
2881 };
2882 let lines = build_health_human_lines(&report, &root);
2883 let text = plain(&lines);
2884 let count = text.matches("src/parser.ts").count();
2885 assert_eq!(count, 1, "File header should appear once for grouped items");
2886 }
2887
2888 fn empty_report() -> fallow_output::HealthReport {
2889 fallow_output::HealthReport {
2890 summary: fallow_output::HealthSummary {
2891 files_analyzed: 10,
2892 functions_analyzed: 50,
2893 ..Default::default()
2894 },
2895 ..Default::default()
2896 }
2897 }
2898
2899 #[test]
2900 fn health_runtime_coverage_renders_section() {
2901 let root = PathBuf::from("/project");
2902 let mut report = empty_report();
2903 report.runtime_coverage = Some(fallow_output::RuntimeCoverageReport {
2904 schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
2905 verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
2906 signals: Vec::new(),
2907 summary: fallow_output::RuntimeCoverageSummary {
2908 data_source: fallow_output::RuntimeCoverageDataSource::Local,
2909 last_received_at: None,
2910 functions_tracked: 4,
2911 functions_hit: 2,
2912 functions_unhit: 1,
2913 functions_untracked: 1,
2914 coverage_percent: 50.0,
2915 trace_count: 2_847_291,
2916 period_days: 30,
2917 deployments_seen: 14,
2918 capture_quality: None,
2919 },
2920 findings: vec![fallow_output::RuntimeCoverageFinding {
2921 id: "fallow:prod:deadbeef".to_owned(),
2922 stable_id: None,
2923 path: root.join("src/cold.ts"),
2924 function: "coldPath".to_owned(),
2925 line: 14,
2926 verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
2927 invocations: Some(0),
2928 confidence: fallow_output::RuntimeCoverageConfidence::Medium,
2929 evidence: fallow_output::RuntimeCoverageEvidence {
2930 static_status: "used".to_owned(),
2931 test_coverage: "not_covered".to_owned(),
2932 v8_tracking: "tracked".to_owned(),
2933 untracked_reason: None,
2934 observation_days: 30,
2935 deployments_observed: 14,
2936 },
2937 actions: vec![],
2938 source_hash: None,
2939 discriminators: None,
2940 }],
2941 hot_paths: vec![fallow_output::RuntimeCoverageHotPath {
2942 id: "fallow:hot:cafebabe".to_owned(),
2943 stable_id: None,
2944 path: root.join("src/hot.ts"),
2945 function: "hotPath".to_owned(),
2946 line: 3,
2947 end_line: 9,
2948 invocations: 250,
2949 percentile: 99,
2950 actions: vec![],
2951 }],
2952 blast_radius: vec![],
2953 importance: vec![],
2954 watermark: Some(fallow_output::RuntimeCoverageWatermark::LicenseExpiredGrace),
2955 warnings: vec![],
2956 actionable: true,
2957 actionability_reason: None,
2958 actionability_verdict: None,
2959 provenance: fallow_output::RuntimeCoverageProvenance::default(),
2960 });
2961
2962 let text = plain(&build_health_human_lines(&report, &root));
2963 assert!(text.contains("Runtime coverage: cold code detected"));
2964 assert!(text.contains("src/cold.ts:14 coldPath [0 invocations, review required]"));
2965 assert!(text.contains("license expired grace active"));
2966 assert!(text.contains("hot paths:"));
2967 assert!(text.contains("src/hot.ts:3 hotPath (250 invocations, p99)"));
2968 assert!(!text.contains("short capture:"));
2969 assert!(!text.contains("start a trial"));
2970 }
2971
2972 #[test]
2973 fn health_coverage_intelligence_renders_findings_and_ambiguity_summary() {
2974 use fallow_output::{
2975 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2976 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2977 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2978 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2979 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2980 };
2981
2982 let root = PathBuf::from("/project");
2983 let mut report = empty_report();
2984 report.coverage_intelligence = Some(CoverageIntelligenceReport {
2985 schema_version: CoverageIntelligenceSchemaVersion::V1,
2986 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2987 summary: CoverageIntelligenceSummary {
2988 findings: 1,
2989 high_confidence_deletes: 1,
2990 ..Default::default()
2991 },
2992 findings: vec![CoverageIntelligenceFinding {
2993 id: "fallow:coverage-intel:abc123".to_owned(),
2994 path: root.join("src/dead.ts"),
2995 identity: Some("deadPath".to_owned()),
2996 line: 9,
2997 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2998 signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2999 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
3000 confidence: CoverageIntelligenceConfidence::High,
3001 related_ids: vec![],
3002 evidence: CoverageIntelligenceEvidence {
3003 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
3004 ..Default::default()
3005 },
3006 actions: vec![CoverageIntelligenceAction {
3007 kind: "delete-after-confirming-owner".to_owned(),
3008 description: "Confirm ownership before deleting".to_owned(),
3009 auto_fixable: false,
3010 }],
3011 }],
3012 });
3013
3014 let text = plain(&build_health_human_lines(&report, &root));
3015 assert!(text.contains("Coverage intelligence"));
3016 assert!(text.contains("src/dead.ts:9 deadPath high-confidence-delete"));
3017 assert!(text.contains("Confirm ownership before deleting"));
3018
3019 report.coverage_intelligence = Some(CoverageIntelligenceReport {
3020 schema_version: CoverageIntelligenceSchemaVersion::V1,
3021 verdict: CoverageIntelligenceVerdict::Clean,
3022 summary: CoverageIntelligenceSummary {
3023 skipped_ambiguous_matches: 2,
3024 ..Default::default()
3025 },
3026 findings: vec![],
3027 });
3028 let text = plain(&build_health_human_lines(&report, &root));
3029 assert!(text.contains("skipped 2 ambiguous evidence matches"));
3030 }
3031
3032 fn runtime_coverage_report_with_quality(
3033 quality: Option<fallow_output::RuntimeCoverageCaptureQuality>,
3034 ) -> fallow_output::RuntimeCoverageReport {
3035 fallow_output::RuntimeCoverageReport {
3036 schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
3037 verdict: fallow_output::RuntimeCoverageReportVerdict::Clean,
3038 signals: Vec::new(),
3039 summary: fallow_output::RuntimeCoverageSummary {
3040 data_source: fallow_output::RuntimeCoverageDataSource::Local,
3041 last_received_at: None,
3042 functions_tracked: 10,
3043 functions_hit: 7,
3044 functions_unhit: 0,
3045 functions_untracked: 3,
3046 coverage_percent: 70.0,
3047 trace_count: 1_000,
3048 period_days: 1,
3049 deployments_seen: 1,
3050 capture_quality: quality,
3051 },
3052 findings: vec![],
3053 hot_paths: vec![],
3054 blast_radius: vec![],
3055 importance: vec![],
3056 watermark: None,
3057 warnings: vec![],
3058 actionable: true,
3059 actionability_reason: None,
3060 actionability_verdict: None,
3061 provenance: fallow_output::RuntimeCoverageProvenance::default(),
3062 }
3063 }
3064
3065 #[test]
3066 fn health_runtime_coverage_short_capture_shows_warning_and_prompt() {
3067 let root = PathBuf::from("/project");
3068 let mut report = empty_report();
3069 report.runtime_coverage = Some(runtime_coverage_report_with_quality(Some(
3070 fallow_output::RuntimeCoverageCaptureQuality {
3071 window_seconds: 720, instances_observed: 1,
3073 lazy_parse_warning: true,
3074 untracked_ratio_percent: 42.5,
3075 },
3076 )));
3077 let text = plain(&build_health_human_lines(&report, &root));
3078 assert!(
3079 text.contains(
3080 "note: short capture (12 min from 1 instance); 42.5% of functions untracked, lazy-parsed scripts may not appear."
3081 ),
3082 "warning banner missing or malformed in:\n{text}"
3083 );
3084 assert!(
3085 text.contains("extend the capture or switch to continuous monitoring"),
3086 "warning follow-up line missing in:\n{text}"
3087 );
3088 assert!(
3089 text.contains("captured 12 min from 1 instance."),
3090 "upgrade prompt header missing in:\n{text}"
3091 );
3092 assert!(
3093 text.contains("continuous monitoring over 30 days evaluates more paths"),
3094 "upgrade prompt body missing in:\n{text}"
3095 );
3096 assert!(
3097 text.contains("fallow license activate --trial --email you@company.com"),
3098 "trial CTA command missing in:\n{text}"
3099 );
3100 }
3101
3102 #[test]
3103 fn health_runtime_coverage_long_capture_shows_neither_warning_nor_prompt() {
3104 let root = PathBuf::from("/project");
3105 let mut report = empty_report();
3106 report.runtime_coverage = Some(runtime_coverage_report_with_quality(Some(
3107 fallow_output::RuntimeCoverageCaptureQuality {
3108 window_seconds: 7 * 24 * 3600, instances_observed: 4,
3110 lazy_parse_warning: false,
3111 untracked_ratio_percent: 3.1,
3112 },
3113 )));
3114 let text = plain(&build_health_human_lines(&report, &root));
3115 assert!(
3116 !text.contains("short capture"),
3117 "long capture should not emit short-capture warning:\n{text}"
3118 );
3119 assert!(
3120 !text.contains("start a trial"),
3121 "long capture should not emit trial CTA:\n{text}"
3122 );
3123 }
3124
3125 #[test]
3126 fn format_window_labels() {
3127 assert_eq!(super::format_window(30), "30 s");
3128 assert_eq!(super::format_window(60), "1 min");
3129 assert_eq!(super::format_window(720), "12 min");
3130 assert_eq!(super::format_window(3600 * 3), "3 h");
3131 assert_eq!(super::format_window(3600 * 24 * 3), "3 d");
3132 }
3133
3134 #[test]
3135 fn health_coverage_gaps_render_section() {
3136 use fallow_output::*;
3137
3138 let root = PathBuf::from("/project");
3139 let mut report = empty_report();
3140 report.coverage_gaps = Some(CoverageGaps {
3141 summary: CoverageGapSummary {
3142 runtime_files: 1,
3143 covered_files: 0,
3144 file_coverage_pct: 0.0,
3145 untested_files: 1,
3146 untested_exports: 1,
3147 },
3148 files: vec![UntestedFileFinding::with_actions(
3149 UntestedFile {
3150 path: root.join("src/app.ts"),
3151 value_export_count: 2,
3152 },
3153 &root,
3154 )],
3155 exports: vec![UntestedExportFinding::with_actions(
3156 UntestedExport {
3157 path: root.join("src/app.ts"),
3158 export_name: "loader".into(),
3159 line: 12,
3160 col: 4,
3161 },
3162 &root,
3163 )],
3164 });
3165
3166 let text = plain(&build_health_human_lines(&report, &root));
3167 assert!(
3168 text.contains("Coverage gaps (1 untested file, 1 untested export, 0.0% file coverage)")
3169 );
3170 assert!(text.contains("src/app.ts"));
3171 assert!(text.contains("loader"));
3172 }
3173
3174 #[test]
3175 fn fmt_trend_val_percentage() {
3176 assert_eq!(fmt_trend_val(15.5, "%"), "15.5%");
3177 assert_eq!(fmt_trend_val(0.0, "%"), "0.0%");
3178 }
3179
3180 #[test]
3181 fn fmt_trend_val_integer_when_round() {
3182 assert_eq!(fmt_trend_val(72.0, ""), "72");
3183 assert_eq!(fmt_trend_val(5.0, "pts"), "5");
3184 }
3185
3186 #[test]
3187 fn fmt_trend_val_decimal_when_fractional() {
3188 assert_eq!(fmt_trend_val(4.7, ""), "4.7");
3189 assert_eq!(fmt_trend_val(1.3, "pts"), "1.3");
3190 }
3191
3192 #[test]
3193 fn fmt_trend_delta_percentage() {
3194 assert_eq!(fmt_trend_delta(2.5, "%"), "+2.5%");
3195 assert_eq!(fmt_trend_delta(-1.3, "%"), "-1.3%");
3196 }
3197
3198 #[test]
3199 fn fmt_trend_delta_integer_when_round() {
3200 assert_eq!(fmt_trend_delta(5.0, ""), "+5");
3201 assert_eq!(fmt_trend_delta(-3.0, "pts"), "-3");
3202 }
3203
3204 #[test]
3205 fn fmt_trend_delta_decimal_when_fractional() {
3206 assert_eq!(fmt_trend_delta(4.9, ""), "+4.9");
3207 assert_eq!(fmt_trend_delta(-0.7, "pts"), "-0.7");
3208 }
3209
3210 #[test]
3211 fn health_score_grade_a_display() {
3212 let root = PathBuf::from("/project");
3213 let mut report = empty_report();
3214 report.health_score = Some(fallow_output::HealthScore {
3215 formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
3216 score: 92.0,
3217 grade: "A",
3218 penalties: fallow_output::HealthScorePenalties {
3219 dead_files: Some(3.0),
3220 dead_exports: Some(2.0),
3221 complexity: 1.5,
3222 p90_complexity: 1.5,
3223 maintainability: Some(0.0),
3224 hotspots: Some(0.0),
3225 unused_deps: Some(0.0),
3226 circular_deps: Some(0.0),
3227 unit_size: None,
3228 coupling: None,
3229 duplication: None,
3230 prop_drilling: None,
3231 },
3232 });
3233 let lines = build_health_human_lines(&report, &root);
3234 let text = plain(&lines);
3235 assert!(text.contains("Health score:"));
3236 assert!(text.contains("92 A"));
3237 assert!(text.contains("dead files -3.0"));
3238 assert!(text.contains("dead exports -2.0"));
3239 assert!(text.contains("complexity -1.5"));
3240 assert!(text.contains("p90 -1.5"));
3241 }
3242
3243 #[test]
3244 fn health_score_grade_b_display() {
3245 let root = PathBuf::from("/project");
3246 let mut report = empty_report();
3247 report.health_score = Some(fallow_output::HealthScore {
3248 formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
3249 score: 76.0,
3250 grade: "B",
3251 penalties: fallow_output::HealthScorePenalties {
3252 dead_files: Some(5.0),
3253 dead_exports: Some(6.0),
3254 complexity: 3.0,
3255 p90_complexity: 2.0,
3256 maintainability: Some(4.0),
3257 hotspots: Some(2.0),
3258 unused_deps: Some(1.0),
3259 circular_deps: Some(1.0),
3260 unit_size: None,
3261 coupling: None,
3262 duplication: None,
3263 prop_drilling: None,
3264 },
3265 });
3266 let lines = build_health_human_lines(&report, &root);
3267 let text = plain(&lines);
3268 assert!(text.contains("76 B"));
3269 assert!(text.contains("dead exports -6.0"));
3270 assert!(text.contains("maintainability -4.0"));
3271 assert!(text.contains("hotspots -2.0"));
3272 assert!(text.contains("unused deps -1.0"));
3273 assert!(text.contains("circular deps -1.0"));
3274 }
3275
3276 #[test]
3277 fn health_score_grade_c_display() {
3278 let root = PathBuf::from("/project");
3279 let mut report = empty_report();
3280 report.health_score = Some(fallow_output::HealthScore {
3281 formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
3282 score: 60.0,
3283 grade: "C",
3284 penalties: fallow_output::HealthScorePenalties {
3285 dead_files: Some(10.0),
3286 dead_exports: Some(10.0),
3287 complexity: 10.0,
3288 p90_complexity: 5.0,
3289 maintainability: Some(5.0),
3290 hotspots: None,
3291 unused_deps: None,
3292 circular_deps: None,
3293 unit_size: None,
3294 coupling: None,
3295 duplication: None,
3296 prop_drilling: None,
3297 },
3298 });
3299 let lines = build_health_human_lines(&report, &root);
3300 let text = plain(&lines);
3301 assert!(text.contains("60 C"));
3302 }
3303
3304 #[test]
3305 fn health_score_grade_f_display() {
3306 let root = PathBuf::from("/project");
3307 let mut report = empty_report();
3308 report.health_score = Some(fallow_output::HealthScore {
3309 formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
3310 score: 30.0,
3311 grade: "F",
3312 penalties: fallow_output::HealthScorePenalties {
3313 dead_files: Some(15.0),
3314 dead_exports: Some(15.0),
3315 complexity: 20.0,
3316 p90_complexity: 10.0,
3317 maintainability: Some(10.0),
3318 hotspots: None,
3319 unused_deps: None,
3320 circular_deps: None,
3321 unit_size: None,
3322 coupling: None,
3323 duplication: None,
3324 prop_drilling: None,
3325 },
3326 });
3327 let lines = build_health_human_lines(&report, &root);
3328 let text = plain(&lines);
3329 assert!(text.contains("30 F"));
3330 }
3331
3332 #[test]
3333 fn health_score_na_components_shown() {
3334 let root = PathBuf::from("/project");
3335 let mut report = empty_report();
3336 report.health_score = Some(fallow_output::HealthScore {
3337 formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
3338 score: 90.0,
3339 grade: "A",
3340 penalties: fallow_output::HealthScorePenalties {
3341 dead_files: None,
3342 dead_exports: None,
3343 complexity: 0.0,
3344 p90_complexity: 0.0,
3345 maintainability: None,
3346 hotspots: None,
3347 unused_deps: None,
3348 circular_deps: None,
3349 unit_size: None,
3350 coupling: None,
3351 duplication: None,
3352 prop_drilling: None,
3353 },
3354 });
3355 let lines = build_health_human_lines(&report, &root);
3356 let text = plain(&lines);
3357 assert!(text.contains("N/A: dead code, maintainability, hotspots"));
3358 assert!(text.contains("enable the corresponding analysis flags"));
3359 }
3360
3361 #[test]
3362 fn health_score_no_na_when_all_present() {
3363 let root = PathBuf::from("/project");
3364 let mut report = empty_report();
3365 report.health_score = Some(fallow_output::HealthScore {
3366 formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
3367 score: 85.0,
3368 grade: "A",
3369 penalties: fallow_output::HealthScorePenalties {
3370 dead_files: Some(0.0),
3371 dead_exports: Some(0.0),
3372 complexity: 0.0,
3373 p90_complexity: 0.0,
3374 maintainability: Some(0.0),
3375 hotspots: Some(0.0),
3376 unused_deps: Some(0.0),
3377 circular_deps: Some(0.0),
3378 unit_size: None,
3379 coupling: None,
3380 duplication: None,
3381 prop_drilling: None,
3382 },
3383 });
3384 let lines = build_health_human_lines(&report, &root);
3385 let text = plain(&lines);
3386 assert!(!text.contains("N/A:"));
3387 }
3388
3389 #[test]
3390 fn health_score_zero_penalties_suppressed() {
3391 let root = PathBuf::from("/project");
3392 let mut report = empty_report();
3393 report.health_score = Some(fallow_output::HealthScore {
3394 formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
3395 score: 100.0,
3396 grade: "A",
3397 penalties: fallow_output::HealthScorePenalties {
3398 dead_files: Some(0.0),
3399 dead_exports: Some(0.0),
3400 complexity: 0.0,
3401 p90_complexity: 0.0,
3402 maintainability: Some(0.0),
3403 hotspots: Some(0.0),
3404 unused_deps: Some(0.0),
3405 circular_deps: Some(0.0),
3406 unit_size: None,
3407 coupling: None,
3408 duplication: None,
3409 prop_drilling: None,
3410 },
3411 });
3412 let lines = build_health_human_lines(&report, &root);
3413 let text = plain(&lines);
3414 assert!(!text.contains("dead files"));
3415 assert!(!text.contains("complexity -"));
3416 }
3417
3418 #[test]
3419 fn health_trend_improving_display() {
3420 let root = PathBuf::from("/project");
3421 let mut report = empty_report();
3422 report.health_trend = Some(fallow_output::HealthTrend {
3423 compared_to: fallow_output::TrendPoint {
3424 timestamp: "2026-03-25T14:30:00Z".into(),
3425 git_sha: Some("abc1234".into()),
3426 score: Some(72.0),
3427 grade: Some("B".into()),
3428 coverage_model: None,
3429 snapshot_schema_version: None,
3430 },
3431 metrics: vec![
3432 fallow_output::TrendMetric {
3433 name: "score",
3434 label: "Health Score",
3435 previous: 72.0,
3436 current: 85.0,
3437 delta: 13.0,
3438 direction: fallow_output::TrendDirection::Improving,
3439 unit: "",
3440 previous_count: None,
3441 current_count: None,
3442 },
3443 fallow_output::TrendMetric {
3444 name: "dead_file_pct",
3445 label: "Dead Files",
3446 previous: 10.0,
3447 current: 5.0,
3448 delta: -5.0,
3449 direction: fallow_output::TrendDirection::Improving,
3450 unit: "%",
3451 previous_count: None,
3452 current_count: None,
3453 },
3454 ],
3455 snapshots_loaded: 2,
3456 overall_direction: fallow_output::TrendDirection::Improving,
3457 });
3458 let lines = build_health_human_lines(&report, &root);
3459 let text = plain(&lines);
3460 assert!(text.contains("Trend:"));
3461 assert!(text.contains("improving"));
3462 assert!(text.contains("vs 2026-03-25"));
3463 assert!(text.contains("abc1234"));
3464 assert!(text.contains("Health Score"));
3465 assert!(text.contains("+13"));
3466 assert!(text.contains("Dead Files"));
3467 assert!(text.contains("-5.0%"));
3468 }
3469
3470 #[test]
3471 fn health_trend_declining_display() {
3472 let root = PathBuf::from("/project");
3473 let mut report = empty_report();
3474 report.health_trend = Some(fallow_output::HealthTrend {
3475 compared_to: fallow_output::TrendPoint {
3476 timestamp: "2026-03-20T10:00:00Z".into(),
3477 git_sha: None,
3478 score: None,
3479 grade: None,
3480 coverage_model: None,
3481 snapshot_schema_version: None,
3482 },
3483 metrics: vec![fallow_output::TrendMetric {
3484 name: "unused_deps",
3485 label: "Unused Deps",
3486 previous: 5.0,
3487 current: 10.0,
3488 delta: 5.0,
3489 direction: fallow_output::TrendDirection::Declining,
3490 unit: "",
3491 previous_count: None,
3492 current_count: None,
3493 }],
3494 snapshots_loaded: 1,
3495 overall_direction: fallow_output::TrendDirection::Declining,
3496 });
3497 let lines = build_health_human_lines(&report, &root);
3498 let text = plain(&lines);
3499 assert!(text.contains("declining"));
3500 assert!(text.contains("Unused Deps"));
3501 }
3502
3503 #[test]
3504 fn health_trend_all_stable_collapsed() {
3505 let root = PathBuf::from("/project");
3506 let mut report = empty_report();
3507 report.health_trend = Some(fallow_output::HealthTrend {
3508 compared_to: fallow_output::TrendPoint {
3509 timestamp: "2026-03-25T14:30:00Z".into(),
3510 git_sha: Some("def5678".into()),
3511 score: Some(80.0),
3512 grade: Some("B".into()),
3513 coverage_model: None,
3514 snapshot_schema_version: None,
3515 },
3516 metrics: vec![
3517 fallow_output::TrendMetric {
3518 name: "score",
3519 label: "Health Score",
3520 previous: 80.0,
3521 current: 80.0,
3522 delta: 0.0,
3523 direction: fallow_output::TrendDirection::Stable,
3524 unit: "",
3525 previous_count: None,
3526 current_count: None,
3527 },
3528 fallow_output::TrendMetric {
3529 name: "avg_cyclomatic",
3530 label: "Avg Cyclomatic",
3531 previous: 2.0,
3532 current: 2.0,
3533 delta: 0.0,
3534 direction: fallow_output::TrendDirection::Stable,
3535 unit: "",
3536 previous_count: None,
3537 current_count: None,
3538 },
3539 ],
3540 snapshots_loaded: 3,
3541 overall_direction: fallow_output::TrendDirection::Stable,
3542 });
3543 let lines = build_health_human_lines(&report, &root);
3544 let text = plain(&lines);
3545 assert!(text.contains("stable"));
3546 assert!(text.contains("All 2 metrics unchanged"));
3547 assert!(!text.contains("Health Score"));
3548 }
3549
3550 #[test]
3551 fn health_trend_without_sha() {
3552 let root = PathBuf::from("/project");
3553 let mut report = empty_report();
3554 report.health_trend = Some(fallow_output::HealthTrend {
3555 compared_to: fallow_output::TrendPoint {
3556 timestamp: "2026-03-20T10:00:00Z".into(),
3557 git_sha: None,
3558 score: None,
3559 grade: None,
3560 coverage_model: None,
3561 snapshot_schema_version: None,
3562 },
3563 metrics: vec![fallow_output::TrendMetric {
3564 name: "score",
3565 label: "Health Score",
3566 previous: 80.0,
3567 current: 82.0,
3568 delta: 2.0,
3569 direction: fallow_output::TrendDirection::Improving,
3570 unit: "",
3571 previous_count: None,
3572 current_count: None,
3573 }],
3574 snapshots_loaded: 1,
3575 overall_direction: fallow_output::TrendDirection::Improving,
3576 });
3577 let lines = build_health_human_lines(&report, &root);
3578 let text = plain(&lines);
3579 assert!(text.contains("vs 2026-03-20"));
3580 assert!(!text.contains("\u{00b7}"));
3581 }
3582
3583 #[test]
3584 fn vital_signs_shown_without_trend() {
3585 let root = PathBuf::from("/project");
3586 let mut report = empty_report();
3587 report.vital_signs = Some(fallow_output::VitalSigns {
3588 dead_file_pct: Some(3.2),
3589 dead_export_pct: Some(8.1),
3590 avg_cyclomatic: 4.7,
3591 p90_cyclomatic: 12,
3592 duplication_pct: None,
3593 hotspot_count: Some(2),
3594 maintainability_avg: Some(72.4),
3595 unused_dep_count: Some(3),
3596 circular_dep_count: Some(1),
3597 counts: None,
3598 unit_size_profile: None,
3599 unit_interfacing_profile: None,
3600 p95_fan_in: None,
3601 coupling_high_pct: None,
3602 total_loc: 42_381,
3603 ..Default::default()
3604 });
3605 report.hotspot_summary = Some(fallow_output::HotspotSummary {
3606 since: "6 months".to_string(),
3607 min_commits: 3,
3608 files_analyzed: 50,
3609 files_excluded: 20,
3610 shallow_clone: false,
3611 });
3612 let lines = build_health_human_lines(&report, &root);
3613 let text = plain(&lines);
3614 assert!(text.contains("42,381 LOC"));
3615 assert!(text.contains("dead files 3.2%"));
3616 assert!(text.contains("dead exports 8.1%"));
3617 assert!(text.contains("avg cyclomatic 4.7"));
3618 assert!(text.contains("p90 cyclomatic 12"));
3619 assert!(text.contains("maintainability 72.4"));
3620 assert!(text.contains("2 churn hotspots (since 6 months)"));
3621 assert!(text.contains("3 unused deps"));
3622 assert!(text.contains("1 circular dep"));
3623 }
3624
3625 #[test]
3626 fn vital_signs_zero_hotspots_still_show_window() {
3627 let root = PathBuf::from("/project");
3628 let mut report = empty_report();
3629 report.vital_signs = Some(fallow_output::VitalSigns {
3630 avg_cyclomatic: 2.0,
3631 p90_cyclomatic: 5,
3632 hotspot_count: Some(0),
3633 total_loc: 1_000,
3634 ..Default::default()
3635 });
3636 report.hotspot_summary = Some(fallow_output::HotspotSummary {
3637 since: "90 days".to_string(),
3638 min_commits: 3,
3639 files_analyzed: 10,
3640 files_excluded: 0,
3641 shallow_clone: false,
3642 });
3643 let lines = build_health_human_lines(&report, &root);
3644 let text = plain(&lines);
3645 assert!(text.contains("0 churn hotspots (since 90 days)"));
3646 assert!(!text.contains("Hotspots ("));
3647 }
3648
3649 #[test]
3650 fn vital_signs_hotspot_count_without_summary_omits_window() {
3651 let root = PathBuf::from("/project");
3652 let mut report = empty_report();
3653 report.vital_signs = Some(fallow_output::VitalSigns {
3654 avg_cyclomatic: 2.0,
3655 p90_cyclomatic: 5,
3656 hotspot_count: Some(1),
3657 total_loc: 1_000,
3658 ..Default::default()
3659 });
3660 report.hotspot_summary = None;
3661 let lines = build_health_human_lines(&report, &root);
3662 let text = plain(&lines);
3663 assert!(text.contains("1 churn hotspot"));
3664 assert!(!text.contains("(since"));
3665 }
3666
3667 #[test]
3668 fn vital_signs_suppressed_when_trend_active() {
3669 let root = PathBuf::from("/project");
3670 let mut report = empty_report();
3671 report.vital_signs = Some(fallow_output::VitalSigns {
3672 dead_file_pct: Some(3.2),
3673 dead_export_pct: Some(8.1),
3674 avg_cyclomatic: 4.7,
3675 p90_cyclomatic: 12,
3676 duplication_pct: None,
3677 hotspot_count: Some(2),
3678 maintainability_avg: Some(72.4),
3679 unused_dep_count: None,
3680 circular_dep_count: None,
3681 counts: None,
3682 unit_size_profile: None,
3683 unit_interfacing_profile: None,
3684 p95_fan_in: None,
3685 coupling_high_pct: None,
3686 total_loc: 0,
3687 ..Default::default()
3688 });
3689 report.health_trend = Some(fallow_output::HealthTrend {
3690 compared_to: fallow_output::TrendPoint {
3691 timestamp: "2026-03-25T14:30:00Z".into(),
3692 git_sha: None,
3693 score: None,
3694 grade: None,
3695 coverage_model: None,
3696 snapshot_schema_version: None,
3697 },
3698 metrics: vec![],
3699 snapshots_loaded: 1,
3700 overall_direction: fallow_output::TrendDirection::Stable,
3701 });
3702 let lines = build_health_human_lines(&report, &root);
3703 let text = plain(&lines);
3704 assert!(!text.contains("dead files"));
3705 assert!(!text.contains("avg cyclomatic"));
3706 }
3707
3708 #[test]
3709 fn vital_signs_optional_fields_omitted_when_none() {
3710 let root = PathBuf::from("/project");
3711 let mut report = empty_report();
3712 report.vital_signs = Some(fallow_output::VitalSigns {
3713 dead_file_pct: None,
3714 dead_export_pct: None,
3715 avg_cyclomatic: 2.0,
3716 p90_cyclomatic: 5,
3717 duplication_pct: None,
3718 hotspot_count: None,
3719 maintainability_avg: None,
3720 unused_dep_count: None,
3721 circular_dep_count: None,
3722 counts: None,
3723 unit_size_profile: None,
3724 unit_interfacing_profile: None,
3725 p95_fan_in: None,
3726 coupling_high_pct: None,
3727 total_loc: 0,
3728 ..Default::default()
3729 });
3730 let lines = build_health_human_lines(&report, &root);
3731 let text = plain(&lines);
3732 assert!(!text.contains("dead files"));
3733 assert!(!text.contains("dead exports"));
3734 assert!(!text.contains("maintainability "));
3735 assert!(!text.contains("hotspot"));
3736 assert!(text.contains("avg cyclomatic 2.0"));
3737 assert!(text.contains("p90 cyclomatic 5"));
3738 }
3739
3740 #[test]
3741 fn vital_signs_zero_counts_suppressed() {
3742 let root = PathBuf::from("/project");
3743 let mut report = empty_report();
3744 report.vital_signs = Some(fallow_output::VitalSigns {
3745 dead_file_pct: None,
3746 dead_export_pct: None,
3747 avg_cyclomatic: 1.0,
3748 p90_cyclomatic: 2,
3749 duplication_pct: None,
3750 hotspot_count: None,
3751 maintainability_avg: None,
3752 unused_dep_count: Some(0),
3753 circular_dep_count: Some(0),
3754 counts: None,
3755 unit_size_profile: None,
3756 unit_interfacing_profile: None,
3757 p95_fan_in: None,
3758 coupling_high_pct: None,
3759 total_loc: 0,
3760 ..Default::default()
3761 });
3762 let lines = build_health_human_lines(&report, &root);
3763 let text = plain(&lines);
3764 assert!(!text.contains("unused dep"));
3765 assert!(!text.contains("circular dep"));
3766 }
3767
3768 #[test]
3769 fn vital_signs_plural_vs_singular() {
3770 let root = PathBuf::from("/project");
3771 let mut report = empty_report();
3772 report.vital_signs = Some(fallow_output::VitalSigns {
3773 dead_file_pct: None,
3774 dead_export_pct: None,
3775 avg_cyclomatic: 1.0,
3776 p90_cyclomatic: 2,
3777 duplication_pct: None,
3778 hotspot_count: Some(1),
3779 maintainability_avg: None,
3780 unused_dep_count: Some(1),
3781 circular_dep_count: Some(2),
3782 counts: None,
3783 unit_size_profile: None,
3784 unit_interfacing_profile: None,
3785 p95_fan_in: None,
3786 coupling_high_pct: None,
3787 total_loc: 0,
3788 ..Default::default()
3789 });
3790 let lines = build_health_human_lines(&report, &root);
3791 let text = plain(&lines);
3792 assert!(text.contains("1 churn hotspot"));
3793 assert!(!text.contains("1 churn hotspots"));
3794 assert!(text.contains("1 unused dep"));
3795 assert!(!text.contains("1 unused deps"));
3796 assert!(text.contains("2 circular deps"));
3797 }
3798
3799 #[test]
3800 fn file_scores_single_entry() {
3801 let root = PathBuf::from("/project");
3802 let mut report = empty_report();
3803 report.file_scores = vec![fallow_output::FileHealthScore {
3804 path: root.join("src/utils.ts"),
3805 fan_in: 5,
3806 fan_out: 3,
3807 dead_code_ratio: 0.15,
3808 complexity_density: 0.42,
3809 maintainability_index: 85.3,
3810 total_cyclomatic: 12,
3811 total_cognitive: 8,
3812 function_count: 4,
3813 lines: 200,
3814 crap_max: 0.0,
3815 crap_above_threshold: 0,
3816 }];
3817 let lines = build_health_human_lines(&report, &root);
3818 let text = plain(&lines);
3819 assert!(text.contains("File health scores (1 files)"));
3820 assert!(text.contains("85.3"));
3821 assert!(text.contains("src/utils.ts"));
3822 assert!(text.contains("200 LOC"));
3823 assert!(text.contains("5 fan-in"));
3824 assert!(text.contains("3 fan-out"));
3825 assert!(text.contains("15% dead"));
3826 assert!(text.contains("0.42 density"));
3827 }
3828
3829 #[test]
3830 fn file_scores_concern_tag_marks_risk_vs_structure() {
3831 let root = PathBuf::from("/project");
3832 let mut report = empty_report();
3833 report.file_scores = vec![
3834 fallow_output::FileHealthScore {
3835 path: root.join("src/risky.ts"),
3836 fan_in: 0,
3837 fan_out: 0,
3838 dead_code_ratio: 0.0,
3839 complexity_density: 0.2,
3840 maintainability_index: 85.0,
3841 total_cyclomatic: 10,
3842 total_cognitive: 8,
3843 function_count: 1,
3844 lines: 100,
3845 crap_max: 552.0,
3846 crap_above_threshold: 1,
3847 },
3848 fallow_output::FileHealthScore {
3849 path: root.join("src/messy.ts"),
3850 fan_in: 0,
3851 fan_out: 0,
3852 dead_code_ratio: 0.0,
3853 complexity_density: 0.3,
3854 maintainability_index: 30.0,
3855 total_cyclomatic: 5,
3856 total_cognitive: 3,
3857 function_count: 1,
3858 lines: 100,
3859 crap_max: 2.0,
3860 crap_above_threshold: 0,
3861 },
3862 ];
3863 let text = plain(&build_health_human_lines(&report, &root));
3864 let risky_line = text
3865 .lines()
3866 .find(|l| l.contains("risky.ts"))
3867 .expect("risky path line");
3868 assert!(
3869 risky_line.trim_end().ends_with("risk"),
3870 "expected risk tag, got: {risky_line:?}"
3871 );
3872 let messy_line = text
3873 .lines()
3874 .find(|l| l.contains("messy.ts"))
3875 .expect("messy path line");
3876 assert!(
3877 messy_line.trim_end().ends_with("structure"),
3878 "expected structure tag, got: {messy_line:?}"
3879 );
3880 }
3881
3882 #[test]
3883 fn file_scores_mi_color_thresholds() {
3884 let root = PathBuf::from("/project");
3885 let mut report = empty_report();
3886 report.file_scores = vec![
3887 fallow_output::FileHealthScore {
3888 path: root.join("src/good.ts"),
3889 fan_in: 1,
3890 fan_out: 1,
3891 dead_code_ratio: 0.0,
3892 complexity_density: 0.1,
3893 maintainability_index: 90.0, total_cyclomatic: 2,
3895 total_cognitive: 1,
3896 function_count: 1,
3897 lines: 50,
3898 crap_max: 0.0,
3899 crap_above_threshold: 0,
3900 },
3901 fallow_output::FileHealthScore {
3902 path: root.join("src/okay.ts"),
3903 fan_in: 2,
3904 fan_out: 3,
3905 dead_code_ratio: 0.1,
3906 complexity_density: 0.3,
3907 maintainability_index: 65.0, total_cyclomatic: 8,
3909 total_cognitive: 5,
3910 function_count: 3,
3911 lines: 100,
3912 crap_max: 0.0,
3913 crap_above_threshold: 0,
3914 },
3915 fallow_output::FileHealthScore {
3916 path: root.join("src/bad.ts"),
3917 fan_in: 8,
3918 fan_out: 12,
3919 dead_code_ratio: 0.5,
3920 complexity_density: 0.9,
3921 maintainability_index: 30.0, total_cyclomatic: 40,
3923 total_cognitive: 30,
3924 function_count: 10,
3925 lines: 500,
3926 crap_max: 0.0,
3927 crap_above_threshold: 0,
3928 },
3929 ];
3930 let lines = build_health_human_lines(&report, &root);
3931 let text = plain(&lines);
3932 assert!(text.contains("File health scores (3 files)"));
3933 assert!(text.contains("90.0"));
3934 assert!(text.contains("65.0"));
3935 assert!(text.contains("30.0"));
3936 }
3937
3938 #[test]
3939 fn file_scores_truncation_above_max_flat_items() {
3940 let root = PathBuf::from("/project");
3941 let mut report = empty_report();
3942 for i in 0..12 {
3943 report.file_scores.push(fallow_output::FileHealthScore {
3944 path: root.join(format!("src/file{i}.ts")),
3945 fan_in: 1,
3946 fan_out: 1,
3947 dead_code_ratio: 0.0,
3948 complexity_density: 0.1,
3949 maintainability_index: 80.0,
3950 total_cyclomatic: 2,
3951 total_cognitive: 1,
3952 function_count: 1,
3953 lines: 50,
3954 crap_max: 0.0,
3955 crap_above_threshold: 0,
3956 });
3957 }
3958 let lines = build_health_human_lines(&report, &root);
3959 let text = plain(&lines);
3960 assert!(text.contains("File health scores (12 files)"));
3961 assert!(text.contains("... and 2 more files"));
3962 assert!(text.contains("file0.ts"));
3963 assert!(text.contains("file9.ts"));
3964 assert!(!text.contains("file10.ts"));
3965 assert!(!text.contains("file11.ts"));
3966 }
3967
3968 #[test]
3969 fn file_scores_docs_link() {
3970 let root = PathBuf::from("/project");
3971 let mut report = empty_report();
3972 report.file_scores = vec![fallow_output::FileHealthScore {
3973 path: root.join("src/a.ts"),
3974 fan_in: 1,
3975 fan_out: 1,
3976 dead_code_ratio: 0.0,
3977 complexity_density: 0.1,
3978 maintainability_index: 80.0,
3979 total_cyclomatic: 2,
3980 total_cognitive: 1,
3981 function_count: 1,
3982 lines: 50,
3983 crap_max: 0.0,
3984 crap_above_threshold: 0,
3985 }];
3986 let lines = build_health_human_lines(&report, &root);
3987 let text = plain(&lines);
3988 assert!(text.contains("docs.fallow.tools/explanations/health#file-health-scores"));
3989 }
3990
3991 #[test]
3992 fn hotspots_accelerating_trend() {
3993 let root = PathBuf::from("/project");
3994 let mut report = empty_report();
3995 report.hotspots = vec![
3996 fallow_output::HotspotEntry {
3997 path: root.join("src/core.ts"),
3998 score: 75.0,
3999 commits: 42,
4000 weighted_commits: 30.0,
4001 lines_added: 500,
4002 lines_deleted: 200,
4003 complexity_density: 0.85,
4004 fan_in: 10,
4005 trend: fallow_engine::churn::ChurnTrend::Accelerating,
4006 ownership: None,
4007 is_test_path: false,
4008 }
4009 .into(),
4010 ];
4011 let lines = build_health_human_lines(&report, &root);
4012 let text = plain(&lines);
4013 assert!(text.contains("Hotspots (1 files)"));
4014 assert!(text.contains("75.0"));
4015 assert!(text.contains("src/core.ts"));
4016 assert!(text.contains("42 commits"));
4017 assert!(text.contains("700 churn"));
4018 assert!(text.contains("0.85 density"));
4019 assert!(text.contains("10 fan-in"));
4020 assert!(text.contains("accelerating"));
4021 }
4022
4023 #[test]
4024 fn hotspots_cooling_trend() {
4025 let root = PathBuf::from("/project");
4026 let mut report = empty_report();
4027 report.hotspots = vec![
4028 fallow_output::HotspotEntry {
4029 path: root.join("src/old.ts"),
4030 score: 20.0,
4031 commits: 5,
4032 weighted_commits: 2.0,
4033 lines_added: 50,
4034 lines_deleted: 30,
4035 complexity_density: 0.3,
4036 fan_in: 2,
4037 trend: fallow_engine::churn::ChurnTrend::Cooling,
4038 ownership: None,
4039 is_test_path: false,
4040 }
4041 .into(),
4042 ];
4043 let lines = build_health_human_lines(&report, &root);
4044 let text = plain(&lines);
4045 assert!(text.contains("20.0"));
4046 assert!(text.contains("cooling"));
4047 }
4048
4049 #[test]
4050 fn hotspots_stable_trend() {
4051 let root = PathBuf::from("/project");
4052 let mut report = empty_report();
4053 report.hotspots = vec![
4054 fallow_output::HotspotEntry {
4055 path: root.join("src/mid.ts"),
4056 score: 45.0,
4057 commits: 15,
4058 weighted_commits: 10.0,
4059 lines_added: 200,
4060 lines_deleted: 100,
4061 complexity_density: 0.5,
4062 fan_in: 5,
4063 trend: fallow_engine::churn::ChurnTrend::Stable,
4064 ownership: None,
4065 is_test_path: false,
4066 }
4067 .into(),
4068 ];
4069 let lines = build_health_human_lines(&report, &root);
4070 let text = plain(&lines);
4071 assert!(text.contains("45.0"));
4072 assert!(text.contains("stable"));
4073 }
4074
4075 #[test]
4076 fn hotspots_with_summary_and_since() {
4077 let root = PathBuf::from("/project");
4078 let mut report = empty_report();
4079 report.hotspots = vec![
4080 fallow_output::HotspotEntry {
4081 path: root.join("src/a.ts"),
4082 score: 50.0,
4083 commits: 10,
4084 weighted_commits: 8.0,
4085 lines_added: 100,
4086 lines_deleted: 50,
4087 complexity_density: 0.4,
4088 fan_in: 3,
4089 trend: fallow_engine::churn::ChurnTrend::Stable,
4090 ownership: None,
4091 is_test_path: false,
4092 }
4093 .into(),
4094 ];
4095 report.hotspot_summary = Some(fallow_output::HotspotSummary {
4096 since: "6 months".to_string(),
4097 min_commits: 3,
4098 files_analyzed: 50,
4099 files_excluded: 20,
4100 shallow_clone: false,
4101 });
4102 let lines = build_health_human_lines(&report, &root);
4103 let text = plain(&lines);
4104 assert!(text.contains("Hotspots (1 files, since 6 months)"));
4105 assert!(text.contains("20 files excluded (< 3 commits)"));
4106 }
4107
4108 #[test]
4109 fn hotspots_summary_no_exclusions() {
4110 let root = PathBuf::from("/project");
4111 let mut report = empty_report();
4112 report.hotspots = vec![
4113 fallow_output::HotspotEntry {
4114 path: root.join("src/a.ts"),
4115 score: 50.0,
4116 commits: 10,
4117 weighted_commits: 8.0,
4118 lines_added: 100,
4119 lines_deleted: 50,
4120 complexity_density: 0.4,
4121 fan_in: 3,
4122 trend: fallow_engine::churn::ChurnTrend::Stable,
4123 ownership: None,
4124 is_test_path: false,
4125 }
4126 .into(),
4127 ];
4128 report.hotspot_summary = Some(fallow_output::HotspotSummary {
4129 since: "3 months".to_string(),
4130 min_commits: 2,
4131 files_analyzed: 50,
4132 files_excluded: 0,
4133 shallow_clone: false,
4134 });
4135 let lines = build_health_human_lines(&report, &root);
4136 let text = plain(&lines);
4137 assert!(!text.contains("files excluded"));
4138 }
4139
4140 #[test]
4141 fn hotspots_docs_link() {
4142 let root = PathBuf::from("/project");
4143 let mut report = empty_report();
4144 report.hotspots = vec![
4145 fallow_output::HotspotEntry {
4146 path: root.join("src/a.ts"),
4147 score: 50.0,
4148 commits: 10,
4149 weighted_commits: 8.0,
4150 lines_added: 100,
4151 lines_deleted: 50,
4152 complexity_density: 0.4,
4153 fan_in: 3,
4154 trend: fallow_engine::churn::ChurnTrend::Stable,
4155 ownership: None,
4156 is_test_path: false,
4157 }
4158 .into(),
4159 ];
4160 let lines = build_health_human_lines(&report, &root);
4161 let text = plain(&lines);
4162 assert!(text.contains("docs.fallow.tools/explanations/health#hotspot-metrics"));
4163 }
4164
4165 #[test]
4166 fn refactoring_targets_single_low_effort() {
4167 let root = PathBuf::from("/project");
4168 let mut report = empty_report();
4169 report.targets = vec![
4170 fallow_output::RefactoringTarget {
4171 path: root.join("src/legacy.ts"),
4172 priority: 65.0,
4173 efficiency: 65.0,
4174 recommendation: "Extract complex logic into helper functions".to_string(),
4175 category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
4176 effort: fallow_output::EffortEstimate::Low,
4177 confidence: fallow_output::Confidence::High,
4178 factors: vec![],
4179 evidence: None,
4180 }
4181 .into(),
4182 ];
4183 let lines = build_health_human_lines(&report, &root);
4184 let text = plain(&lines);
4185 assert!(text.contains("Refactoring targets (1)"));
4186 assert!(text.contains("1 low effort"));
4187 assert!(text.contains("65.0"));
4188 assert!(text.contains("pri:65.0"));
4189 assert!(text.contains("src/legacy.ts"));
4190 assert!(text.contains("complexity"));
4191 assert!(text.contains("effort:low"));
4192 assert!(text.contains("confidence:high"));
4193 assert!(text.contains("Extract complex logic into helper functions"));
4194 }
4195
4196 #[test]
4197 fn refactoring_targets_render_non_empty_relation_evidence() {
4198 let root = PathBuf::from("/project");
4199 let mut report = empty_report();
4200 report.targets = vec![
4201 fallow_output::RefactoringTarget {
4202 path: root.join("src/legacy.ts"),
4203 priority: 65.0,
4204 efficiency: 65.0,
4205 recommendation: "Extract complex logic into helper functions".to_string(),
4206 category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
4207 effort: fallow_output::EffortEstimate::Low,
4208 confidence: fallow_output::Confidence::High,
4209 factors: vec![],
4210 evidence: Some(fallow_output::TargetEvidence {
4211 direct_callers: vec![fallow_output::DirectCallerEvidence {
4212 path: root.join("src/consumer.ts"),
4213 symbols: vec![
4214 fallow_output::DirectCallerSymbolEvidence {
4215 imported: "loadLegacy".into(),
4216 local: "load".into(),
4217 type_only: false,
4218 },
4219 fallow_output::DirectCallerSymbolEvidence {
4220 imported: "side-effect".into(),
4221 local: String::new(),
4222 type_only: false,
4223 },
4224 ],
4225 }],
4226 clone_siblings: vec![fallow_output::CloneSiblingEvidence {
4227 path: root.join("src/peer.ts"),
4228 start_line: 12,
4229 end_line: 20,
4230 fingerprint: "dup:12345678".into(),
4231 }],
4232 ..Default::default()
4233 }),
4234 }
4235 .into(),
4236 ];
4237 let lines = build_health_human_lines(&report, &root);
4238 let text = plain(&lines);
4239 assert!(text.contains("importers: src/consumer.ts (loadLegacy as load, side effect)"));
4240 assert!(!text.contains("side-effect"));
4241 assert!(text.contains("clones: src/peer.ts:12-20 dup:12345678"));
4242 }
4243
4244 #[test]
4245 fn refactoring_targets_mixed_effort() {
4246 let root = PathBuf::from("/project");
4247 let mut report = empty_report();
4248 report.targets = vec![
4249 fallow_output::RefactoringTarget {
4250 path: root.join("src/a.ts"),
4251 priority: 80.0,
4252 efficiency: 80.0,
4253 recommendation: "Remove dead exports".to_string(),
4254 category: fallow_output::RecommendationCategory::RemoveDeadCode,
4255 effort: fallow_output::EffortEstimate::Low,
4256 confidence: fallow_output::Confidence::High,
4257 factors: vec![],
4258 evidence: None,
4259 }
4260 .into(),
4261 fallow_output::RefactoringTarget {
4262 path: root.join("src/b.ts"),
4263 priority: 60.0,
4264 efficiency: 30.0,
4265 recommendation: "Split into smaller modules".to_string(),
4266 category: fallow_output::RecommendationCategory::SplitHighImpact,
4267 effort: fallow_output::EffortEstimate::Medium,
4268 confidence: fallow_output::Confidence::Medium,
4269 factors: vec![],
4270 evidence: None,
4271 }
4272 .into(),
4273 fallow_output::RefactoringTarget {
4274 path: root.join("src/c.ts"),
4275 priority: 50.0,
4276 efficiency: 16.7,
4277 recommendation: "Break circular dependency".to_string(),
4278 category: fallow_output::RecommendationCategory::BreakCircularDependency,
4279 effort: fallow_output::EffortEstimate::High,
4280 confidence: fallow_output::Confidence::Low,
4281 factors: vec![],
4282 evidence: None,
4283 }
4284 .into(),
4285 ];
4286 let lines = build_health_human_lines(&report, &root);
4287 let text = plain(&lines);
4288 assert!(text.contains("Refactoring targets (3)"));
4289 assert!(text.contains("1 low effort"));
4290 assert!(text.contains("1 medium"));
4291 assert!(text.contains("1 high"));
4292 assert!(text.contains("effort:low"));
4293 assert!(text.contains("effort:medium"));
4294 assert!(text.contains("effort:high"));
4295 assert!(text.contains("confidence:high"));
4296 assert!(text.contains("confidence:medium"));
4297 assert!(text.contains("confidence:low"));
4298 }
4299
4300 #[test]
4301 fn refactoring_targets_truncation_above_max_flat_items() {
4302 let root = PathBuf::from("/project");
4303 let mut report = empty_report();
4304 for i in 0..12 {
4305 report.targets.push(
4306 fallow_output::RefactoringTarget {
4307 path: root.join(format!("src/target{i}.ts")),
4308 priority: 50.0,
4309 efficiency: 25.0,
4310 recommendation: format!("Fix target {i}"),
4311 category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
4312 effort: fallow_output::EffortEstimate::Medium,
4313 confidence: fallow_output::Confidence::Medium,
4314 factors: vec![],
4315 evidence: None,
4316 }
4317 .into(),
4318 );
4319 }
4320 let lines = build_health_human_lines(&report, &root);
4321 let text = plain(&lines);
4322 assert!(text.contains("Refactoring targets (12)"));
4323 assert!(text.contains("... and 2 more targets"));
4324 assert!(text.contains("target0.ts"));
4325 assert!(text.contains("target9.ts"));
4326 assert!(!text.contains("target10.ts"));
4327 }
4328
4329 #[test]
4330 fn refactoring_targets_docs_link() {
4331 let root = PathBuf::from("/project");
4332 let mut report = empty_report();
4333 report.targets = vec![
4334 fallow_output::RefactoringTarget {
4335 path: root.join("src/a.ts"),
4336 priority: 50.0,
4337 efficiency: 50.0,
4338 recommendation: "Fix it".to_string(),
4339 category: fallow_output::RecommendationCategory::ExtractDependencies,
4340 effort: fallow_output::EffortEstimate::Low,
4341 confidence: fallow_output::Confidence::High,
4342 factors: vec![],
4343 evidence: None,
4344 }
4345 .into(),
4346 ];
4347 let lines = build_health_human_lines(&report, &root);
4348 let text = plain(&lines);
4349 assert!(text.contains("docs.fallow.tools/explanations/health#refactoring-targets"));
4350 }
4351
4352 #[test]
4353 fn refactoring_targets_all_categories() {
4354 let root = PathBuf::from("/project");
4355 let mut report = empty_report();
4356 let categories = [
4357 (
4358 fallow_output::RecommendationCategory::UrgentChurnComplexity,
4359 "churn+complexity",
4360 ),
4361 (
4362 fallow_output::RecommendationCategory::BreakCircularDependency,
4363 "circular dependency",
4364 ),
4365 (
4366 fallow_output::RecommendationCategory::SplitHighImpact,
4367 "high impact",
4368 ),
4369 (
4370 fallow_output::RecommendationCategory::RemoveDeadCode,
4371 "dead code",
4372 ),
4373 (
4374 fallow_output::RecommendationCategory::ExtractComplexFunctions,
4375 "complexity",
4376 ),
4377 (
4378 fallow_output::RecommendationCategory::ExtractDependencies,
4379 "coupling",
4380 ),
4381 (
4382 fallow_output::RecommendationCategory::AddTestCoverage,
4383 "untested risk",
4384 ),
4385 ];
4386 for (i, (cat, _label)) in categories.iter().enumerate() {
4387 report.targets.push(
4388 fallow_output::RefactoringTarget {
4389 path: root.join(format!("src/cat{i}.ts")),
4390 priority: 50.0,
4391 efficiency: 50.0,
4392 recommendation: format!("Fix cat{i}"),
4393 category: cat.clone(),
4394 effort: fallow_output::EffortEstimate::Low,
4395 confidence: fallow_output::Confidence::High,
4396 factors: vec![],
4397 evidence: None,
4398 }
4399 .into(),
4400 );
4401 }
4402 let lines = build_health_human_lines(&report, &root);
4403 let text = plain(&lines);
4404 for (_cat, label) in &categories {
4405 assert!(
4406 text.contains(label),
4407 "Expected category label '{label}' in output"
4408 );
4409 }
4410 }
4411
4412 #[test]
4413 fn refactoring_targets_efficiency_color_thresholds() {
4414 let root = PathBuf::from("/project");
4415 let mut report = empty_report();
4416 report.targets = vec![
4417 fallow_output::RefactoringTarget {
4418 path: root.join("src/high.ts"),
4419 priority: 50.0,
4420 efficiency: 50.0, recommendation: "High eff".to_string(),
4422 category: fallow_output::RecommendationCategory::RemoveDeadCode,
4423 effort: fallow_output::EffortEstimate::Low,
4424 confidence: fallow_output::Confidence::High,
4425 factors: vec![],
4426 evidence: None,
4427 }
4428 .into(),
4429 fallow_output::RefactoringTarget {
4430 path: root.join("src/mid.ts"),
4431 priority: 50.0,
4432 efficiency: 25.0, recommendation: "Mid eff".to_string(),
4434 category: fallow_output::RecommendationCategory::RemoveDeadCode,
4435 effort: fallow_output::EffortEstimate::Medium,
4436 confidence: fallow_output::Confidence::Medium,
4437 factors: vec![],
4438 evidence: None,
4439 }
4440 .into(),
4441 fallow_output::RefactoringTarget {
4442 path: root.join("src/low.ts"),
4443 priority: 50.0,
4444 efficiency: 10.0, recommendation: "Low eff".to_string(),
4446 category: fallow_output::RecommendationCategory::RemoveDeadCode,
4447 effort: fallow_output::EffortEstimate::High,
4448 confidence: fallow_output::Confidence::Low,
4449 factors: vec![],
4450 evidence: None,
4451 }
4452 .into(),
4453 ];
4454 let lines = build_health_human_lines(&report, &root);
4455 let text = plain(&lines);
4456 assert!(text.contains("50.0"));
4457 assert!(text.contains("25.0"));
4458 assert!(text.contains("10.0"));
4459 }
4460
4461 #[test]
4462 #[expect(
4463 clippy::too_many_lines,
4464 reason = "test fixture; linear setup/assert, length is not a maintainability concern"
4465 )]
4466 fn all_sections_combined() {
4467 let root = PathBuf::from("/project");
4468 let mut report = empty_report();
4469 report.summary.functions_above_threshold = 1;
4470 report.findings = vec![
4471 fallow_output::ComplexityViolation {
4472 path: root.join("src/complex.ts"),
4473 name: "bigFn".to_string(),
4474 line: 10,
4475 col: 0,
4476 cyclomatic: 25,
4477 cognitive: 20,
4478 line_count: 80,
4479 param_count: 0,
4480 react_hook_count: 0,
4481 react_jsx_max_depth: 0,
4482 react_prop_count: 0,
4483 react_hook_profile: None,
4484 exceeded: fallow_output::ExceededThreshold::Both,
4485 severity: fallow_output::FindingSeverity::Moderate,
4486 crap: None,
4487 coverage_pct: None,
4488 coverage_tier: None,
4489 coverage_source: None,
4490 inherited_from: None,
4491 component_rollup: None,
4492 contributions: Vec::new(),
4493 effective_thresholds: None,
4494 threshold_source: None,
4495 }
4496 .into(),
4497 ];
4498 report.health_score = Some(fallow_output::HealthScore {
4499 formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
4500 score: 75.0,
4501 grade: "B",
4502 penalties: fallow_output::HealthScorePenalties {
4503 dead_files: Some(5.0),
4504 dead_exports: Some(5.0),
4505 complexity: 5.0,
4506 p90_complexity: 2.0,
4507 maintainability: Some(3.0),
4508 hotspots: Some(2.0),
4509 unused_deps: Some(2.0),
4510 circular_deps: Some(1.0),
4511 unit_size: None,
4512 coupling: None,
4513 duplication: None,
4514 prop_drilling: None,
4515 },
4516 });
4517 report.file_scores = vec![fallow_output::FileHealthScore {
4518 path: root.join("src/complex.ts"),
4519 fan_in: 5,
4520 fan_out: 3,
4521 dead_code_ratio: 0.1,
4522 complexity_density: 0.5,
4523 maintainability_index: 60.0,
4524 total_cyclomatic: 15,
4525 total_cognitive: 10,
4526 function_count: 3,
4527 lines: 200,
4528 crap_max: 0.0,
4529 crap_above_threshold: 0,
4530 }];
4531 report.hotspots = vec![
4532 fallow_output::HotspotEntry {
4533 path: root.join("src/complex.ts"),
4534 score: 65.0,
4535 commits: 20,
4536 weighted_commits: 15.0,
4537 lines_added: 300,
4538 lines_deleted: 100,
4539 complexity_density: 0.5,
4540 fan_in: 5,
4541 trend: fallow_engine::churn::ChurnTrend::Accelerating,
4542 ownership: None,
4543 is_test_path: false,
4544 }
4545 .into(),
4546 ];
4547 report.targets = vec![
4548 fallow_output::RefactoringTarget {
4549 path: root.join("src/complex.ts"),
4550 priority: 70.0,
4551 efficiency: 70.0,
4552 recommendation: "Extract complex functions".to_string(),
4553 category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
4554 effort: fallow_output::EffortEstimate::Low,
4555 confidence: fallow_output::Confidence::High,
4556 factors: vec![],
4557 evidence: None,
4558 }
4559 .into(),
4560 ];
4561 let lines = build_health_human_lines(&report, &root);
4562 let text = plain(&lines);
4563 assert!(text.contains("Health score:"));
4564 assert!(text.contains("High complexity functions"));
4565 assert!(text.contains("File health scores"));
4566 assert!(text.contains("Hotspots"));
4567 assert!(text.contains("Refactoring targets"));
4568 }
4569
4570 #[test]
4571 fn completely_empty_report_produces_no_lines() {
4572 let root = PathBuf::from("/project");
4573 let report = empty_report();
4574 let lines = build_health_human_lines(&report, &root);
4575 assert!(lines.is_empty());
4576 }
4577
4578 #[test]
4579 fn finding_only_cyclomatic_exceeds() {
4580 let root = PathBuf::from("/project");
4581 let mut report = empty_report();
4582 report.summary.functions_above_threshold = 1;
4583 report.findings = vec![
4584 fallow_output::ComplexityViolation {
4585 path: root.join("src/a.ts"),
4586 name: "fn1".to_string(),
4587 line: 1,
4588 col: 0,
4589 cyclomatic: 25, cognitive: 10, line_count: 50,
4592 param_count: 0,
4593 react_hook_count: 0,
4594 react_jsx_max_depth: 0,
4595 react_prop_count: 0,
4596 react_hook_profile: None,
4597 exceeded: fallow_output::ExceededThreshold::Cyclomatic,
4598 severity: fallow_output::FindingSeverity::Moderate,
4599 crap: None,
4600 coverage_pct: None,
4601 coverage_tier: None,
4602 coverage_source: None,
4603 inherited_from: None,
4604 component_rollup: None,
4605 contributions: Vec::new(),
4606 effective_thresholds: None,
4607 threshold_source: None,
4608 }
4609 .into(),
4610 ];
4611 let lines = build_health_human_lines(&report, &root);
4612 let text = plain(&lines);
4613 assert!(text.contains("25 cyclomatic"));
4614 assert!(text.contains("10 cognitive"));
4615 }
4616
4617 #[test]
4618 fn finding_only_cognitive_exceeds() {
4619 let root = PathBuf::from("/project");
4620 let mut report = empty_report();
4621 report.summary.functions_above_threshold = 1;
4622 report.findings = vec![
4623 fallow_output::ComplexityViolation {
4624 path: root.join("src/a.ts"),
4625 name: "fn1".to_string(),
4626 line: 1,
4627 col: 0,
4628 cyclomatic: 10, cognitive: 25, line_count: 50,
4631 param_count: 0,
4632 react_hook_count: 0,
4633 react_jsx_max_depth: 0,
4634 react_prop_count: 0,
4635 react_hook_profile: None,
4636 exceeded: fallow_output::ExceededThreshold::Cognitive,
4637 severity: fallow_output::FindingSeverity::High,
4638 crap: None,
4639 coverage_pct: None,
4640 coverage_tier: None,
4641 coverage_source: None,
4642 inherited_from: None,
4643 component_rollup: None,
4644 contributions: Vec::new(),
4645 effective_thresholds: None,
4646 threshold_source: None,
4647 }
4648 .into(),
4649 ];
4650 let lines = build_health_human_lines(&report, &root);
4651 let text = plain(&lines);
4652 assert!(text.contains("10 cyclomatic"));
4653 assert!(text.contains("25 cognitive"));
4654 }
4655
4656 #[test]
4657 fn findings_across_multiple_files() {
4658 let root = PathBuf::from("/project");
4659 let mut report = empty_report();
4660 report.summary.functions_above_threshold = 2;
4661 report.findings = vec![
4662 fallow_output::ComplexityViolation {
4663 path: root.join("src/a.ts"),
4664 name: "fn1".to_string(),
4665 line: 1,
4666 col: 0,
4667 cyclomatic: 25,
4668 cognitive: 20,
4669 line_count: 50,
4670 param_count: 0,
4671 react_hook_count: 0,
4672 react_jsx_max_depth: 0,
4673 react_prop_count: 0,
4674 react_hook_profile: None,
4675 exceeded: fallow_output::ExceededThreshold::Both,
4676 severity: fallow_output::FindingSeverity::Moderate,
4677 crap: None,
4678 coverage_pct: None,
4679 coverage_tier: None,
4680 coverage_source: None,
4681 inherited_from: None,
4682 component_rollup: None,
4683 contributions: Vec::new(),
4684 effective_thresholds: None,
4685 threshold_source: None,
4686 }
4687 .into(),
4688 fallow_output::ComplexityViolation {
4689 path: root.join("src/b.ts"),
4690 name: "fn2".to_string(),
4691 line: 5,
4692 col: 0,
4693 cyclomatic: 22,
4694 cognitive: 18,
4695 line_count: 40,
4696 param_count: 0,
4697 react_hook_count: 0,
4698 react_jsx_max_depth: 0,
4699 react_prop_count: 0,
4700 react_hook_profile: None,
4701 exceeded: fallow_output::ExceededThreshold::Both,
4702 severity: fallow_output::FindingSeverity::Moderate,
4703 crap: None,
4704 coverage_pct: None,
4705 coverage_tier: None,
4706 coverage_source: None,
4707 inherited_from: None,
4708 component_rollup: None,
4709 contributions: Vec::new(),
4710 effective_thresholds: None,
4711 threshold_source: None,
4712 }
4713 .into(),
4714 ];
4715 let lines = build_health_human_lines(&report, &root);
4716 let text = plain(&lines);
4717 assert!(text.contains("src/a.ts"));
4718 assert!(text.contains("src/b.ts"));
4719 }
4720
4721 #[test]
4722 fn findings_docs_link() {
4723 let root = PathBuf::from("/project");
4724 let mut report = empty_report();
4725 report.summary.functions_above_threshold = 1;
4726 report.findings = vec![
4727 fallow_output::ComplexityViolation {
4728 path: root.join("src/a.ts"),
4729 name: "fn1".to_string(),
4730 line: 1,
4731 col: 0,
4732 cyclomatic: 25,
4733 cognitive: 20,
4734 line_count: 50,
4735 param_count: 0,
4736 react_hook_count: 0,
4737 react_jsx_max_depth: 0,
4738 react_prop_count: 0,
4739 react_hook_profile: None,
4740 exceeded: fallow_output::ExceededThreshold::Both,
4741 severity: fallow_output::FindingSeverity::Moderate,
4742 crap: None,
4743 coverage_pct: None,
4744 coverage_tier: None,
4745 coverage_source: None,
4746 inherited_from: None,
4747 component_rollup: None,
4748 contributions: Vec::new(),
4749 effective_thresholds: None,
4750 threshold_source: None,
4751 }
4752 .into(),
4753 ];
4754 let lines = build_health_human_lines(&report, &root);
4755 let text = plain(&lines);
4756 assert!(text.contains("docs.fallow.tools/explanations/health#complexity-metrics"));
4757 }
4758
4759 #[test]
4760 fn hotspot_score_high_medium_low() {
4761 let root = PathBuf::from("/project");
4762 let mut report = empty_report();
4763 report.hotspots = vec![
4764 fallow_output::HotspotEntry {
4765 path: root.join("src/high.ts"),
4766 score: 80.0, commits: 30,
4768 weighted_commits: 25.0,
4769 lines_added: 400,
4770 lines_deleted: 200,
4771 complexity_density: 0.9,
4772 fan_in: 8,
4773 trend: fallow_engine::churn::ChurnTrend::Accelerating,
4774 ownership: None,
4775 is_test_path: false,
4776 }
4777 .into(),
4778 fallow_output::HotspotEntry {
4779 path: root.join("src/medium.ts"),
4780 score: 45.0, commits: 15,
4782 weighted_commits: 10.0,
4783 lines_added: 200,
4784 lines_deleted: 100,
4785 complexity_density: 0.5,
4786 fan_in: 4,
4787 trend: fallow_engine::churn::ChurnTrend::Stable,
4788 ownership: None,
4789 is_test_path: false,
4790 }
4791 .into(),
4792 fallow_output::HotspotEntry {
4793 path: root.join("src/low.ts"),
4794 score: 15.0, commits: 5,
4796 weighted_commits: 3.0,
4797 lines_added: 50,
4798 lines_deleted: 20,
4799 complexity_density: 0.2,
4800 fan_in: 1,
4801 trend: fallow_engine::churn::ChurnTrend::Cooling,
4802 ownership: None,
4803 is_test_path: false,
4804 }
4805 .into(),
4806 ];
4807 let lines = build_health_human_lines(&report, &root);
4808 let text = plain(&lines);
4809 assert!(text.contains("80.0"));
4810 assert!(text.contains("45.0"));
4811 assert!(text.contains("15.0"));
4812 assert!(text.contains("Hotspots (3 files)"));
4813 }
4814
4815 #[test]
4816 fn rollup_breakdown_renders_workspace_relative_template_path() {
4817 let root = PathBuf::from("/project");
4818 let template =
4819 root.join("apps/admin/src/app/payments/payment-list/payment-list.component.html");
4820 let finding = fallow_output::ComplexityViolation {
4821 path: root.join("apps/admin/src/app/payments/payment-list/payment-list.component.ts"),
4822 name: "<component>".to_string(),
4823 line: 1,
4824 col: 0,
4825 cyclomatic: 25,
4826 cognitive: 28,
4827 line_count: 0,
4828 param_count: 0,
4829 react_hook_count: 0,
4830 react_jsx_max_depth: 0,
4831 react_prop_count: 0,
4832 react_hook_profile: None,
4833 exceeded: fallow_output::ExceededThreshold::Both,
4834 severity: fallow_output::FindingSeverity::High,
4835 crap: None,
4836 coverage_pct: None,
4837 coverage_tier: None,
4838 coverage_source: None,
4839 inherited_from: None,
4840 component_rollup: Some(fallow_output::ComponentRollup {
4841 component: "PaymentListComponent".to_string(),
4842 class_worst_function: "ngOnInit".to_string(),
4843 class_cyclomatic: 12,
4844 class_cognitive: 16,
4845 template_path: template,
4846 template_cyclomatic: 13,
4847 template_cognitive: 12,
4848 }),
4849 contributions: Vec::new(),
4850 effective_thresholds: None,
4851 threshold_source: None,
4852 };
4853 let line = render_component_rollup_breakdown(&finding, &root)
4854 .expect("rollup payload should render a breakdown line");
4855 assert!(
4856 line.contains("apps/admin/src/app/payments/payment-list/payment-list.component.html"),
4857 "breakdown must include workspace-relative template path: {line}"
4858 );
4859 assert!(
4860 !line.contains(" payment-list.component.html"),
4861 "bare basename token must not be the rendered template: {line}"
4862 );
4863 }
4864
4865 #[test]
4866 fn inherited_from_renders_workspace_relative_owner_path() {
4867 let root = PathBuf::from("/project");
4868 let owner = root.join("apps/admin/src/app/auth/permissions/permissions.component.ts");
4869 let template_path =
4870 root.join("apps/admin/src/app/auth/permissions/permissions.component.html");
4871 let report = fallow_output::HealthReport {
4872 findings: vec![
4873 fallow_output::ComplexityViolation {
4874 path: template_path,
4875 name: "<template>".to_string(),
4876 line: 1,
4877 col: 0,
4878 cyclomatic: 12,
4879 cognitive: 14,
4880 line_count: 0,
4881 param_count: 0,
4882 react_hook_count: 0,
4883 react_jsx_max_depth: 0,
4884 react_prop_count: 0,
4885 react_hook_profile: None,
4886 exceeded: fallow_output::ExceededThreshold::Both,
4887 severity: fallow_output::FindingSeverity::High,
4888 crap: Some(45.0),
4889 coverage_pct: None,
4890 coverage_tier: Some(fallow_output::CoverageTier::Partial),
4891 coverage_source: Some(
4892 fallow_output::CoverageSource::EstimatedComponentInherited,
4893 ),
4894 inherited_from: Some(owner),
4895 component_rollup: None,
4896 contributions: Vec::new(),
4897 effective_thresholds: None,
4898 threshold_source: None,
4899 }
4900 .into(),
4901 ],
4902 summary: fallow_output::HealthSummary {
4903 files_analyzed: 1,
4904 functions_analyzed: 1,
4905 functions_above_threshold: 1,
4906 ..Default::default()
4907 },
4908 ..Default::default()
4909 };
4910 let lines = build_health_human_lines(&report, &root);
4911 let text = plain(&lines);
4912 assert!(
4913 text.contains(
4914 "(inherited from apps/admin/src/app/auth/permissions/permissions.component.ts)"
4915 ),
4916 "inherited-from suffix must use workspace-relative path: {text}"
4917 );
4918 assert!(
4919 !text.contains("(inherited from permissions.component.ts)"),
4920 "bare basename suffix must not be rendered: {text}"
4921 );
4922 }
4923
4924 fn react_finding(
4925 react_hook_count: u16,
4926 react_prop_count: u16,
4927 react_jsx_max_depth: u16,
4928 profile: Option<fallow_output::ReactHookProfile>,
4929 ) -> fallow_output::ComplexityViolation {
4930 fallow_output::ComplexityViolation {
4931 path: PathBuf::from("src/Dashboard.tsx"),
4932 name: "Dashboard".to_string(),
4933 line: 1,
4934 col: 0,
4935 cyclomatic: 40,
4936 cognitive: 30,
4937 line_count: 90,
4938 param_count: 1,
4939 react_hook_count,
4940 react_jsx_max_depth,
4941 react_prop_count,
4942 react_hook_profile: profile,
4943 exceeded: fallow_output::ExceededThreshold::Both,
4944 severity: fallow_output::FindingSeverity::High,
4945 crap: None,
4946 coverage_pct: None,
4947 coverage_tier: None,
4948 coverage_source: None,
4949 inherited_from: None,
4950 component_rollup: None,
4951 contributions: Vec::new(),
4952 effective_thresholds: None,
4953 threshold_source: None,
4954 }
4955 }
4956
4957 #[test]
4958 fn react_context_renders_hook_breakdown_and_max_effect_deps() {
4959 let finding = react_finding(
4960 9,
4961 14,
4962 7,
4963 Some(fallow_output::ReactHookProfile {
4964 state: 3,
4965 effect: 4,
4966 memo: 2,
4967 callback: 0,
4968 custom: 0,
4969 max_effect_dep_arity: Some(5),
4970 }),
4971 );
4972 let line = render_react_context(&finding).expect("react context line");
4973 let plain_line = strip_ansi(&line);
4974 assert!(
4975 plain_line
4976 .contains("react: 14 props, 9 hooks (3 state, 4 effect, 2 memo), max effect deps 5, JSX depth 7"),
4977 "breakdown line: {plain_line}"
4978 );
4979 }
4980
4981 #[test]
4982 fn react_context_without_profile_keeps_bare_hook_count() {
4983 let finding = react_finding(5, 0, 0, None);
4984 let line = render_react_context(&finding).expect("react context line");
4985 let plain_line = strip_ansi(&line);
4986 assert!(plain_line.contains("react: 5 hooks"), "{plain_line}");
4987 assert!(
4988 !plain_line.contains('('),
4989 "no breakdown parenthetical without a profile: {plain_line}"
4990 );
4991 assert!(
4992 !plain_line.contains("max effect deps"),
4993 "no effect-deps segment without a profile: {plain_line}"
4994 );
4995 }
4996
4997 #[test]
4998 fn react_context_omits_max_effect_deps_when_arity_absent() {
4999 let finding = react_finding(
5000 2,
5001 0,
5002 0,
5003 Some(fallow_output::ReactHookProfile {
5004 state: 1,
5005 effect: 1,
5006 memo: 0,
5007 callback: 0,
5008 custom: 0,
5009 max_effect_dep_arity: None,
5010 }),
5011 );
5012 let line = render_react_context(&finding).expect("react context line");
5013 let plain_line = strip_ansi(&line);
5014 assert!(
5015 plain_line.contains("2 hooks (1 state, 1 effect)"),
5016 "{plain_line}"
5017 );
5018 assert!(
5019 !plain_line.contains("max effect deps"),
5020 "absent arity must omit the effect-deps segment: {plain_line}"
5021 );
5022 }
5023
5024 #[test]
5025 fn react_context_none_for_non_react_finding() {
5026 let finding = react_finding(0, 0, 0, None);
5027 assert!(render_react_context(&finding).is_none());
5028 }
5029}