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