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