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