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 change 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 contributions: Vec::new(),
2045 }
2046 .into(),
2047 ],
2048 summary: crate::health_types::HealthSummary {
2049 files_analyzed: 10,
2050 functions_analyzed: 50,
2051 functions_above_threshold: 1,
2052 ..Default::default()
2053 },
2054 ..Default::default()
2055 };
2056 let lines = build_health_human_lines(&report, &root);
2057 let text = plain(&lines);
2058 assert!(text.contains("High complexity functions (1)"));
2059 assert!(text.contains("src/parser.ts"));
2060 assert!(text.contains(":42"));
2061 assert!(text.contains("parseExpression"));
2062 assert!(text.contains("25 cyclomatic"));
2063 assert!(text.contains("30 cognitive"));
2064 assert!(text.contains("80 lines"));
2065 }
2066
2067 #[test]
2068 fn health_shown_vs_total_when_truncated() {
2069 let root = PathBuf::from("/project");
2070 let report = crate::health_types::HealthReport {
2071 findings: vec![
2072 crate::health_types::ComplexityViolation {
2073 path: root.join("src/a.ts"),
2074 name: "fn1".to_string(),
2075 line: 1,
2076 col: 0,
2077 cyclomatic: 25,
2078 cognitive: 20,
2079 line_count: 50,
2080 param_count: 0,
2081 exceeded: crate::health_types::ExceededThreshold::Both,
2082 severity: crate::health_types::FindingSeverity::High,
2083 crap: None,
2084 coverage_pct: None,
2085 coverage_tier: None,
2086 coverage_source: None,
2087 inherited_from: None,
2088 component_rollup: None,
2089 contributions: Vec::new(),
2090 }
2091 .into(),
2092 ],
2093 summary: crate::health_types::HealthSummary {
2094 files_analyzed: 100,
2095 functions_analyzed: 500,
2096 functions_above_threshold: 10,
2097 ..Default::default()
2098 },
2099 ..Default::default()
2100 };
2101 let lines = build_health_human_lines(&report, &root);
2102 let text = plain(&lines);
2103 assert!(text.contains("1 shown, 10 total"));
2104 }
2105
2106 #[test]
2107 fn health_findings_explain_estimated_crap_scores() {
2108 let root = PathBuf::from("/project");
2109 let report = crate::health_types::HealthReport {
2110 findings: vec![
2111 crate::health_types::ComplexityViolation {
2112 path: root.join("src/risky.ts"),
2113 name: "risky".to_string(),
2114 line: 7,
2115 col: 0,
2116 cyclomatic: 25,
2117 cognitive: 20,
2118 line_count: 80,
2119 param_count: 0,
2120 exceeded: crate::health_types::ExceededThreshold::Crap,
2121 severity: crate::health_types::FindingSeverity::High,
2122 crap: Some(650.0),
2123 coverage_pct: None,
2124 coverage_tier: Some(crate::health_types::CoverageTier::None),
2125 coverage_source: Some(crate::health_types::CoverageSource::Estimated),
2126 inherited_from: None,
2127 component_rollup: None,
2128 contributions: Vec::new(),
2129 }
2130 .into(),
2131 ],
2132 summary: crate::health_types::HealthSummary {
2133 files_analyzed: 1,
2134 functions_analyzed: 1,
2135 functions_above_threshold: 1,
2136 coverage_model: Some(crate::health_types::CoverageModel::StaticEstimated),
2137 coverage_source_consistency: None,
2138 ..Default::default()
2139 },
2140 ..Default::default()
2141 };
2142 let text = plain(&build_health_human_lines(&report, &root));
2143 assert!(text.contains("CRAP scores are estimated from export references"));
2144 assert!(text.contains("fallow health --coverage <coverage-final.json>"));
2145 }
2146
2147 #[test]
2148 fn health_findings_explain_mixed_istanbul_crap_scores() {
2149 let root = PathBuf::from("/project");
2150 let report = crate::health_types::HealthReport {
2151 findings: vec![
2152 crate::health_types::ComplexityViolation {
2153 path: root.join("src/risky.ts"),
2154 name: "risky".to_string(),
2155 line: 7,
2156 col: 0,
2157 cyclomatic: 25,
2158 cognitive: 20,
2159 line_count: 80,
2160 param_count: 0,
2161 exceeded: crate::health_types::ExceededThreshold::Crap,
2162 severity: crate::health_types::FindingSeverity::High,
2163 crap: Some(45.0),
2164 coverage_pct: Some(40.0),
2165 coverage_tier: Some(crate::health_types::CoverageTier::Partial),
2166 coverage_source: Some(crate::health_types::CoverageSource::Istanbul),
2167 inherited_from: None,
2168 component_rollup: None,
2169 contributions: Vec::new(),
2170 }
2171 .into(),
2172 ],
2173 summary: crate::health_types::HealthSummary {
2174 files_analyzed: 1,
2175 functions_analyzed: 2,
2176 functions_above_threshold: 1,
2177 coverage_model: Some(crate::health_types::CoverageModel::Istanbul),
2178 coverage_source_consistency: None,
2179 istanbul_matched: Some(1),
2180 istanbul_total: Some(2),
2181 ..Default::default()
2182 },
2183 ..Default::default()
2184 };
2185 let text = plain(&build_health_human_lines(&report, &root));
2186 assert!(
2187 text.contains(
2188 "CRAP scores use Istanbul coverage where matched (1/2 functions); unmatched functions are estimated"
2189 ),
2190 "mixed Istanbul note missing from output: {text}"
2191 );
2192 }
2193
2194 #[test]
2195 fn health_findings_explain_istanbul_counts_without_summary_model() {
2196 let root = PathBuf::from("/project");
2197 let report = crate::health_types::HealthReport {
2198 findings: vec![
2199 crate::health_types::ComplexityViolation {
2200 path: root.join("src/risky.ts"),
2201 name: "risky".to_string(),
2202 line: 7,
2203 col: 0,
2204 cyclomatic: 25,
2205 cognitive: 20,
2206 line_count: 80,
2207 param_count: 0,
2208 exceeded: crate::health_types::ExceededThreshold::Crap,
2209 severity: crate::health_types::FindingSeverity::High,
2210 crap: Some(45.0),
2211 coverage_pct: None,
2212 coverage_tier: Some(crate::health_types::CoverageTier::None),
2213 coverage_source: Some(crate::health_types::CoverageSource::Estimated),
2214 inherited_from: None,
2215 component_rollup: None,
2216 contributions: Vec::new(),
2217 }
2218 .into(),
2219 ],
2220 summary: crate::health_types::HealthSummary {
2221 files_analyzed: 1,
2222 functions_analyzed: 2,
2223 functions_above_threshold: 1,
2224 coverage_model: None,
2225 coverage_source_consistency: None,
2226 istanbul_matched: Some(1),
2227 istanbul_total: Some(2),
2228 ..Default::default()
2229 },
2230 ..Default::default()
2231 };
2232 let text = plain(&build_health_human_lines(&report, &root));
2233 assert!(
2234 text.contains(
2235 "CRAP scores use Istanbul coverage where matched (1/2 functions); unmatched functions are estimated"
2236 ),
2237 "Istanbul counts should drive the note even when coverage_model is omitted: {text}"
2238 );
2239 }
2240
2241 #[test]
2242 fn health_findings_grouped_by_file() {
2243 let root = PathBuf::from("/project");
2244 let report = crate::health_types::HealthReport {
2245 findings: vec![
2246 crate::health_types::ComplexityViolation {
2247 path: root.join("src/parser.ts"),
2248 name: "fn1".to_string(),
2249 line: 10,
2250 col: 0,
2251 cyclomatic: 25,
2252 cognitive: 20,
2253 line_count: 40,
2254 param_count: 0,
2255 exceeded: crate::health_types::ExceededThreshold::Both,
2256 severity: crate::health_types::FindingSeverity::High,
2257 crap: None,
2258 coverage_pct: None,
2259 coverage_tier: None,
2260 coverage_source: None,
2261 inherited_from: None,
2262 component_rollup: None,
2263 contributions: Vec::new(),
2264 }
2265 .into(),
2266 crate::health_types::ComplexityViolation {
2267 path: root.join("src/parser.ts"),
2268 name: "fn2".to_string(),
2269 line: 60,
2270 col: 0,
2271 cyclomatic: 22,
2272 cognitive: 18,
2273 line_count: 30,
2274 param_count: 0,
2275 exceeded: crate::health_types::ExceededThreshold::Both,
2276 severity: crate::health_types::FindingSeverity::High,
2277 crap: None,
2278 coverage_pct: None,
2279 coverage_tier: None,
2280 coverage_source: None,
2281 inherited_from: None,
2282 component_rollup: None,
2283 contributions: Vec::new(),
2284 }
2285 .into(),
2286 ],
2287 summary: crate::health_types::HealthSummary {
2288 files_analyzed: 10,
2289 functions_analyzed: 50,
2290 functions_above_threshold: 2,
2291 ..Default::default()
2292 },
2293 ..Default::default()
2294 };
2295 let lines = build_health_human_lines(&report, &root);
2296 let text = plain(&lines);
2297 let count = text.matches("src/parser.ts").count();
2298 assert_eq!(count, 1, "File header should appear once for grouped items");
2299 }
2300
2301 fn empty_report() -> crate::health_types::HealthReport {
2302 crate::health_types::HealthReport {
2303 summary: crate::health_types::HealthSummary {
2304 files_analyzed: 10,
2305 functions_analyzed: 50,
2306 ..Default::default()
2307 },
2308 ..Default::default()
2309 }
2310 }
2311
2312 #[test]
2313 fn health_runtime_coverage_renders_section() {
2314 let root = PathBuf::from("/project");
2315 let mut report = empty_report();
2316 report.runtime_coverage = Some(crate::health_types::RuntimeCoverageReport {
2317 schema_version: crate::health_types::RuntimeCoverageSchemaVersion::V1,
2318 verdict: crate::health_types::RuntimeCoverageReportVerdict::ColdCodeDetected,
2319 signals: Vec::new(),
2320 summary: crate::health_types::RuntimeCoverageSummary {
2321 data_source: crate::health_types::RuntimeCoverageDataSource::Local,
2322 last_received_at: None,
2323 functions_tracked: 4,
2324 functions_hit: 2,
2325 functions_unhit: 1,
2326 functions_untracked: 1,
2327 coverage_percent: 50.0,
2328 trace_count: 2_847_291,
2329 period_days: 30,
2330 deployments_seen: 14,
2331 capture_quality: None,
2332 },
2333 findings: vec![crate::health_types::RuntimeCoverageFinding {
2334 id: "fallow:prod:deadbeef".to_owned(),
2335 stable_id: None,
2336 path: root.join("src/cold.ts"),
2337 function: "coldPath".to_owned(),
2338 line: 14,
2339 verdict: crate::health_types::RuntimeCoverageVerdict::ReviewRequired,
2340 invocations: Some(0),
2341 confidence: crate::health_types::RuntimeCoverageConfidence::Medium,
2342 evidence: crate::health_types::RuntimeCoverageEvidence {
2343 static_status: "used".to_owned(),
2344 test_coverage: "not_covered".to_owned(),
2345 v8_tracking: "tracked".to_owned(),
2346 untracked_reason: None,
2347 observation_days: 30,
2348 deployments_observed: 14,
2349 },
2350 actions: vec![],
2351 source_hash: None,
2352 }],
2353 hot_paths: vec![crate::health_types::RuntimeCoverageHotPath {
2354 id: "fallow:hot:cafebabe".to_owned(),
2355 stable_id: None,
2356 path: root.join("src/hot.ts"),
2357 function: "hotPath".to_owned(),
2358 line: 3,
2359 end_line: 9,
2360 invocations: 250,
2361 percentile: 99,
2362 actions: vec![],
2363 }],
2364 blast_radius: vec![],
2365 importance: vec![],
2366 watermark: Some(crate::health_types::RuntimeCoverageWatermark::LicenseExpiredGrace),
2367 warnings: vec![],
2368 });
2369
2370 let text = plain(&build_health_human_lines(&report, &root));
2371 assert!(text.contains("Runtime coverage: cold code detected"));
2372 assert!(text.contains("src/cold.ts:14 coldPath [0 invocations, review required]"));
2373 assert!(text.contains("license expired grace active"));
2374 assert!(text.contains("hot paths:"));
2375 assert!(text.contains("src/hot.ts:3 hotPath (250 invocations, p99)"));
2376 assert!(!text.contains("short capture:"));
2377 assert!(!text.contains("start a trial"));
2378 }
2379
2380 #[test]
2381 fn health_coverage_intelligence_renders_findings_and_ambiguity_summary() {
2382 use crate::health_types::{
2383 CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2384 CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2385 CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2386 CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2387 CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2388 };
2389
2390 let root = PathBuf::from("/project");
2391 let mut report = empty_report();
2392 report.coverage_intelligence = Some(CoverageIntelligenceReport {
2393 schema_version: CoverageIntelligenceSchemaVersion::V1,
2394 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2395 summary: CoverageIntelligenceSummary {
2396 findings: 1,
2397 high_confidence_deletes: 1,
2398 ..Default::default()
2399 },
2400 findings: vec![CoverageIntelligenceFinding {
2401 id: "fallow:coverage-intel:abc123".to_owned(),
2402 path: root.join("src/dead.ts"),
2403 identity: Some("deadPath".to_owned()),
2404 line: 9,
2405 verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2406 signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2407 recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2408 confidence: CoverageIntelligenceConfidence::High,
2409 related_ids: vec![],
2410 evidence: CoverageIntelligenceEvidence {
2411 match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2412 ..Default::default()
2413 },
2414 actions: vec![CoverageIntelligenceAction {
2415 kind: "delete-after-confirming-owner".to_owned(),
2416 description: "Confirm ownership before deleting".to_owned(),
2417 auto_fixable: false,
2418 }],
2419 }],
2420 });
2421
2422 let text = plain(&build_health_human_lines(&report, &root));
2423 assert!(text.contains("Coverage intelligence"));
2424 assert!(text.contains("src/dead.ts:9 deadPath high-confidence-delete"));
2425 assert!(text.contains("Confirm ownership before deleting"));
2426
2427 report.coverage_intelligence = Some(CoverageIntelligenceReport {
2428 schema_version: CoverageIntelligenceSchemaVersion::V1,
2429 verdict: CoverageIntelligenceVerdict::Clean,
2430 summary: CoverageIntelligenceSummary {
2431 skipped_ambiguous_matches: 2,
2432 ..Default::default()
2433 },
2434 findings: vec![],
2435 });
2436 let text = plain(&build_health_human_lines(&report, &root));
2437 assert!(text.contains("skipped 2 ambiguous evidence matches"));
2438 }
2439
2440 fn runtime_coverage_report_with_quality(
2441 quality: Option<crate::health_types::RuntimeCoverageCaptureQuality>,
2442 ) -> crate::health_types::RuntimeCoverageReport {
2443 crate::health_types::RuntimeCoverageReport {
2444 schema_version: crate::health_types::RuntimeCoverageSchemaVersion::V1,
2445 verdict: crate::health_types::RuntimeCoverageReportVerdict::Clean,
2446 signals: Vec::new(),
2447 summary: crate::health_types::RuntimeCoverageSummary {
2448 data_source: crate::health_types::RuntimeCoverageDataSource::Local,
2449 last_received_at: None,
2450 functions_tracked: 10,
2451 functions_hit: 7,
2452 functions_unhit: 0,
2453 functions_untracked: 3,
2454 coverage_percent: 70.0,
2455 trace_count: 1_000,
2456 period_days: 1,
2457 deployments_seen: 1,
2458 capture_quality: quality,
2459 },
2460 findings: vec![],
2461 hot_paths: vec![],
2462 blast_radius: vec![],
2463 importance: vec![],
2464 watermark: None,
2465 warnings: vec![],
2466 }
2467 }
2468
2469 #[test]
2470 fn health_runtime_coverage_short_capture_shows_warning_and_prompt() {
2471 let root = PathBuf::from("/project");
2472 let mut report = empty_report();
2473 report.runtime_coverage = Some(runtime_coverage_report_with_quality(Some(
2474 crate::health_types::RuntimeCoverageCaptureQuality {
2475 window_seconds: 720, instances_observed: 1,
2477 lazy_parse_warning: true,
2478 untracked_ratio_percent: 42.5,
2479 },
2480 )));
2481 let text = plain(&build_health_human_lines(&report, &root));
2482 assert!(
2483 text.contains(
2484 "note: short capture (12 min from 1 instance); 42.5% of functions untracked, lazy-parsed scripts may not appear."
2485 ),
2486 "warning banner missing or malformed in:\n{text}"
2487 );
2488 assert!(
2489 text.contains("extend the capture or switch to continuous monitoring"),
2490 "warning follow-up line missing in:\n{text}"
2491 );
2492 assert!(
2493 text.contains("captured 12 min from 1 instance."),
2494 "upgrade prompt header missing in:\n{text}"
2495 );
2496 assert!(
2497 text.contains("continuous monitoring over 30 days evaluates more paths"),
2498 "upgrade prompt body missing in:\n{text}"
2499 );
2500 assert!(
2501 text.contains("fallow license activate --trial --email you@company.com"),
2502 "trial CTA command missing in:\n{text}"
2503 );
2504 }
2505
2506 #[test]
2507 fn health_runtime_coverage_long_capture_shows_neither_warning_nor_prompt() {
2508 let root = PathBuf::from("/project");
2509 let mut report = empty_report();
2510 report.runtime_coverage = Some(runtime_coverage_report_with_quality(Some(
2511 crate::health_types::RuntimeCoverageCaptureQuality {
2512 window_seconds: 7 * 24 * 3600, instances_observed: 4,
2514 lazy_parse_warning: false,
2515 untracked_ratio_percent: 3.1,
2516 },
2517 )));
2518 let text = plain(&build_health_human_lines(&report, &root));
2519 assert!(
2520 !text.contains("short capture"),
2521 "long capture should not emit short-capture warning:\n{text}"
2522 );
2523 assert!(
2524 !text.contains("start a trial"),
2525 "long capture should not emit trial CTA:\n{text}"
2526 );
2527 }
2528
2529 #[test]
2530 fn format_window_labels() {
2531 assert_eq!(super::format_window(30), "30 s");
2532 assert_eq!(super::format_window(60), "1 min");
2533 assert_eq!(super::format_window(720), "12 min");
2534 assert_eq!(super::format_window(3600 * 3), "3 h");
2535 assert_eq!(super::format_window(3600 * 24 * 3), "3 d");
2536 }
2537
2538 #[test]
2539 fn health_coverage_gaps_render_section() {
2540 use crate::health_types::*;
2541
2542 let root = PathBuf::from("/project");
2543 let mut report = empty_report();
2544 report.coverage_gaps = Some(CoverageGaps {
2545 summary: CoverageGapSummary {
2546 runtime_files: 1,
2547 covered_files: 0,
2548 file_coverage_pct: 0.0,
2549 untested_files: 1,
2550 untested_exports: 1,
2551 },
2552 files: vec![UntestedFileFinding::with_actions(
2553 UntestedFile {
2554 path: root.join("src/app.ts"),
2555 value_export_count: 2,
2556 },
2557 &root,
2558 )],
2559 exports: vec![UntestedExportFinding::with_actions(
2560 UntestedExport {
2561 path: root.join("src/app.ts"),
2562 export_name: "loader".into(),
2563 line: 12,
2564 col: 4,
2565 },
2566 &root,
2567 )],
2568 });
2569
2570 let text = plain(&build_health_human_lines(&report, &root));
2571 assert!(
2572 text.contains("Coverage gaps (1 untested file, 1 untested export, 0.0% file coverage)")
2573 );
2574 assert!(text.contains("src/app.ts"));
2575 assert!(text.contains("loader"));
2576 }
2577
2578 #[test]
2579 fn fmt_trend_val_percentage() {
2580 assert_eq!(fmt_trend_val(15.5, "%"), "15.5%");
2581 assert_eq!(fmt_trend_val(0.0, "%"), "0.0%");
2582 }
2583
2584 #[test]
2585 fn fmt_trend_val_integer_when_round() {
2586 assert_eq!(fmt_trend_val(72.0, ""), "72");
2587 assert_eq!(fmt_trend_val(5.0, "pts"), "5");
2588 }
2589
2590 #[test]
2591 fn fmt_trend_val_decimal_when_fractional() {
2592 assert_eq!(fmt_trend_val(4.7, ""), "4.7");
2593 assert_eq!(fmt_trend_val(1.3, "pts"), "1.3");
2594 }
2595
2596 #[test]
2597 fn fmt_trend_delta_percentage() {
2598 assert_eq!(fmt_trend_delta(2.5, "%"), "+2.5%");
2599 assert_eq!(fmt_trend_delta(-1.3, "%"), "-1.3%");
2600 }
2601
2602 #[test]
2603 fn fmt_trend_delta_integer_when_round() {
2604 assert_eq!(fmt_trend_delta(5.0, ""), "+5");
2605 assert_eq!(fmt_trend_delta(-3.0, "pts"), "-3");
2606 }
2607
2608 #[test]
2609 fn fmt_trend_delta_decimal_when_fractional() {
2610 assert_eq!(fmt_trend_delta(4.9, ""), "+4.9");
2611 assert_eq!(fmt_trend_delta(-0.7, "pts"), "-0.7");
2612 }
2613
2614 #[test]
2615 fn health_score_grade_a_display() {
2616 let root = PathBuf::from("/project");
2617 let mut report = empty_report();
2618 report.health_score = Some(crate::health_types::HealthScore {
2619 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2620 score: 92.0,
2621 grade: "A",
2622 penalties: crate::health_types::HealthScorePenalties {
2623 dead_files: Some(3.0),
2624 dead_exports: Some(2.0),
2625 complexity: 1.5,
2626 p90_complexity: 1.5,
2627 maintainability: Some(0.0),
2628 hotspots: Some(0.0),
2629 unused_deps: Some(0.0),
2630 circular_deps: Some(0.0),
2631 unit_size: None,
2632 coupling: None,
2633 duplication: None,
2634 },
2635 });
2636 let lines = build_health_human_lines(&report, &root);
2637 let text = plain(&lines);
2638 assert!(text.contains("Health score:"));
2639 assert!(text.contains("92 A"));
2640 assert!(text.contains("dead files -3.0"));
2641 assert!(text.contains("dead exports -2.0"));
2642 assert!(text.contains("complexity -1.5"));
2643 assert!(text.contains("p90 -1.5"));
2644 }
2645
2646 #[test]
2647 fn health_score_grade_b_display() {
2648 let root = PathBuf::from("/project");
2649 let mut report = empty_report();
2650 report.health_score = Some(crate::health_types::HealthScore {
2651 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2652 score: 76.0,
2653 grade: "B",
2654 penalties: crate::health_types::HealthScorePenalties {
2655 dead_files: Some(5.0),
2656 dead_exports: Some(6.0),
2657 complexity: 3.0,
2658 p90_complexity: 2.0,
2659 maintainability: Some(4.0),
2660 hotspots: Some(2.0),
2661 unused_deps: Some(1.0),
2662 circular_deps: Some(1.0),
2663 unit_size: None,
2664 coupling: None,
2665 duplication: None,
2666 },
2667 });
2668 let lines = build_health_human_lines(&report, &root);
2669 let text = plain(&lines);
2670 assert!(text.contains("76 B"));
2671 assert!(text.contains("dead exports -6.0"));
2672 assert!(text.contains("maintainability -4.0"));
2673 assert!(text.contains("hotspots -2.0"));
2674 assert!(text.contains("unused deps -1.0"));
2675 assert!(text.contains("circular deps -1.0"));
2676 }
2677
2678 #[test]
2679 fn health_score_grade_c_display() {
2680 let root = PathBuf::from("/project");
2681 let mut report = empty_report();
2682 report.health_score = Some(crate::health_types::HealthScore {
2683 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2684 score: 60.0,
2685 grade: "C",
2686 penalties: crate::health_types::HealthScorePenalties {
2687 dead_files: Some(10.0),
2688 dead_exports: Some(10.0),
2689 complexity: 10.0,
2690 p90_complexity: 5.0,
2691 maintainability: Some(5.0),
2692 hotspots: None,
2693 unused_deps: None,
2694 circular_deps: None,
2695 unit_size: None,
2696 coupling: None,
2697 duplication: None,
2698 },
2699 });
2700 let lines = build_health_human_lines(&report, &root);
2701 let text = plain(&lines);
2702 assert!(text.contains("60 C"));
2703 }
2704
2705 #[test]
2706 fn health_score_grade_f_display() {
2707 let root = PathBuf::from("/project");
2708 let mut report = empty_report();
2709 report.health_score = Some(crate::health_types::HealthScore {
2710 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2711 score: 30.0,
2712 grade: "F",
2713 penalties: crate::health_types::HealthScorePenalties {
2714 dead_files: Some(15.0),
2715 dead_exports: Some(15.0),
2716 complexity: 20.0,
2717 p90_complexity: 10.0,
2718 maintainability: Some(10.0),
2719 hotspots: None,
2720 unused_deps: None,
2721 circular_deps: None,
2722 unit_size: None,
2723 coupling: None,
2724 duplication: None,
2725 },
2726 });
2727 let lines = build_health_human_lines(&report, &root);
2728 let text = plain(&lines);
2729 assert!(text.contains("30 F"));
2730 }
2731
2732 #[test]
2733 fn health_score_na_components_shown() {
2734 let root = PathBuf::from("/project");
2735 let mut report = empty_report();
2736 report.health_score = Some(crate::health_types::HealthScore {
2737 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2738 score: 90.0,
2739 grade: "A",
2740 penalties: crate::health_types::HealthScorePenalties {
2741 dead_files: None,
2742 dead_exports: None,
2743 complexity: 0.0,
2744 p90_complexity: 0.0,
2745 maintainability: None,
2746 hotspots: None,
2747 unused_deps: None,
2748 circular_deps: None,
2749 unit_size: None,
2750 coupling: None,
2751 duplication: None,
2752 },
2753 });
2754 let lines = build_health_human_lines(&report, &root);
2755 let text = plain(&lines);
2756 assert!(text.contains("N/A: dead code, maintainability, hotspots"));
2757 assert!(text.contains("enable the corresponding analysis flags"));
2758 }
2759
2760 #[test]
2761 fn health_score_no_na_when_all_present() {
2762 let root = PathBuf::from("/project");
2763 let mut report = empty_report();
2764 report.health_score = Some(crate::health_types::HealthScore {
2765 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2766 score: 85.0,
2767 grade: "A",
2768 penalties: crate::health_types::HealthScorePenalties {
2769 dead_files: Some(0.0),
2770 dead_exports: Some(0.0),
2771 complexity: 0.0,
2772 p90_complexity: 0.0,
2773 maintainability: Some(0.0),
2774 hotspots: Some(0.0),
2775 unused_deps: Some(0.0),
2776 circular_deps: Some(0.0),
2777 unit_size: None,
2778 coupling: None,
2779 duplication: None,
2780 },
2781 });
2782 let lines = build_health_human_lines(&report, &root);
2783 let text = plain(&lines);
2784 assert!(!text.contains("N/A:"));
2785 }
2786
2787 #[test]
2788 fn health_score_zero_penalties_suppressed() {
2789 let root = PathBuf::from("/project");
2790 let mut report = empty_report();
2791 report.health_score = Some(crate::health_types::HealthScore {
2792 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2793 score: 100.0,
2794 grade: "A",
2795 penalties: crate::health_types::HealthScorePenalties {
2796 dead_files: Some(0.0),
2797 dead_exports: Some(0.0),
2798 complexity: 0.0,
2799 p90_complexity: 0.0,
2800 maintainability: Some(0.0),
2801 hotspots: Some(0.0),
2802 unused_deps: Some(0.0),
2803 circular_deps: Some(0.0),
2804 unit_size: None,
2805 coupling: None,
2806 duplication: None,
2807 },
2808 });
2809 let lines = build_health_human_lines(&report, &root);
2810 let text = plain(&lines);
2811 assert!(!text.contains("dead files"));
2812 assert!(!text.contains("complexity -"));
2813 }
2814
2815 #[test]
2816 fn health_trend_improving_display() {
2817 let root = PathBuf::from("/project");
2818 let mut report = empty_report();
2819 report.health_trend = Some(crate::health_types::HealthTrend {
2820 compared_to: crate::health_types::TrendPoint {
2821 timestamp: "2026-03-25T14:30:00Z".into(),
2822 git_sha: Some("abc1234".into()),
2823 score: Some(72.0),
2824 grade: Some("B".into()),
2825 coverage_model: None,
2826 snapshot_schema_version: None,
2827 },
2828 metrics: vec![
2829 crate::health_types::TrendMetric {
2830 name: "score",
2831 label: "Health Score",
2832 previous: 72.0,
2833 current: 85.0,
2834 delta: 13.0,
2835 direction: crate::health_types::TrendDirection::Improving,
2836 unit: "",
2837 previous_count: None,
2838 current_count: None,
2839 },
2840 crate::health_types::TrendMetric {
2841 name: "dead_file_pct",
2842 label: "Dead Files",
2843 previous: 10.0,
2844 current: 5.0,
2845 delta: -5.0,
2846 direction: crate::health_types::TrendDirection::Improving,
2847 unit: "%",
2848 previous_count: None,
2849 current_count: None,
2850 },
2851 ],
2852 snapshots_loaded: 2,
2853 overall_direction: crate::health_types::TrendDirection::Improving,
2854 });
2855 let lines = build_health_human_lines(&report, &root);
2856 let text = plain(&lines);
2857 assert!(text.contains("Trend:"));
2858 assert!(text.contains("improving"));
2859 assert!(text.contains("vs 2026-03-25"));
2860 assert!(text.contains("abc1234"));
2861 assert!(text.contains("Health Score"));
2862 assert!(text.contains("+13"));
2863 assert!(text.contains("Dead Files"));
2864 assert!(text.contains("-5.0%"));
2865 }
2866
2867 #[test]
2868 fn health_trend_declining_display() {
2869 let root = PathBuf::from("/project");
2870 let mut report = empty_report();
2871 report.health_trend = Some(crate::health_types::HealthTrend {
2872 compared_to: crate::health_types::TrendPoint {
2873 timestamp: "2026-03-20T10:00:00Z".into(),
2874 git_sha: None,
2875 score: None,
2876 grade: None,
2877 coverage_model: None,
2878 snapshot_schema_version: None,
2879 },
2880 metrics: vec![crate::health_types::TrendMetric {
2881 name: "unused_deps",
2882 label: "Unused Deps",
2883 previous: 5.0,
2884 current: 10.0,
2885 delta: 5.0,
2886 direction: crate::health_types::TrendDirection::Declining,
2887 unit: "",
2888 previous_count: None,
2889 current_count: None,
2890 }],
2891 snapshots_loaded: 1,
2892 overall_direction: crate::health_types::TrendDirection::Declining,
2893 });
2894 let lines = build_health_human_lines(&report, &root);
2895 let text = plain(&lines);
2896 assert!(text.contains("declining"));
2897 assert!(text.contains("Unused Deps"));
2898 }
2899
2900 #[test]
2901 fn health_trend_all_stable_collapsed() {
2902 let root = PathBuf::from("/project");
2903 let mut report = empty_report();
2904 report.health_trend = Some(crate::health_types::HealthTrend {
2905 compared_to: crate::health_types::TrendPoint {
2906 timestamp: "2026-03-25T14:30:00Z".into(),
2907 git_sha: Some("def5678".into()),
2908 score: Some(80.0),
2909 grade: Some("B".into()),
2910 coverage_model: None,
2911 snapshot_schema_version: None,
2912 },
2913 metrics: vec![
2914 crate::health_types::TrendMetric {
2915 name: "score",
2916 label: "Health Score",
2917 previous: 80.0,
2918 current: 80.0,
2919 delta: 0.0,
2920 direction: crate::health_types::TrendDirection::Stable,
2921 unit: "",
2922 previous_count: None,
2923 current_count: None,
2924 },
2925 crate::health_types::TrendMetric {
2926 name: "avg_cyclomatic",
2927 label: "Avg Cyclomatic",
2928 previous: 2.0,
2929 current: 2.0,
2930 delta: 0.0,
2931 direction: crate::health_types::TrendDirection::Stable,
2932 unit: "",
2933 previous_count: None,
2934 current_count: None,
2935 },
2936 ],
2937 snapshots_loaded: 3,
2938 overall_direction: crate::health_types::TrendDirection::Stable,
2939 });
2940 let lines = build_health_human_lines(&report, &root);
2941 let text = plain(&lines);
2942 assert!(text.contains("stable"));
2943 assert!(text.contains("All 2 metrics unchanged"));
2944 assert!(!text.contains("Health Score"));
2945 }
2946
2947 #[test]
2948 fn health_trend_without_sha() {
2949 let root = PathBuf::from("/project");
2950 let mut report = empty_report();
2951 report.health_trend = Some(crate::health_types::HealthTrend {
2952 compared_to: crate::health_types::TrendPoint {
2953 timestamp: "2026-03-20T10:00:00Z".into(),
2954 git_sha: None,
2955 score: None,
2956 grade: None,
2957 coverage_model: None,
2958 snapshot_schema_version: None,
2959 },
2960 metrics: vec![crate::health_types::TrendMetric {
2961 name: "score",
2962 label: "Health Score",
2963 previous: 80.0,
2964 current: 82.0,
2965 delta: 2.0,
2966 direction: crate::health_types::TrendDirection::Improving,
2967 unit: "",
2968 previous_count: None,
2969 current_count: None,
2970 }],
2971 snapshots_loaded: 1,
2972 overall_direction: crate::health_types::TrendDirection::Improving,
2973 });
2974 let lines = build_health_human_lines(&report, &root);
2975 let text = plain(&lines);
2976 assert!(text.contains("vs 2026-03-20"));
2977 assert!(!text.contains("\u{00b7}"));
2978 }
2979
2980 #[test]
2981 fn vital_signs_shown_without_trend() {
2982 let root = PathBuf::from("/project");
2983 let mut report = empty_report();
2984 report.vital_signs = Some(crate::health_types::VitalSigns {
2985 dead_file_pct: Some(3.2),
2986 dead_export_pct: Some(8.1),
2987 avg_cyclomatic: 4.7,
2988 p90_cyclomatic: 12,
2989 duplication_pct: None,
2990 hotspot_count: Some(2),
2991 maintainability_avg: Some(72.4),
2992 unused_dep_count: Some(3),
2993 circular_dep_count: Some(1),
2994 counts: None,
2995 unit_size_profile: None,
2996 unit_interfacing_profile: None,
2997 p95_fan_in: None,
2998 coupling_high_pct: None,
2999 total_loc: 42_381,
3000 ..Default::default()
3001 });
3002 report.hotspot_summary = Some(crate::health_types::HotspotSummary {
3003 since: "6 months".to_string(),
3004 min_commits: 3,
3005 files_analyzed: 50,
3006 files_excluded: 20,
3007 shallow_clone: false,
3008 });
3009 let lines = build_health_human_lines(&report, &root);
3010 let text = plain(&lines);
3011 assert!(text.contains("42,381 LOC"));
3012 assert!(text.contains("dead files 3.2%"));
3013 assert!(text.contains("dead exports 8.1%"));
3014 assert!(text.contains("avg cyclomatic 4.7"));
3015 assert!(text.contains("p90 cyclomatic 12"));
3016 assert!(text.contains("maintainability 72.4"));
3017 assert!(text.contains("2 churn hotspots (since 6 months)"));
3018 assert!(text.contains("3 unused deps"));
3019 assert!(text.contains("1 circular dep"));
3020 }
3021
3022 #[test]
3023 fn vital_signs_zero_hotspots_still_show_window() {
3024 let root = PathBuf::from("/project");
3025 let mut report = empty_report();
3026 report.vital_signs = Some(crate::health_types::VitalSigns {
3027 avg_cyclomatic: 2.0,
3028 p90_cyclomatic: 5,
3029 hotspot_count: Some(0),
3030 total_loc: 1_000,
3031 ..Default::default()
3032 });
3033 report.hotspot_summary = Some(crate::health_types::HotspotSummary {
3034 since: "90 days".to_string(),
3035 min_commits: 3,
3036 files_analyzed: 10,
3037 files_excluded: 0,
3038 shallow_clone: false,
3039 });
3040 let lines = build_health_human_lines(&report, &root);
3041 let text = plain(&lines);
3042 assert!(text.contains("0 churn hotspots (since 90 days)"));
3043 assert!(!text.contains("Hotspots ("));
3044 }
3045
3046 #[test]
3047 fn vital_signs_hotspot_count_without_summary_omits_window() {
3048 let root = PathBuf::from("/project");
3049 let mut report = empty_report();
3050 report.vital_signs = Some(crate::health_types::VitalSigns {
3051 avg_cyclomatic: 2.0,
3052 p90_cyclomatic: 5,
3053 hotspot_count: Some(1),
3054 total_loc: 1_000,
3055 ..Default::default()
3056 });
3057 report.hotspot_summary = None;
3058 let lines = build_health_human_lines(&report, &root);
3059 let text = plain(&lines);
3060 assert!(text.contains("1 churn hotspot"));
3061 assert!(!text.contains("(since"));
3062 }
3063
3064 #[test]
3065 fn vital_signs_suppressed_when_trend_active() {
3066 let root = PathBuf::from("/project");
3067 let mut report = empty_report();
3068 report.vital_signs = Some(crate::health_types::VitalSigns {
3069 dead_file_pct: Some(3.2),
3070 dead_export_pct: Some(8.1),
3071 avg_cyclomatic: 4.7,
3072 p90_cyclomatic: 12,
3073 duplication_pct: None,
3074 hotspot_count: Some(2),
3075 maintainability_avg: Some(72.4),
3076 unused_dep_count: None,
3077 circular_dep_count: None,
3078 counts: None,
3079 unit_size_profile: None,
3080 unit_interfacing_profile: None,
3081 p95_fan_in: None,
3082 coupling_high_pct: None,
3083 total_loc: 0,
3084 ..Default::default()
3085 });
3086 report.health_trend = Some(crate::health_types::HealthTrend {
3087 compared_to: crate::health_types::TrendPoint {
3088 timestamp: "2026-03-25T14:30:00Z".into(),
3089 git_sha: None,
3090 score: None,
3091 grade: None,
3092 coverage_model: None,
3093 snapshot_schema_version: None,
3094 },
3095 metrics: vec![],
3096 snapshots_loaded: 1,
3097 overall_direction: crate::health_types::TrendDirection::Stable,
3098 });
3099 let lines = build_health_human_lines(&report, &root);
3100 let text = plain(&lines);
3101 assert!(!text.contains("dead files"));
3102 assert!(!text.contains("avg cyclomatic"));
3103 }
3104
3105 #[test]
3106 fn vital_signs_optional_fields_omitted_when_none() {
3107 let root = PathBuf::from("/project");
3108 let mut report = empty_report();
3109 report.vital_signs = Some(crate::health_types::VitalSigns {
3110 dead_file_pct: None,
3111 dead_export_pct: None,
3112 avg_cyclomatic: 2.0,
3113 p90_cyclomatic: 5,
3114 duplication_pct: None,
3115 hotspot_count: None,
3116 maintainability_avg: None,
3117 unused_dep_count: None,
3118 circular_dep_count: None,
3119 counts: None,
3120 unit_size_profile: None,
3121 unit_interfacing_profile: None,
3122 p95_fan_in: None,
3123 coupling_high_pct: None,
3124 total_loc: 0,
3125 ..Default::default()
3126 });
3127 let lines = build_health_human_lines(&report, &root);
3128 let text = plain(&lines);
3129 assert!(!text.contains("dead files"));
3130 assert!(!text.contains("dead exports"));
3131 assert!(!text.contains("maintainability "));
3132 assert!(!text.contains("hotspot"));
3133 assert!(text.contains("avg cyclomatic 2.0"));
3134 assert!(text.contains("p90 cyclomatic 5"));
3135 }
3136
3137 #[test]
3138 fn vital_signs_zero_counts_suppressed() {
3139 let root = PathBuf::from("/project");
3140 let mut report = empty_report();
3141 report.vital_signs = Some(crate::health_types::VitalSigns {
3142 dead_file_pct: None,
3143 dead_export_pct: None,
3144 avg_cyclomatic: 1.0,
3145 p90_cyclomatic: 2,
3146 duplication_pct: None,
3147 hotspot_count: None,
3148 maintainability_avg: None,
3149 unused_dep_count: Some(0),
3150 circular_dep_count: Some(0),
3151 counts: None,
3152 unit_size_profile: None,
3153 unit_interfacing_profile: None,
3154 p95_fan_in: None,
3155 coupling_high_pct: None,
3156 total_loc: 0,
3157 ..Default::default()
3158 });
3159 let lines = build_health_human_lines(&report, &root);
3160 let text = plain(&lines);
3161 assert!(!text.contains("unused dep"));
3162 assert!(!text.contains("circular dep"));
3163 }
3164
3165 #[test]
3166 fn vital_signs_plural_vs_singular() {
3167 let root = PathBuf::from("/project");
3168 let mut report = empty_report();
3169 report.vital_signs = Some(crate::health_types::VitalSigns {
3170 dead_file_pct: None,
3171 dead_export_pct: None,
3172 avg_cyclomatic: 1.0,
3173 p90_cyclomatic: 2,
3174 duplication_pct: None,
3175 hotspot_count: Some(1),
3176 maintainability_avg: None,
3177 unused_dep_count: Some(1),
3178 circular_dep_count: Some(2),
3179 counts: None,
3180 unit_size_profile: None,
3181 unit_interfacing_profile: None,
3182 p95_fan_in: None,
3183 coupling_high_pct: None,
3184 total_loc: 0,
3185 ..Default::default()
3186 });
3187 let lines = build_health_human_lines(&report, &root);
3188 let text = plain(&lines);
3189 assert!(text.contains("1 churn hotspot"));
3190 assert!(!text.contains("1 churn hotspots"));
3191 assert!(text.contains("1 unused dep"));
3192 assert!(!text.contains("1 unused deps"));
3193 assert!(text.contains("2 circular deps"));
3194 }
3195
3196 #[test]
3197 fn file_scores_single_entry() {
3198 let root = PathBuf::from("/project");
3199 let mut report = empty_report();
3200 report.file_scores = vec![crate::health_types::FileHealthScore {
3201 path: root.join("src/utils.ts"),
3202 fan_in: 5,
3203 fan_out: 3,
3204 dead_code_ratio: 0.15,
3205 complexity_density: 0.42,
3206 maintainability_index: 85.3,
3207 total_cyclomatic: 12,
3208 total_cognitive: 8,
3209 function_count: 4,
3210 lines: 200,
3211 crap_max: 0.0,
3212 crap_above_threshold: 0,
3213 }];
3214 let lines = build_health_human_lines(&report, &root);
3215 let text = plain(&lines);
3216 assert!(text.contains("File health scores (1 files)"));
3217 assert!(text.contains("85.3"));
3218 assert!(text.contains("src/utils.ts"));
3219 assert!(text.contains("200 LOC"));
3220 assert!(text.contains("5 fan-in"));
3221 assert!(text.contains("3 fan-out"));
3222 assert!(text.contains("15% dead"));
3223 assert!(text.contains("0.42 density"));
3224 }
3225
3226 #[test]
3227 fn file_scores_concern_tag_marks_risk_vs_structure() {
3228 let root = PathBuf::from("/project");
3229 let mut report = empty_report();
3230 report.file_scores = vec![
3231 crate::health_types::FileHealthScore {
3232 path: root.join("src/risky.ts"),
3233 fan_in: 0,
3234 fan_out: 0,
3235 dead_code_ratio: 0.0,
3236 complexity_density: 0.2,
3237 maintainability_index: 85.0,
3238 total_cyclomatic: 10,
3239 total_cognitive: 8,
3240 function_count: 1,
3241 lines: 100,
3242 crap_max: 552.0,
3243 crap_above_threshold: 1,
3244 },
3245 crate::health_types::FileHealthScore {
3246 path: root.join("src/messy.ts"),
3247 fan_in: 0,
3248 fan_out: 0,
3249 dead_code_ratio: 0.0,
3250 complexity_density: 0.3,
3251 maintainability_index: 30.0,
3252 total_cyclomatic: 5,
3253 total_cognitive: 3,
3254 function_count: 1,
3255 lines: 100,
3256 crap_max: 2.0,
3257 crap_above_threshold: 0,
3258 },
3259 ];
3260 let text = plain(&build_health_human_lines(&report, &root));
3261 let risky_line = text
3262 .lines()
3263 .find(|l| l.contains("risky.ts"))
3264 .expect("risky path line");
3265 assert!(
3266 risky_line.trim_end().ends_with("risk"),
3267 "expected risk tag, got: {risky_line:?}"
3268 );
3269 let messy_line = text
3270 .lines()
3271 .find(|l| l.contains("messy.ts"))
3272 .expect("messy path line");
3273 assert!(
3274 messy_line.trim_end().ends_with("structure"),
3275 "expected structure tag, got: {messy_line:?}"
3276 );
3277 }
3278
3279 #[test]
3280 fn file_scores_mi_color_thresholds() {
3281 let root = PathBuf::from("/project");
3282 let mut report = empty_report();
3283 report.file_scores = vec![
3284 crate::health_types::FileHealthScore {
3285 path: root.join("src/good.ts"),
3286 fan_in: 1,
3287 fan_out: 1,
3288 dead_code_ratio: 0.0,
3289 complexity_density: 0.1,
3290 maintainability_index: 90.0, total_cyclomatic: 2,
3292 total_cognitive: 1,
3293 function_count: 1,
3294 lines: 50,
3295 crap_max: 0.0,
3296 crap_above_threshold: 0,
3297 },
3298 crate::health_types::FileHealthScore {
3299 path: root.join("src/okay.ts"),
3300 fan_in: 2,
3301 fan_out: 3,
3302 dead_code_ratio: 0.1,
3303 complexity_density: 0.3,
3304 maintainability_index: 65.0, total_cyclomatic: 8,
3306 total_cognitive: 5,
3307 function_count: 3,
3308 lines: 100,
3309 crap_max: 0.0,
3310 crap_above_threshold: 0,
3311 },
3312 crate::health_types::FileHealthScore {
3313 path: root.join("src/bad.ts"),
3314 fan_in: 8,
3315 fan_out: 12,
3316 dead_code_ratio: 0.5,
3317 complexity_density: 0.9,
3318 maintainability_index: 30.0, total_cyclomatic: 40,
3320 total_cognitive: 30,
3321 function_count: 10,
3322 lines: 500,
3323 crap_max: 0.0,
3324 crap_above_threshold: 0,
3325 },
3326 ];
3327 let lines = build_health_human_lines(&report, &root);
3328 let text = plain(&lines);
3329 assert!(text.contains("File health scores (3 files)"));
3330 assert!(text.contains("90.0"));
3331 assert!(text.contains("65.0"));
3332 assert!(text.contains("30.0"));
3333 }
3334
3335 #[test]
3336 fn file_scores_truncation_above_max_flat_items() {
3337 let root = PathBuf::from("/project");
3338 let mut report = empty_report();
3339 for i in 0..12 {
3340 report
3341 .file_scores
3342 .push(crate::health_types::FileHealthScore {
3343 path: root.join(format!("src/file{i}.ts")),
3344 fan_in: 1,
3345 fan_out: 1,
3346 dead_code_ratio: 0.0,
3347 complexity_density: 0.1,
3348 maintainability_index: 80.0,
3349 total_cyclomatic: 2,
3350 total_cognitive: 1,
3351 function_count: 1,
3352 lines: 50,
3353 crap_max: 0.0,
3354 crap_above_threshold: 0,
3355 });
3356 }
3357 let lines = build_health_human_lines(&report, &root);
3358 let text = plain(&lines);
3359 assert!(text.contains("File health scores (12 files)"));
3360 assert!(text.contains("... and 2 more files"));
3361 assert!(text.contains("file0.ts"));
3362 assert!(text.contains("file9.ts"));
3363 assert!(!text.contains("file10.ts"));
3364 assert!(!text.contains("file11.ts"));
3365 }
3366
3367 #[test]
3368 fn file_scores_docs_link() {
3369 let root = PathBuf::from("/project");
3370 let mut report = empty_report();
3371 report.file_scores = vec![crate::health_types::FileHealthScore {
3372 path: root.join("src/a.ts"),
3373 fan_in: 1,
3374 fan_out: 1,
3375 dead_code_ratio: 0.0,
3376 complexity_density: 0.1,
3377 maintainability_index: 80.0,
3378 total_cyclomatic: 2,
3379 total_cognitive: 1,
3380 function_count: 1,
3381 lines: 50,
3382 crap_max: 0.0,
3383 crap_above_threshold: 0,
3384 }];
3385 let lines = build_health_human_lines(&report, &root);
3386 let text = plain(&lines);
3387 assert!(text.contains("docs.fallow.tools/explanations/health#file-health-scores"));
3388 }
3389
3390 #[test]
3391 fn hotspots_accelerating_trend() {
3392 let root = PathBuf::from("/project");
3393 let mut report = empty_report();
3394 report.hotspots = vec![
3395 crate::health_types::HotspotEntry {
3396 path: root.join("src/core.ts"),
3397 score: 75.0,
3398 commits: 42,
3399 weighted_commits: 30.0,
3400 lines_added: 500,
3401 lines_deleted: 200,
3402 complexity_density: 0.85,
3403 fan_in: 10,
3404 trend: fallow_core::churn::ChurnTrend::Accelerating,
3405 ownership: None,
3406 is_test_path: false,
3407 }
3408 .into(),
3409 ];
3410 let lines = build_health_human_lines(&report, &root);
3411 let text = plain(&lines);
3412 assert!(text.contains("Hotspots (1 files)"));
3413 assert!(text.contains("75.0"));
3414 assert!(text.contains("src/core.ts"));
3415 assert!(text.contains("42 commits"));
3416 assert!(text.contains("700 churn"));
3417 assert!(text.contains("0.85 density"));
3418 assert!(text.contains("10 fan-in"));
3419 assert!(text.contains("accelerating"));
3420 }
3421
3422 #[test]
3423 fn hotspots_cooling_trend() {
3424 let root = PathBuf::from("/project");
3425 let mut report = empty_report();
3426 report.hotspots = vec![
3427 crate::health_types::HotspotEntry {
3428 path: root.join("src/old.ts"),
3429 score: 20.0,
3430 commits: 5,
3431 weighted_commits: 2.0,
3432 lines_added: 50,
3433 lines_deleted: 30,
3434 complexity_density: 0.3,
3435 fan_in: 2,
3436 trend: fallow_core::churn::ChurnTrend::Cooling,
3437 ownership: None,
3438 is_test_path: false,
3439 }
3440 .into(),
3441 ];
3442 let lines = build_health_human_lines(&report, &root);
3443 let text = plain(&lines);
3444 assert!(text.contains("20.0"));
3445 assert!(text.contains("cooling"));
3446 }
3447
3448 #[test]
3449 fn hotspots_stable_trend() {
3450 let root = PathBuf::from("/project");
3451 let mut report = empty_report();
3452 report.hotspots = vec![
3453 crate::health_types::HotspotEntry {
3454 path: root.join("src/mid.ts"),
3455 score: 45.0,
3456 commits: 15,
3457 weighted_commits: 10.0,
3458 lines_added: 200,
3459 lines_deleted: 100,
3460 complexity_density: 0.5,
3461 fan_in: 5,
3462 trend: fallow_core::churn::ChurnTrend::Stable,
3463 ownership: None,
3464 is_test_path: false,
3465 }
3466 .into(),
3467 ];
3468 let lines = build_health_human_lines(&report, &root);
3469 let text = plain(&lines);
3470 assert!(text.contains("45.0"));
3471 assert!(text.contains("stable"));
3472 }
3473
3474 #[test]
3475 fn hotspots_with_summary_and_since() {
3476 let root = PathBuf::from("/project");
3477 let mut report = empty_report();
3478 report.hotspots = vec![
3479 crate::health_types::HotspotEntry {
3480 path: root.join("src/a.ts"),
3481 score: 50.0,
3482 commits: 10,
3483 weighted_commits: 8.0,
3484 lines_added: 100,
3485 lines_deleted: 50,
3486 complexity_density: 0.4,
3487 fan_in: 3,
3488 trend: fallow_core::churn::ChurnTrend::Stable,
3489 ownership: None,
3490 is_test_path: false,
3491 }
3492 .into(),
3493 ];
3494 report.hotspot_summary = Some(crate::health_types::HotspotSummary {
3495 since: "6 months".to_string(),
3496 min_commits: 3,
3497 files_analyzed: 50,
3498 files_excluded: 20,
3499 shallow_clone: false,
3500 });
3501 let lines = build_health_human_lines(&report, &root);
3502 let text = plain(&lines);
3503 assert!(text.contains("Hotspots (1 files, since 6 months)"));
3504 assert!(text.contains("20 files excluded (< 3 commits)"));
3505 }
3506
3507 #[test]
3508 fn hotspots_summary_no_exclusions() {
3509 let root = PathBuf::from("/project");
3510 let mut report = empty_report();
3511 report.hotspots = vec![
3512 crate::health_types::HotspotEntry {
3513 path: root.join("src/a.ts"),
3514 score: 50.0,
3515 commits: 10,
3516 weighted_commits: 8.0,
3517 lines_added: 100,
3518 lines_deleted: 50,
3519 complexity_density: 0.4,
3520 fan_in: 3,
3521 trend: fallow_core::churn::ChurnTrend::Stable,
3522 ownership: None,
3523 is_test_path: false,
3524 }
3525 .into(),
3526 ];
3527 report.hotspot_summary = Some(crate::health_types::HotspotSummary {
3528 since: "3 months".to_string(),
3529 min_commits: 2,
3530 files_analyzed: 50,
3531 files_excluded: 0,
3532 shallow_clone: false,
3533 });
3534 let lines = build_health_human_lines(&report, &root);
3535 let text = plain(&lines);
3536 assert!(!text.contains("files excluded"));
3537 }
3538
3539 #[test]
3540 fn hotspots_docs_link() {
3541 let root = PathBuf::from("/project");
3542 let mut report = empty_report();
3543 report.hotspots = vec![
3544 crate::health_types::HotspotEntry {
3545 path: root.join("src/a.ts"),
3546 score: 50.0,
3547 commits: 10,
3548 weighted_commits: 8.0,
3549 lines_added: 100,
3550 lines_deleted: 50,
3551 complexity_density: 0.4,
3552 fan_in: 3,
3553 trend: fallow_core::churn::ChurnTrend::Stable,
3554 ownership: None,
3555 is_test_path: false,
3556 }
3557 .into(),
3558 ];
3559 let lines = build_health_human_lines(&report, &root);
3560 let text = plain(&lines);
3561 assert!(text.contains("docs.fallow.tools/explanations/health#hotspot-metrics"));
3562 }
3563
3564 #[test]
3565 fn refactoring_targets_single_low_effort() {
3566 let root = PathBuf::from("/project");
3567 let mut report = empty_report();
3568 report.targets = vec![
3569 crate::health_types::RefactoringTarget {
3570 path: root.join("src/legacy.ts"),
3571 priority: 65.0,
3572 efficiency: 65.0,
3573 recommendation: "Extract complex logic into helper functions".to_string(),
3574 category: crate::health_types::RecommendationCategory::ExtractComplexFunctions,
3575 effort: crate::health_types::EffortEstimate::Low,
3576 confidence: crate::health_types::Confidence::High,
3577 factors: vec![],
3578 evidence: None,
3579 }
3580 .into(),
3581 ];
3582 let lines = build_health_human_lines(&report, &root);
3583 let text = plain(&lines);
3584 assert!(text.contains("Refactoring targets (1)"));
3585 assert!(text.contains("1 low effort"));
3586 assert!(text.contains("65.0"));
3587 assert!(text.contains("pri:65.0"));
3588 assert!(text.contains("src/legacy.ts"));
3589 assert!(text.contains("complexity"));
3590 assert!(text.contains("effort:low"));
3591 assert!(text.contains("confidence:high"));
3592 assert!(text.contains("Extract complex logic into helper functions"));
3593 }
3594
3595 #[test]
3596 fn refactoring_targets_mixed_effort() {
3597 let root = PathBuf::from("/project");
3598 let mut report = empty_report();
3599 report.targets = vec![
3600 crate::health_types::RefactoringTarget {
3601 path: root.join("src/a.ts"),
3602 priority: 80.0,
3603 efficiency: 80.0,
3604 recommendation: "Remove dead exports".to_string(),
3605 category: crate::health_types::RecommendationCategory::RemoveDeadCode,
3606 effort: crate::health_types::EffortEstimate::Low,
3607 confidence: crate::health_types::Confidence::High,
3608 factors: vec![],
3609 evidence: None,
3610 }
3611 .into(),
3612 crate::health_types::RefactoringTarget {
3613 path: root.join("src/b.ts"),
3614 priority: 60.0,
3615 efficiency: 30.0,
3616 recommendation: "Split into smaller modules".to_string(),
3617 category: crate::health_types::RecommendationCategory::SplitHighImpact,
3618 effort: crate::health_types::EffortEstimate::Medium,
3619 confidence: crate::health_types::Confidence::Medium,
3620 factors: vec![],
3621 evidence: None,
3622 }
3623 .into(),
3624 crate::health_types::RefactoringTarget {
3625 path: root.join("src/c.ts"),
3626 priority: 50.0,
3627 efficiency: 16.7,
3628 recommendation: "Break circular dependency".to_string(),
3629 category: crate::health_types::RecommendationCategory::BreakCircularDependency,
3630 effort: crate::health_types::EffortEstimate::High,
3631 confidence: crate::health_types::Confidence::Low,
3632 factors: vec![],
3633 evidence: None,
3634 }
3635 .into(),
3636 ];
3637 let lines = build_health_human_lines(&report, &root);
3638 let text = plain(&lines);
3639 assert!(text.contains("Refactoring targets (3)"));
3640 assert!(text.contains("1 low effort"));
3641 assert!(text.contains("1 medium"));
3642 assert!(text.contains("1 high"));
3643 assert!(text.contains("effort:low"));
3644 assert!(text.contains("effort:medium"));
3645 assert!(text.contains("effort:high"));
3646 assert!(text.contains("confidence:high"));
3647 assert!(text.contains("confidence:medium"));
3648 assert!(text.contains("confidence:low"));
3649 }
3650
3651 #[test]
3652 fn refactoring_targets_truncation_above_max_flat_items() {
3653 let root = PathBuf::from("/project");
3654 let mut report = empty_report();
3655 for i in 0..12 {
3656 report.targets.push(
3657 crate::health_types::RefactoringTarget {
3658 path: root.join(format!("src/target{i}.ts")),
3659 priority: 50.0,
3660 efficiency: 25.0,
3661 recommendation: format!("Fix target {i}"),
3662 category: crate::health_types::RecommendationCategory::ExtractComplexFunctions,
3663 effort: crate::health_types::EffortEstimate::Medium,
3664 confidence: crate::health_types::Confidence::Medium,
3665 factors: vec![],
3666 evidence: None,
3667 }
3668 .into(),
3669 );
3670 }
3671 let lines = build_health_human_lines(&report, &root);
3672 let text = plain(&lines);
3673 assert!(text.contains("Refactoring targets (12)"));
3674 assert!(text.contains("... and 2 more targets"));
3675 assert!(text.contains("target0.ts"));
3676 assert!(text.contains("target9.ts"));
3677 assert!(!text.contains("target10.ts"));
3678 }
3679
3680 #[test]
3681 fn refactoring_targets_docs_link() {
3682 let root = PathBuf::from("/project");
3683 let mut report = empty_report();
3684 report.targets = vec![
3685 crate::health_types::RefactoringTarget {
3686 path: root.join("src/a.ts"),
3687 priority: 50.0,
3688 efficiency: 50.0,
3689 recommendation: "Fix it".to_string(),
3690 category: crate::health_types::RecommendationCategory::ExtractDependencies,
3691 effort: crate::health_types::EffortEstimate::Low,
3692 confidence: crate::health_types::Confidence::High,
3693 factors: vec![],
3694 evidence: None,
3695 }
3696 .into(),
3697 ];
3698 let lines = build_health_human_lines(&report, &root);
3699 let text = plain(&lines);
3700 assert!(text.contains("docs.fallow.tools/explanations/health#refactoring-targets"));
3701 }
3702
3703 #[test]
3704 fn refactoring_targets_all_categories() {
3705 let root = PathBuf::from("/project");
3706 let mut report = empty_report();
3707 let categories = [
3708 (
3709 crate::health_types::RecommendationCategory::UrgentChurnComplexity,
3710 "churn+complexity",
3711 ),
3712 (
3713 crate::health_types::RecommendationCategory::BreakCircularDependency,
3714 "circular dependency",
3715 ),
3716 (
3717 crate::health_types::RecommendationCategory::SplitHighImpact,
3718 "high impact",
3719 ),
3720 (
3721 crate::health_types::RecommendationCategory::RemoveDeadCode,
3722 "dead code",
3723 ),
3724 (
3725 crate::health_types::RecommendationCategory::ExtractComplexFunctions,
3726 "complexity",
3727 ),
3728 (
3729 crate::health_types::RecommendationCategory::ExtractDependencies,
3730 "coupling",
3731 ),
3732 (
3733 crate::health_types::RecommendationCategory::AddTestCoverage,
3734 "untested risk",
3735 ),
3736 ];
3737 for (i, (cat, _label)) in categories.iter().enumerate() {
3738 report.targets.push(
3739 crate::health_types::RefactoringTarget {
3740 path: root.join(format!("src/cat{i}.ts")),
3741 priority: 50.0,
3742 efficiency: 50.0,
3743 recommendation: format!("Fix cat{i}"),
3744 category: cat.clone(),
3745 effort: crate::health_types::EffortEstimate::Low,
3746 confidence: crate::health_types::Confidence::High,
3747 factors: vec![],
3748 evidence: None,
3749 }
3750 .into(),
3751 );
3752 }
3753 let lines = build_health_human_lines(&report, &root);
3754 let text = plain(&lines);
3755 for (_cat, label) in &categories {
3756 assert!(
3757 text.contains(label),
3758 "Expected category label '{label}' in output"
3759 );
3760 }
3761 }
3762
3763 #[test]
3764 fn refactoring_targets_efficiency_color_thresholds() {
3765 let root = PathBuf::from("/project");
3766 let mut report = empty_report();
3767 report.targets = vec![
3768 crate::health_types::RefactoringTarget {
3769 path: root.join("src/high.ts"),
3770 priority: 50.0,
3771 efficiency: 50.0, recommendation: "High eff".to_string(),
3773 category: crate::health_types::RecommendationCategory::RemoveDeadCode,
3774 effort: crate::health_types::EffortEstimate::Low,
3775 confidence: crate::health_types::Confidence::High,
3776 factors: vec![],
3777 evidence: None,
3778 }
3779 .into(),
3780 crate::health_types::RefactoringTarget {
3781 path: root.join("src/mid.ts"),
3782 priority: 50.0,
3783 efficiency: 25.0, recommendation: "Mid eff".to_string(),
3785 category: crate::health_types::RecommendationCategory::RemoveDeadCode,
3786 effort: crate::health_types::EffortEstimate::Medium,
3787 confidence: crate::health_types::Confidence::Medium,
3788 factors: vec![],
3789 evidence: None,
3790 }
3791 .into(),
3792 crate::health_types::RefactoringTarget {
3793 path: root.join("src/low.ts"),
3794 priority: 50.0,
3795 efficiency: 10.0, recommendation: "Low eff".to_string(),
3797 category: crate::health_types::RecommendationCategory::RemoveDeadCode,
3798 effort: crate::health_types::EffortEstimate::High,
3799 confidence: crate::health_types::Confidence::Low,
3800 factors: vec![],
3801 evidence: None,
3802 }
3803 .into(),
3804 ];
3805 let lines = build_health_human_lines(&report, &root);
3806 let text = plain(&lines);
3807 assert!(text.contains("50.0"));
3808 assert!(text.contains("25.0"));
3809 assert!(text.contains("10.0"));
3810 }
3811
3812 #[test]
3813 fn all_sections_combined() {
3814 let root = PathBuf::from("/project");
3815 let mut report = empty_report();
3816 report.summary.functions_above_threshold = 1;
3817 report.findings = vec![
3818 crate::health_types::ComplexityViolation {
3819 path: root.join("src/complex.ts"),
3820 name: "bigFn".to_string(),
3821 line: 10,
3822 col: 0,
3823 cyclomatic: 25,
3824 cognitive: 20,
3825 line_count: 80,
3826 param_count: 0,
3827 exceeded: crate::health_types::ExceededThreshold::Both,
3828 severity: crate::health_types::FindingSeverity::Moderate,
3829 crap: None,
3830 coverage_pct: None,
3831 coverage_tier: None,
3832 coverage_source: None,
3833 inherited_from: None,
3834 component_rollup: None,
3835 contributions: Vec::new(),
3836 }
3837 .into(),
3838 ];
3839 report.health_score = Some(crate::health_types::HealthScore {
3840 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
3841 score: 75.0,
3842 grade: "B",
3843 penalties: crate::health_types::HealthScorePenalties {
3844 dead_files: Some(5.0),
3845 dead_exports: Some(5.0),
3846 complexity: 5.0,
3847 p90_complexity: 2.0,
3848 maintainability: Some(3.0),
3849 hotspots: Some(2.0),
3850 unused_deps: Some(2.0),
3851 circular_deps: Some(1.0),
3852 unit_size: None,
3853 coupling: None,
3854 duplication: None,
3855 },
3856 });
3857 report.file_scores = vec![crate::health_types::FileHealthScore {
3858 path: root.join("src/complex.ts"),
3859 fan_in: 5,
3860 fan_out: 3,
3861 dead_code_ratio: 0.1,
3862 complexity_density: 0.5,
3863 maintainability_index: 60.0,
3864 total_cyclomatic: 15,
3865 total_cognitive: 10,
3866 function_count: 3,
3867 lines: 200,
3868 crap_max: 0.0,
3869 crap_above_threshold: 0,
3870 }];
3871 report.hotspots = vec![
3872 crate::health_types::HotspotEntry {
3873 path: root.join("src/complex.ts"),
3874 score: 65.0,
3875 commits: 20,
3876 weighted_commits: 15.0,
3877 lines_added: 300,
3878 lines_deleted: 100,
3879 complexity_density: 0.5,
3880 fan_in: 5,
3881 trend: fallow_core::churn::ChurnTrend::Accelerating,
3882 ownership: None,
3883 is_test_path: false,
3884 }
3885 .into(),
3886 ];
3887 report.targets = vec![
3888 crate::health_types::RefactoringTarget {
3889 path: root.join("src/complex.ts"),
3890 priority: 70.0,
3891 efficiency: 70.0,
3892 recommendation: "Extract complex functions".to_string(),
3893 category: crate::health_types::RecommendationCategory::ExtractComplexFunctions,
3894 effort: crate::health_types::EffortEstimate::Low,
3895 confidence: crate::health_types::Confidence::High,
3896 factors: vec![],
3897 evidence: None,
3898 }
3899 .into(),
3900 ];
3901 let lines = build_health_human_lines(&report, &root);
3902 let text = plain(&lines);
3903 assert!(text.contains("Health score:"));
3904 assert!(text.contains("High complexity functions"));
3905 assert!(text.contains("File health scores"));
3906 assert!(text.contains("Hotspots"));
3907 assert!(text.contains("Refactoring targets"));
3908 }
3909
3910 #[test]
3911 fn completely_empty_report_produces_no_lines() {
3912 let root = PathBuf::from("/project");
3913 let report = empty_report();
3914 let lines = build_health_human_lines(&report, &root);
3915 assert!(lines.is_empty());
3916 }
3917
3918 #[test]
3919 fn finding_only_cyclomatic_exceeds() {
3920 let root = PathBuf::from("/project");
3921 let mut report = empty_report();
3922 report.summary.functions_above_threshold = 1;
3923 report.findings = vec![
3924 crate::health_types::ComplexityViolation {
3925 path: root.join("src/a.ts"),
3926 name: "fn1".to_string(),
3927 line: 1,
3928 col: 0,
3929 cyclomatic: 25, cognitive: 10, line_count: 50,
3932 param_count: 0,
3933 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
3934 severity: crate::health_types::FindingSeverity::Moderate,
3935 crap: None,
3936 coverage_pct: None,
3937 coverage_tier: None,
3938 coverage_source: None,
3939 inherited_from: None,
3940 component_rollup: None,
3941 contributions: Vec::new(),
3942 }
3943 .into(),
3944 ];
3945 let lines = build_health_human_lines(&report, &root);
3946 let text = plain(&lines);
3947 assert!(text.contains("25 cyclomatic"));
3948 assert!(text.contains("10 cognitive"));
3949 }
3950
3951 #[test]
3952 fn finding_only_cognitive_exceeds() {
3953 let root = PathBuf::from("/project");
3954 let mut report = empty_report();
3955 report.summary.functions_above_threshold = 1;
3956 report.findings = vec![
3957 crate::health_types::ComplexityViolation {
3958 path: root.join("src/a.ts"),
3959 name: "fn1".to_string(),
3960 line: 1,
3961 col: 0,
3962 cyclomatic: 10, cognitive: 25, line_count: 50,
3965 param_count: 0,
3966 exceeded: crate::health_types::ExceededThreshold::Cognitive,
3967 severity: crate::health_types::FindingSeverity::High,
3968 crap: None,
3969 coverage_pct: None,
3970 coverage_tier: None,
3971 coverage_source: None,
3972 inherited_from: None,
3973 component_rollup: None,
3974 contributions: Vec::new(),
3975 }
3976 .into(),
3977 ];
3978 let lines = build_health_human_lines(&report, &root);
3979 let text = plain(&lines);
3980 assert!(text.contains("10 cyclomatic"));
3981 assert!(text.contains("25 cognitive"));
3982 }
3983
3984 #[test]
3985 fn findings_across_multiple_files() {
3986 let root = PathBuf::from("/project");
3987 let mut report = empty_report();
3988 report.summary.functions_above_threshold = 2;
3989 report.findings = vec![
3990 crate::health_types::ComplexityViolation {
3991 path: root.join("src/a.ts"),
3992 name: "fn1".to_string(),
3993 line: 1,
3994 col: 0,
3995 cyclomatic: 25,
3996 cognitive: 20,
3997 line_count: 50,
3998 param_count: 0,
3999 exceeded: crate::health_types::ExceededThreshold::Both,
4000 severity: crate::health_types::FindingSeverity::Moderate,
4001 crap: None,
4002 coverage_pct: None,
4003 coverage_tier: None,
4004 coverage_source: None,
4005 inherited_from: None,
4006 component_rollup: None,
4007 contributions: Vec::new(),
4008 }
4009 .into(),
4010 crate::health_types::ComplexityViolation {
4011 path: root.join("src/b.ts"),
4012 name: "fn2".to_string(),
4013 line: 5,
4014 col: 0,
4015 cyclomatic: 22,
4016 cognitive: 18,
4017 line_count: 40,
4018 param_count: 0,
4019 exceeded: crate::health_types::ExceededThreshold::Both,
4020 severity: crate::health_types::FindingSeverity::Moderate,
4021 crap: None,
4022 coverage_pct: None,
4023 coverage_tier: None,
4024 coverage_source: None,
4025 inherited_from: None,
4026 component_rollup: None,
4027 contributions: Vec::new(),
4028 }
4029 .into(),
4030 ];
4031 let lines = build_health_human_lines(&report, &root);
4032 let text = plain(&lines);
4033 assert!(text.contains("src/a.ts"));
4034 assert!(text.contains("src/b.ts"));
4035 }
4036
4037 #[test]
4038 fn findings_docs_link() {
4039 let root = PathBuf::from("/project");
4040 let mut report = empty_report();
4041 report.summary.functions_above_threshold = 1;
4042 report.findings = vec![
4043 crate::health_types::ComplexityViolation {
4044 path: root.join("src/a.ts"),
4045 name: "fn1".to_string(),
4046 line: 1,
4047 col: 0,
4048 cyclomatic: 25,
4049 cognitive: 20,
4050 line_count: 50,
4051 param_count: 0,
4052 exceeded: crate::health_types::ExceededThreshold::Both,
4053 severity: crate::health_types::FindingSeverity::Moderate,
4054 crap: None,
4055 coverage_pct: None,
4056 coverage_tier: None,
4057 coverage_source: None,
4058 inherited_from: None,
4059 component_rollup: None,
4060 contributions: Vec::new(),
4061 }
4062 .into(),
4063 ];
4064 let lines = build_health_human_lines(&report, &root);
4065 let text = plain(&lines);
4066 assert!(text.contains("docs.fallow.tools/explanations/health#complexity-metrics"));
4067 }
4068
4069 #[test]
4070 fn hotspot_score_high_medium_low() {
4071 let root = PathBuf::from("/project");
4072 let mut report = empty_report();
4073 report.hotspots = vec![
4074 crate::health_types::HotspotEntry {
4075 path: root.join("src/high.ts"),
4076 score: 80.0, commits: 30,
4078 weighted_commits: 25.0,
4079 lines_added: 400,
4080 lines_deleted: 200,
4081 complexity_density: 0.9,
4082 fan_in: 8,
4083 trend: fallow_core::churn::ChurnTrend::Accelerating,
4084 ownership: None,
4085 is_test_path: false,
4086 }
4087 .into(),
4088 crate::health_types::HotspotEntry {
4089 path: root.join("src/medium.ts"),
4090 score: 45.0, commits: 15,
4092 weighted_commits: 10.0,
4093 lines_added: 200,
4094 lines_deleted: 100,
4095 complexity_density: 0.5,
4096 fan_in: 4,
4097 trend: fallow_core::churn::ChurnTrend::Stable,
4098 ownership: None,
4099 is_test_path: false,
4100 }
4101 .into(),
4102 crate::health_types::HotspotEntry {
4103 path: root.join("src/low.ts"),
4104 score: 15.0, commits: 5,
4106 weighted_commits: 3.0,
4107 lines_added: 50,
4108 lines_deleted: 20,
4109 complexity_density: 0.2,
4110 fan_in: 1,
4111 trend: fallow_core::churn::ChurnTrend::Cooling,
4112 ownership: None,
4113 is_test_path: false,
4114 }
4115 .into(),
4116 ];
4117 let lines = build_health_human_lines(&report, &root);
4118 let text = plain(&lines);
4119 assert!(text.contains("80.0"));
4120 assert!(text.contains("45.0"));
4121 assert!(text.contains("15.0"));
4122 assert!(text.contains("Hotspots (3 files)"));
4123 }
4124
4125 #[test]
4126 fn rollup_breakdown_renders_workspace_relative_template_path() {
4127 let root = PathBuf::from("/project");
4128 let template =
4129 root.join("apps/admin/src/app/payments/payment-list/payment-list.component.html");
4130 let finding = crate::health_types::ComplexityViolation {
4131 path: root.join("apps/admin/src/app/payments/payment-list/payment-list.component.ts"),
4132 name: "<component>".to_string(),
4133 line: 1,
4134 col: 0,
4135 cyclomatic: 25,
4136 cognitive: 28,
4137 line_count: 0,
4138 param_count: 0,
4139 exceeded: crate::health_types::ExceededThreshold::Both,
4140 severity: crate::health_types::FindingSeverity::High,
4141 crap: None,
4142 coverage_pct: None,
4143 coverage_tier: None,
4144 coverage_source: None,
4145 inherited_from: None,
4146 component_rollup: Some(crate::health_types::ComponentRollup {
4147 component: "PaymentListComponent".to_string(),
4148 class_worst_function: "ngOnInit".to_string(),
4149 class_cyclomatic: 12,
4150 class_cognitive: 16,
4151 template_path: template,
4152 template_cyclomatic: 13,
4153 template_cognitive: 12,
4154 }),
4155 contributions: Vec::new(),
4156 };
4157 let line = render_component_rollup_breakdown(&finding, &root)
4158 .expect("rollup payload should render a breakdown line");
4159 assert!(
4160 line.contains("apps/admin/src/app/payments/payment-list/payment-list.component.html"),
4161 "breakdown must include workspace-relative template path: {line}"
4162 );
4163 assert!(
4164 !line.contains(" payment-list.component.html"),
4165 "bare basename token must not be the rendered template: {line}"
4166 );
4167 }
4168
4169 #[test]
4170 fn inherited_from_renders_workspace_relative_owner_path() {
4171 let root = PathBuf::from("/project");
4172 let owner = root.join("apps/admin/src/app/auth/permissions/permissions.component.ts");
4173 let template_path =
4174 root.join("apps/admin/src/app/auth/permissions/permissions.component.html");
4175 let report = crate::health_types::HealthReport {
4176 findings: vec![
4177 crate::health_types::ComplexityViolation {
4178 path: template_path,
4179 name: "<template>".to_string(),
4180 line: 1,
4181 col: 0,
4182 cyclomatic: 12,
4183 cognitive: 14,
4184 line_count: 0,
4185 param_count: 0,
4186 exceeded: crate::health_types::ExceededThreshold::Both,
4187 severity: crate::health_types::FindingSeverity::High,
4188 crap: Some(45.0),
4189 coverage_pct: None,
4190 coverage_tier: Some(crate::health_types::CoverageTier::Partial),
4191 coverage_source: Some(
4192 crate::health_types::CoverageSource::EstimatedComponentInherited,
4193 ),
4194 inherited_from: Some(owner),
4195 component_rollup: None,
4196 contributions: Vec::new(),
4197 }
4198 .into(),
4199 ],
4200 summary: crate::health_types::HealthSummary {
4201 files_analyzed: 1,
4202 functions_analyzed: 1,
4203 functions_above_threshold: 1,
4204 ..Default::default()
4205 },
4206 ..Default::default()
4207 };
4208 let lines = build_health_human_lines(&report, &root);
4209 let text = plain(&lines);
4210 assert!(
4211 text.contains(
4212 "(inherited from apps/admin/src/app/auth/permissions/permissions.component.ts)"
4213 ),
4214 "inherited-from suffix must use workspace-relative path: {text}"
4215 );
4216 assert!(
4217 !text.contains("(inherited from permissions.component.ts)"),
4218 "bare basename suffix must not be rendered: {text}"
4219 );
4220 }
4221}