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