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