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