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