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