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