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.drift {
1467 parts.push("drift".yellow().to_string());
1468 }
1469
1470 parts.join(" ")
1471}
1472
1473fn render_hotspots(
1474 lines: &mut Vec<String>,
1475 report: &crate::health_types::HealthReport,
1476 root: &Path,
1477) {
1478 if report.hotspots.is_empty() {
1479 return;
1480 }
1481
1482 let header = report.hotspot_summary.as_ref().map_or_else(
1483 || format!("Hotspots ({} files)", report.hotspots.len()),
1484 |summary| {
1485 format!(
1486 "Hotspots ({} files, since {})",
1487 report.hotspots.len(),
1488 summary.since,
1489 )
1490 },
1491 );
1492 lines.push(format!("{} {}", "\u{25cf}".red(), header.red().bold()));
1493 lines.push(String::new());
1494
1495 if let Some(summary_line) = render_ownership_summary(report) {
1499 lines.push(format!(" {summary_line}"));
1500 lines.push(String::new());
1501 }
1502
1503 for entry in &report.hotspots {
1504 let file_str = relative_path(&entry.path, root).display().to_string();
1505
1506 let score_str = format!("{:>5.1}", entry.score);
1508 let score_colored = if entry.score >= 70.0 {
1509 score_str.red().bold().to_string()
1510 } else if entry.score >= 30.0 {
1511 score_str.yellow().to_string()
1512 } else {
1513 score_str.green().to_string()
1514 };
1515
1516 let (trend_symbol, trend_colored) = match entry.trend {
1518 fallow_core::churn::ChurnTrend::Accelerating => {
1519 ("\u{25b2}", "\u{25b2} accelerating".red().to_string())
1520 }
1521 fallow_core::churn::ChurnTrend::Cooling => {
1522 ("\u{25bc}", "\u{25bc} cooling".green().to_string())
1523 }
1524 fallow_core::churn::ChurnTrend::Stable => {
1525 ("\u{2500}", "\u{2500} stable".dimmed().to_string())
1526 }
1527 };
1528
1529 let (dir, filename) = split_dir_filename(&file_str);
1531
1532 let test_tag = if entry.is_test_path {
1536 format!(" {}", "[test]".dimmed())
1537 } else {
1538 String::new()
1539 };
1540 lines.push(format!(
1541 " {} {} {}{}{}",
1542 score_colored,
1543 match entry.trend {
1544 fallow_core::churn::ChurnTrend::Accelerating => trend_symbol.red().to_string(),
1545 fallow_core::churn::ChurnTrend::Cooling => trend_symbol.green().to_string(),
1546 fallow_core::churn::ChurnTrend::Stable => trend_symbol.dimmed().to_string(),
1547 },
1548 dir.dimmed(),
1549 filename,
1550 test_tag,
1551 ));
1552
1553 lines.push(format!(
1555 " {} commits {} churn {} density {} fan-in {}",
1556 format!("{:>3}", entry.commits).dimmed(),
1557 format!("{:>5}", entry.lines_added + entry.lines_deleted).dimmed(),
1558 format!("{:.2}", entry.complexity_density).dimmed(),
1559 format!("{:>2}", entry.fan_in).dimmed(),
1560 trend_colored,
1561 ));
1562
1563 if let Some(ownership) = &entry.ownership {
1566 lines.push(format!(
1567 " {}",
1568 render_ownership_line(ownership, entry.trend)
1569 ));
1570 }
1571
1572 lines.push(String::new());
1574 }
1575
1576 if let Some(ref summary) = report.hotspot_summary
1577 && summary.files_excluded > 0
1578 {
1579 lines.push(format!(
1580 " {}",
1581 format!(
1582 "{} file{} excluded (< {} commits)",
1583 summary.files_excluded,
1584 plural(summary.files_excluded),
1585 summary.min_commits,
1586 )
1587 .dimmed()
1588 ));
1589 lines.push(String::new());
1590 }
1591 let any_ownership = report.hotspots.iter().any(|h| h.ownership.is_some());
1595 let no_codeowners_anywhere = report
1596 .hotspots
1597 .iter()
1598 .filter_map(|h| h.ownership.as_ref())
1599 .all(|o| o.unowned.is_none());
1600 if any_ownership && no_codeowners_anywhere {
1601 lines.push(format!(
1602 " {}",
1603 "No CODEOWNERS file discovered, ownership signals limited to git history.".dimmed()
1604 ));
1605 }
1606 lines.push(format!(
1607 " {}",
1608 format!("Files with high churn and high complexity \u{2014} {DOCS_HEALTH}#hotspot-metrics")
1609 .dimmed()
1610 ));
1611 lines.push(String::new());
1612}
1613
1614fn render_refactoring_targets(
1615 lines: &mut Vec<String>,
1616 report: &crate::health_types::HealthReport,
1617 root: &Path,
1618) {
1619 if report.targets.is_empty() {
1620 return;
1621 }
1622
1623 lines.push(format!(
1624 "{} {}",
1625 "\u{25cf}".cyan(),
1626 format!("Refactoring targets ({})", report.targets.len())
1627 .cyan()
1628 .bold()
1629 ));
1630
1631 let low = report
1633 .targets
1634 .iter()
1635 .filter(|t| matches!(t.effort, crate::health_types::EffortEstimate::Low))
1636 .count();
1637 let medium = report
1638 .targets
1639 .iter()
1640 .filter(|t| matches!(t.effort, crate::health_types::EffortEstimate::Medium))
1641 .count();
1642 let high = report
1643 .targets
1644 .iter()
1645 .filter(|t| matches!(t.effort, crate::health_types::EffortEstimate::High))
1646 .count();
1647 let mut effort_parts = Vec::new();
1648 if low > 0 {
1649 effort_parts.push(format!("{low} low effort"));
1650 }
1651 if medium > 0 {
1652 effort_parts.push(format!("{medium} medium"));
1653 }
1654 if high > 0 {
1655 effort_parts.push(format!("{high} high"));
1656 }
1657 lines.push(format!(" {}", effort_parts.join(" \u{00b7} ").dimmed()));
1658 lines.push(format!(
1659 " {}",
1660 " score = quick-win ROI (higher = better) \u{00b7} pri = absolute priority".dimmed()
1661 ));
1662 lines.push(String::new());
1663
1664 let shown_targets = report.targets.len().min(MAX_FLAT_ITEMS);
1665 for target in &report.targets[..shown_targets] {
1666 let file_str = relative_path(&target.path, root).display().to_string();
1667
1668 let eff_str = format!("{:>5.1}", target.efficiency);
1670 let eff_colored = if target.efficiency >= 40.0 {
1671 eff_str.green().to_string()
1672 } else if target.efficiency >= 20.0 {
1673 eff_str.yellow().to_string()
1674 } else {
1675 eff_str.dimmed().to_string()
1676 };
1677
1678 let (dir, filename) = split_dir_filename(&file_str);
1680
1681 lines.push(format!(
1683 " {} {} {}{}",
1684 eff_colored,
1685 format!("pri:{:.1}", target.priority).dimmed(),
1686 dir.dimmed(),
1687 filename,
1688 ));
1689
1690 let label = target.category.label();
1692 let effort = target.effort.label();
1693 let effort_colored = match target.effort {
1694 crate::health_types::EffortEstimate::Low => effort.green().to_string(),
1695 crate::health_types::EffortEstimate::Medium => effort.yellow().to_string(),
1696 crate::health_types::EffortEstimate::High => effort.red().to_string(),
1697 };
1698 let confidence = target.confidence.label();
1699 let confidence_colored = match target.confidence {
1700 crate::health_types::Confidence::High => confidence.green().to_string(),
1701 crate::health_types::Confidence::Medium => confidence.yellow().to_string(),
1702 crate::health_types::Confidence::Low => confidence.dimmed().to_string(),
1703 };
1704 let generated_tag = if recommendation_mentions_generated(&target.recommendation) {
1705 format!(" {}", "(generated)".dimmed())
1706 } else {
1707 String::new()
1708 };
1709 lines.push(format!(
1710 " {} \u{00b7} effort:{} \u{00b7} confidence:{} {}{}",
1711 label.yellow(),
1712 effort_colored,
1713 confidence_colored,
1714 target.recommendation.dimmed(),
1715 generated_tag,
1716 ));
1717
1718 lines.push(String::new());
1720 }
1721 if report.targets.len() > MAX_FLAT_ITEMS {
1722 lines.push(format!(
1723 " {}",
1724 format!(
1725 "... and {} more targets (--format json for full list)",
1726 report.targets.len() - MAX_FLAT_ITEMS
1727 )
1728 .dimmed()
1729 ));
1730 lines.push(String::new());
1731 }
1732 lines.push(format!(
1733 " {}",
1734 format!(
1735 "Prioritized refactoring recommendations based on complexity, churn, and coupling signals \u{2014} {DOCS_HEALTH}#refactoring-targets"
1736 )
1737 .dimmed()
1738 ));
1739 lines.push(String::new());
1740}
1741
1742pub(in crate::report) fn print_health_summary(
1744 report: &crate::health_types::HealthReport,
1745 elapsed: Duration,
1746 quiet: bool,
1747 heading: bool,
1748) {
1749 let s = &report.summary;
1750
1751 if heading {
1752 println!("{}", "Health Summary".bold());
1753 println!();
1754 }
1755 println!(" {:>6} Functions analyzed", s.functions_analyzed);
1756 println!(" {:>6} Above threshold", s.functions_above_threshold);
1757 if let Some(mi) = s.average_maintainability {
1758 let label = if mi >= 85.0 {
1759 "good"
1760 } else if mi >= 65.0 {
1761 "moderate"
1762 } else {
1763 "low"
1764 };
1765 println!(" {mi:>5.1} Average maintainability ({label})");
1766 }
1767 if let Some(ref score) = report.health_score {
1768 println!(" {:>5.0} {} Health score", score.score, score.grade);
1769 }
1770 if let Some(ref gaps) = report.coverage_gaps {
1771 println!(
1772 " {:>6} Untested {} ({:.1}% file coverage)",
1773 gaps.summary.untested_files,
1774 if gaps.summary.untested_files == 1 {
1775 "file"
1776 } else {
1777 "files"
1778 },
1779 gaps.summary.file_coverage_pct,
1780 );
1781 println!(
1782 " {:>6} Untested {}",
1783 gaps.summary.untested_exports,
1784 if gaps.summary.untested_exports == 1 {
1785 "export"
1786 } else {
1787 "exports"
1788 },
1789 );
1790 }
1791 if let Some(ref production) = report.runtime_coverage {
1792 println!(
1793 " {:>6} Unhit in production",
1794 production.summary.functions_unhit,
1795 );
1796 println!(
1797 " {:>6} Untracked by V8 (lazy-parsed / worker / dynamic)",
1798 production.summary.functions_untracked,
1799 );
1800 }
1801
1802 if !quiet {
1803 eprintln!(
1804 "{}",
1805 format!(
1806 "\u{2713} {} functions analyzed ({:.2}s)",
1807 s.functions_analyzed,
1808 elapsed.as_secs_f64()
1809 )
1810 .green()
1811 .bold()
1812 );
1813 }
1814}
1815
1816pub(in crate::report) fn print_health_grouping(
1837 grouping: &crate::health_types::HealthGrouping,
1838 _root: &Path,
1839 quiet: bool,
1840) {
1841 if grouping.groups.is_empty() {
1842 return;
1843 }
1844 if !quiet {
1845 eprintln!();
1846 }
1847 println!(
1848 "{} {}",
1849 "\u{25cf}".cyan(),
1850 format!("Per-{} health", grouping.mode).cyan().bold()
1851 );
1852 let key_width = grouping
1853 .groups
1854 .iter()
1855 .map(|g| g.key.len())
1856 .max()
1857 .unwrap_or(0)
1858 .max(8);
1859 let any_score = grouping.groups.iter().any(|g| g.health_score.is_some());
1860 let any_vitals = grouping.groups.iter().any(|g| g.vital_signs.is_some());
1861
1862 let mut ordered: Vec<&crate::health_types::HealthGroup> = grouping.groups.iter().collect();
1866 if any_score {
1867 ordered.sort_by(|a, b| {
1868 let a_score = a.health_score.as_ref().map_or(f64::INFINITY, |hs| hs.score);
1869 let b_score = b.health_score.as_ref().map_or(f64::INFINITY, |hs| hs.score);
1870 a_score
1871 .partial_cmp(&b_score)
1872 .unwrap_or(std::cmp::Ordering::Equal)
1873 });
1874 }
1875
1876 let mut header = format!(" {:<width$}", "", width = key_width);
1878 if any_score {
1879 let _ = write!(header, " {:>9} grade", "score");
1880 }
1881 let _ = write!(header, " {:>5}", "files");
1882 let _ = write!(header, " {:>3}", "hot");
1883 if any_vitals {
1884 let _ = write!(header, " {:>3}", "p90");
1885 }
1886 println!("{}", header.dimmed());
1887
1888 let mut has_root_bucket = false;
1889 for group in ordered {
1890 if group.key == "(root)" {
1891 has_root_bucket = true;
1892 }
1893 let mut row = format!(" {:<width$}", group.key, width = key_width);
1894 if any_score {
1895 if let Some(ref hs) = group.health_score {
1896 let grade_colored = colorize_grade(hs.grade);
1897 let _ = write!(row, " {:>9.1} {}", hs.score, grade_colored);
1898 } else {
1899 row.push_str(" ");
1900 }
1901 }
1902 let _ = write!(row, " {:>5}", group.files_analyzed);
1903 let _ = write!(row, " {:>3}", group.hotspots.len());
1904 if any_vitals {
1905 if let Some(ref vs) = group.vital_signs {
1906 let _ = write!(row, " {:>3}", vs.p90_cyclomatic);
1907 } else {
1908 row.push_str(" ");
1909 }
1910 }
1911 println!("{row}");
1912 }
1913 if !quiet {
1914 if has_root_bucket {
1915 eprintln!(
1916 " {}",
1917 "(root) = files outside any workspace package".dimmed()
1918 );
1919 }
1920 eprintln!(
1921 " {}",
1922 "per-group summary only; --format json includes per-group findings, file scores, and hotspots"
1923 .dimmed()
1924 );
1925 }
1926}
1927
1928fn colorize_grade(grade: &str) -> String {
1930 match grade {
1931 "A" | "B" => grade.green().to_string(),
1932 "C" => grade.yellow().to_string(),
1933 _ => grade.red().to_string(),
1934 }
1935}
1936
1937#[cfg(test)]
1938mod tests {
1939 use super::super::plain;
1940 use super::*;
1941 use std::path::PathBuf;
1942
1943 #[test]
1944 fn health_empty_findings_produces_no_header() {
1945 let root = PathBuf::from("/project");
1946 let report = crate::health_types::HealthReport {
1947 summary: crate::health_types::HealthSummary {
1948 files_analyzed: 10,
1949 functions_analyzed: 50,
1950 ..Default::default()
1951 },
1952 ..Default::default()
1953 };
1954 let lines = build_health_human_lines(&report, &root);
1955 let text = plain(&lines);
1956 assert!(!text.contains("High complexity functions"));
1958 }
1959
1960 #[test]
1961 fn health_findings_show_function_details() {
1962 let root = PathBuf::from("/project");
1963 let report = crate::health_types::HealthReport {
1964 findings: vec![
1965 crate::health_types::ComplexityViolation {
1966 path: root.join("src/parser.ts"),
1967 name: "parseExpression".to_string(),
1968 line: 42,
1969 col: 0,
1970 cyclomatic: 25,
1971 cognitive: 30,
1972 line_count: 80,
1973 param_count: 0,
1974 exceeded: crate::health_types::ExceededThreshold::Both,
1975 severity: crate::health_types::FindingSeverity::High,
1976 crap: None,
1977 coverage_pct: None,
1978 coverage_tier: None,
1979 coverage_source: None,
1980 inherited_from: None,
1981 component_rollup: None,
1982 }
1983 .into(),
1984 ],
1985 summary: crate::health_types::HealthSummary {
1986 files_analyzed: 10,
1987 functions_analyzed: 50,
1988 functions_above_threshold: 1,
1989 ..Default::default()
1990 },
1991 ..Default::default()
1992 };
1993 let lines = build_health_human_lines(&report, &root);
1994 let text = plain(&lines);
1995 assert!(text.contains("High complexity functions (1)"));
1996 assert!(text.contains("src/parser.ts"));
1997 assert!(text.contains(":42"));
1998 assert!(text.contains("parseExpression"));
1999 assert!(text.contains("25 cyclomatic"));
2000 assert!(text.contains("30 cognitive"));
2001 assert!(text.contains("80 lines"));
2002 }
2003
2004 #[test]
2005 fn health_shown_vs_total_when_truncated() {
2006 let root = PathBuf::from("/project");
2007 let report = crate::health_types::HealthReport {
2008 findings: vec![
2009 crate::health_types::ComplexityViolation {
2010 path: root.join("src/a.ts"),
2011 name: "fn1".to_string(),
2012 line: 1,
2013 col: 0,
2014 cyclomatic: 25,
2015 cognitive: 20,
2016 line_count: 50,
2017 param_count: 0,
2018 exceeded: crate::health_types::ExceededThreshold::Both,
2019 severity: crate::health_types::FindingSeverity::High,
2020 crap: None,
2021 coverage_pct: None,
2022 coverage_tier: None,
2023 coverage_source: None,
2024 inherited_from: None,
2025 component_rollup: None,
2026 }
2027 .into(),
2028 ],
2029 summary: crate::health_types::HealthSummary {
2030 files_analyzed: 100,
2031 functions_analyzed: 500,
2032 functions_above_threshold: 10,
2033 ..Default::default()
2034 },
2035 ..Default::default()
2036 };
2037 let lines = build_health_human_lines(&report, &root);
2038 let text = plain(&lines);
2039 assert!(text.contains("1 shown, 10 total"));
2041 }
2042
2043 #[test]
2044 fn health_findings_grouped_by_file() {
2045 let root = PathBuf::from("/project");
2046 let report = crate::health_types::HealthReport {
2047 findings: vec![
2048 crate::health_types::ComplexityViolation {
2049 path: root.join("src/parser.ts"),
2050 name: "fn1".to_string(),
2051 line: 10,
2052 col: 0,
2053 cyclomatic: 25,
2054 cognitive: 20,
2055 line_count: 40,
2056 param_count: 0,
2057 exceeded: crate::health_types::ExceededThreshold::Both,
2058 severity: crate::health_types::FindingSeverity::High,
2059 crap: None,
2060 coverage_pct: None,
2061 coverage_tier: None,
2062 coverage_source: None,
2063 inherited_from: None,
2064 component_rollup: None,
2065 }
2066 .into(),
2067 crate::health_types::ComplexityViolation {
2068 path: root.join("src/parser.ts"),
2069 name: "fn2".to_string(),
2070 line: 60,
2071 col: 0,
2072 cyclomatic: 22,
2073 cognitive: 18,
2074 line_count: 30,
2075 param_count: 0,
2076 exceeded: crate::health_types::ExceededThreshold::Both,
2077 severity: crate::health_types::FindingSeverity::High,
2078 crap: None,
2079 coverage_pct: None,
2080 coverage_tier: None,
2081 coverage_source: None,
2082 inherited_from: None,
2083 component_rollup: None,
2084 }
2085 .into(),
2086 ],
2087 summary: crate::health_types::HealthSummary {
2088 files_analyzed: 10,
2089 functions_analyzed: 50,
2090 functions_above_threshold: 2,
2091 ..Default::default()
2092 },
2093 ..Default::default()
2094 };
2095 let lines = build_health_human_lines(&report, &root);
2096 let text = plain(&lines);
2097 let count = text.matches("src/parser.ts").count();
2099 assert_eq!(count, 1, "File header should appear once for grouped items");
2100 }
2101
2102 fn empty_report() -> crate::health_types::HealthReport {
2105 crate::health_types::HealthReport {
2106 summary: crate::health_types::HealthSummary {
2107 files_analyzed: 10,
2108 functions_analyzed: 50,
2109 ..Default::default()
2110 },
2111 ..Default::default()
2112 }
2113 }
2114
2115 #[test]
2116 fn health_runtime_coverage_renders_section() {
2117 let root = PathBuf::from("/project");
2118 let mut report = empty_report();
2119 report.runtime_coverage = Some(crate::health_types::RuntimeCoverageReport {
2120 schema_version: crate::health_types::RuntimeCoverageSchemaVersion::V1,
2121 verdict: crate::health_types::RuntimeCoverageReportVerdict::ColdCodeDetected,
2122 signals: Vec::new(),
2123 summary: crate::health_types::RuntimeCoverageSummary {
2124 data_source: crate::health_types::RuntimeCoverageDataSource::Local,
2125 last_received_at: None,
2126 functions_tracked: 4,
2127 functions_hit: 2,
2128 functions_unhit: 1,
2129 functions_untracked: 1,
2130 coverage_percent: 50.0,
2131 trace_count: 2_847_291,
2132 period_days: 30,
2133 deployments_seen: 14,
2134 capture_quality: None,
2135 },
2136 findings: vec![crate::health_types::RuntimeCoverageFinding {
2137 id: "fallow:prod:deadbeef".to_owned(),
2138 path: root.join("src/cold.ts"),
2139 function: "coldPath".to_owned(),
2140 line: 14,
2141 verdict: crate::health_types::RuntimeCoverageVerdict::ReviewRequired,
2142 invocations: Some(0),
2143 confidence: crate::health_types::RuntimeCoverageConfidence::Medium,
2144 evidence: crate::health_types::RuntimeCoverageEvidence {
2145 static_status: "used".to_owned(),
2146 test_coverage: "not_covered".to_owned(),
2147 v8_tracking: "tracked".to_owned(),
2148 untracked_reason: None,
2149 observation_days: 30,
2150 deployments_observed: 14,
2151 },
2152 actions: vec![],
2153 }],
2154 hot_paths: vec![crate::health_types::RuntimeCoverageHotPath {
2155 id: "fallow:hot:cafebabe".to_owned(),
2156 path: root.join("src/hot.ts"),
2157 function: "hotPath".to_owned(),
2158 line: 3,
2159 end_line: 9,
2160 invocations: 250,
2161 percentile: 99,
2162 actions: vec![],
2163 }],
2164 blast_radius: vec![],
2165 importance: vec![],
2166 watermark: Some(crate::health_types::RuntimeCoverageWatermark::LicenseExpiredGrace),
2167 warnings: vec![],
2168 });
2169
2170 let text = plain(&build_health_human_lines(&report, &root));
2171 assert!(text.contains("Runtime coverage: cold code detected"));
2172 assert!(text.contains("src/cold.ts:14 coldPath [0 invocations, review required]"));
2173 assert!(text.contains("license expired grace active"));
2174 assert!(text.contains("hot paths:"));
2175 assert!(text.contains("src/hot.ts:3 hotPath (250 invocations, p99)"));
2176 assert!(!text.contains("short capture:"));
2178 assert!(!text.contains("start a trial"));
2179 }
2180
2181 fn runtime_coverage_report_with_quality(
2182 quality: Option<crate::health_types::RuntimeCoverageCaptureQuality>,
2183 ) -> crate::health_types::RuntimeCoverageReport {
2184 crate::health_types::RuntimeCoverageReport {
2185 schema_version: crate::health_types::RuntimeCoverageSchemaVersion::V1,
2186 verdict: crate::health_types::RuntimeCoverageReportVerdict::Clean,
2187 signals: Vec::new(),
2188 summary: crate::health_types::RuntimeCoverageSummary {
2189 data_source: crate::health_types::RuntimeCoverageDataSource::Local,
2190 last_received_at: None,
2191 functions_tracked: 10,
2192 functions_hit: 7,
2193 functions_unhit: 0,
2194 functions_untracked: 3,
2195 coverage_percent: 70.0,
2196 trace_count: 1_000,
2197 period_days: 1,
2198 deployments_seen: 1,
2199 capture_quality: quality,
2200 },
2201 findings: vec![],
2202 hot_paths: vec![],
2203 blast_radius: vec![],
2204 importance: vec![],
2205 watermark: None,
2206 warnings: vec![],
2207 }
2208 }
2209
2210 #[test]
2211 fn health_runtime_coverage_short_capture_shows_warning_and_prompt() {
2212 let root = PathBuf::from("/project");
2213 let mut report = empty_report();
2214 report.runtime_coverage = Some(runtime_coverage_report_with_quality(Some(
2215 crate::health_types::RuntimeCoverageCaptureQuality {
2216 window_seconds: 720, instances_observed: 1,
2218 lazy_parse_warning: true,
2219 untracked_ratio_percent: 42.5,
2220 },
2221 )));
2222 let text = plain(&build_health_human_lines(&report, &root));
2223 assert!(
2224 text.contains(
2225 "note: short capture (12 min from 1 instance); 42.5% of functions untracked, lazy-parsed scripts may not appear."
2226 ),
2227 "warning banner missing or malformed in:\n{text}"
2228 );
2229 assert!(
2230 text.contains("extend the capture or switch to continuous monitoring"),
2231 "warning follow-up line missing in:\n{text}"
2232 );
2233 assert!(
2234 text.contains("captured 12 min from 1 instance."),
2235 "upgrade prompt header missing in:\n{text}"
2236 );
2237 assert!(
2238 text.contains("continuous monitoring over 30 days evaluates more paths"),
2239 "upgrade prompt body missing in:\n{text}"
2240 );
2241 assert!(
2242 text.contains("fallow license activate --trial --email you@company.com"),
2243 "trial CTA command missing in:\n{text}"
2244 );
2245 }
2246
2247 #[test]
2248 fn health_runtime_coverage_long_capture_shows_neither_warning_nor_prompt() {
2249 let root = PathBuf::from("/project");
2250 let mut report = empty_report();
2251 report.runtime_coverage = Some(runtime_coverage_report_with_quality(Some(
2252 crate::health_types::RuntimeCoverageCaptureQuality {
2253 window_seconds: 7 * 24 * 3600, instances_observed: 4,
2255 lazy_parse_warning: false,
2256 untracked_ratio_percent: 3.1,
2257 },
2258 )));
2259 let text = plain(&build_health_human_lines(&report, &root));
2260 assert!(
2261 !text.contains("short capture"),
2262 "long capture should not emit short-capture warning:\n{text}"
2263 );
2264 assert!(
2265 !text.contains("start a trial"),
2266 "long capture should not emit trial CTA:\n{text}"
2267 );
2268 }
2269
2270 #[test]
2271 fn format_window_labels() {
2272 assert_eq!(super::format_window(30), "30 s");
2273 assert_eq!(super::format_window(60), "1 min");
2274 assert_eq!(super::format_window(720), "12 min");
2275 assert_eq!(super::format_window(3600 * 3), "3 h");
2276 assert_eq!(super::format_window(3600 * 24 * 3), "3 d");
2277 }
2278
2279 #[test]
2280 fn health_coverage_gaps_render_section() {
2281 use crate::health_types::*;
2282
2283 let root = PathBuf::from("/project");
2284 let mut report = empty_report();
2285 report.coverage_gaps = Some(CoverageGaps {
2286 summary: CoverageGapSummary {
2287 runtime_files: 1,
2288 covered_files: 0,
2289 file_coverage_pct: 0.0,
2290 untested_files: 1,
2291 untested_exports: 1,
2292 },
2293 files: vec![UntestedFileFinding::with_actions(
2294 UntestedFile {
2295 path: root.join("src/app.ts"),
2296 value_export_count: 2,
2297 },
2298 &root,
2299 )],
2300 exports: vec![UntestedExportFinding::with_actions(
2301 UntestedExport {
2302 path: root.join("src/app.ts"),
2303 export_name: "loader".into(),
2304 line: 12,
2305 col: 4,
2306 },
2307 &root,
2308 )],
2309 });
2310
2311 let text = plain(&build_health_human_lines(&report, &root));
2312 assert!(
2313 text.contains("Coverage gaps (1 untested file, 1 untested export, 0.0% file coverage)")
2314 );
2315 assert!(text.contains("src/app.ts"));
2316 assert!(text.contains("loader"));
2317 }
2318
2319 #[test]
2322 fn fmt_trend_val_percentage() {
2323 assert_eq!(fmt_trend_val(15.5, "%"), "15.5%");
2324 assert_eq!(fmt_trend_val(0.0, "%"), "0.0%");
2325 }
2326
2327 #[test]
2328 fn fmt_trend_val_integer_when_round() {
2329 assert_eq!(fmt_trend_val(72.0, ""), "72");
2330 assert_eq!(fmt_trend_val(5.0, "pts"), "5");
2331 }
2332
2333 #[test]
2334 fn fmt_trend_val_decimal_when_fractional() {
2335 assert_eq!(fmt_trend_val(4.7, ""), "4.7");
2336 assert_eq!(fmt_trend_val(1.3, "pts"), "1.3");
2337 }
2338
2339 #[test]
2340 fn fmt_trend_delta_percentage() {
2341 assert_eq!(fmt_trend_delta(2.5, "%"), "+2.5%");
2342 assert_eq!(fmt_trend_delta(-1.3, "%"), "-1.3%");
2343 }
2344
2345 #[test]
2346 fn fmt_trend_delta_integer_when_round() {
2347 assert_eq!(fmt_trend_delta(5.0, ""), "+5");
2348 assert_eq!(fmt_trend_delta(-3.0, "pts"), "-3");
2349 }
2350
2351 #[test]
2352 fn fmt_trend_delta_decimal_when_fractional() {
2353 assert_eq!(fmt_trend_delta(4.9, ""), "+4.9");
2354 assert_eq!(fmt_trend_delta(-0.7, "pts"), "-0.7");
2355 }
2356
2357 #[test]
2360 fn health_score_grade_a_display() {
2361 let root = PathBuf::from("/project");
2362 let mut report = empty_report();
2363 report.health_score = Some(crate::health_types::HealthScore {
2364 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2365 score: 92.0,
2366 grade: "A",
2367 penalties: crate::health_types::HealthScorePenalties {
2368 dead_files: Some(3.0),
2369 dead_exports: Some(2.0),
2370 complexity: 1.5,
2371 p90_complexity: 1.5,
2372 maintainability: Some(0.0),
2373 hotspots: Some(0.0),
2374 unused_deps: Some(0.0),
2375 circular_deps: Some(0.0),
2376 unit_size: None,
2377 coupling: None,
2378 duplication: None,
2379 },
2380 });
2381 let lines = build_health_human_lines(&report, &root);
2382 let text = plain(&lines);
2383 assert!(text.contains("Health score:"));
2384 assert!(text.contains("92 A"));
2385 assert!(text.contains("dead files -3.0"));
2386 assert!(text.contains("dead exports -2.0"));
2387 assert!(text.contains("complexity -1.5"));
2388 assert!(text.contains("p90 -1.5"));
2389 }
2390
2391 #[test]
2392 fn health_score_grade_b_display() {
2393 let root = PathBuf::from("/project");
2394 let mut report = empty_report();
2395 report.health_score = Some(crate::health_types::HealthScore {
2396 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2397 score: 76.0,
2398 grade: "B",
2399 penalties: crate::health_types::HealthScorePenalties {
2400 dead_files: Some(5.0),
2401 dead_exports: Some(6.0),
2402 complexity: 3.0,
2403 p90_complexity: 2.0,
2404 maintainability: Some(4.0),
2405 hotspots: Some(2.0),
2406 unused_deps: Some(1.0),
2407 circular_deps: Some(1.0),
2408 unit_size: None,
2409 coupling: None,
2410 duplication: None,
2411 },
2412 });
2413 let lines = build_health_human_lines(&report, &root);
2414 let text = plain(&lines);
2415 assert!(text.contains("76 B"));
2416 assert!(text.contains("dead exports -6.0"));
2418 assert!(text.contains("maintainability -4.0"));
2419 assert!(text.contains("hotspots -2.0"));
2420 assert!(text.contains("unused deps -1.0"));
2421 assert!(text.contains("circular deps -1.0"));
2422 }
2423
2424 #[test]
2425 fn health_score_grade_c_display() {
2426 let root = PathBuf::from("/project");
2427 let mut report = empty_report();
2428 report.health_score = Some(crate::health_types::HealthScore {
2429 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2430 score: 60.0,
2431 grade: "C",
2432 penalties: crate::health_types::HealthScorePenalties {
2433 dead_files: Some(10.0),
2434 dead_exports: Some(10.0),
2435 complexity: 10.0,
2436 p90_complexity: 5.0,
2437 maintainability: Some(5.0),
2438 hotspots: None,
2439 unused_deps: None,
2440 circular_deps: None,
2441 unit_size: None,
2442 coupling: None,
2443 duplication: None,
2444 },
2445 });
2446 let lines = build_health_human_lines(&report, &root);
2447 let text = plain(&lines);
2448 assert!(text.contains("60 C"));
2449 }
2450
2451 #[test]
2452 fn health_score_grade_f_display() {
2453 let root = PathBuf::from("/project");
2454 let mut report = empty_report();
2455 report.health_score = Some(crate::health_types::HealthScore {
2456 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2457 score: 30.0,
2458 grade: "F",
2459 penalties: crate::health_types::HealthScorePenalties {
2460 dead_files: Some(15.0),
2461 dead_exports: Some(15.0),
2462 complexity: 20.0,
2463 p90_complexity: 10.0,
2464 maintainability: Some(10.0),
2465 hotspots: None,
2466 unused_deps: None,
2467 circular_deps: None,
2468 unit_size: None,
2469 coupling: None,
2470 duplication: None,
2471 },
2472 });
2473 let lines = build_health_human_lines(&report, &root);
2474 let text = plain(&lines);
2475 assert!(text.contains("30 F"));
2476 }
2477
2478 #[test]
2479 fn health_score_na_components_shown() {
2480 let root = PathBuf::from("/project");
2481 let mut report = empty_report();
2482 report.health_score = Some(crate::health_types::HealthScore {
2483 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2484 score: 90.0,
2485 grade: "A",
2486 penalties: crate::health_types::HealthScorePenalties {
2487 dead_files: None,
2488 dead_exports: None,
2489 complexity: 0.0,
2490 p90_complexity: 0.0,
2491 maintainability: None,
2492 hotspots: None,
2493 unused_deps: None,
2494 circular_deps: None,
2495 unit_size: None,
2496 coupling: None,
2497 duplication: None,
2498 },
2499 });
2500 let lines = build_health_human_lines(&report, &root);
2501 let text = plain(&lines);
2502 assert!(text.contains("N/A: dead code, maintainability, hotspots"));
2503 assert!(text.contains("enable the corresponding analysis flags"));
2504 }
2505
2506 #[test]
2507 fn health_score_no_na_when_all_present() {
2508 let root = PathBuf::from("/project");
2509 let mut report = empty_report();
2510 report.health_score = Some(crate::health_types::HealthScore {
2511 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2512 score: 85.0,
2513 grade: "A",
2514 penalties: crate::health_types::HealthScorePenalties {
2515 dead_files: Some(0.0),
2516 dead_exports: Some(0.0),
2517 complexity: 0.0,
2518 p90_complexity: 0.0,
2519 maintainability: Some(0.0),
2520 hotspots: Some(0.0),
2521 unused_deps: Some(0.0),
2522 circular_deps: Some(0.0),
2523 unit_size: None,
2524 coupling: None,
2525 duplication: None,
2526 },
2527 });
2528 let lines = build_health_human_lines(&report, &root);
2529 let text = plain(&lines);
2530 assert!(!text.contains("N/A:"));
2531 }
2532
2533 #[test]
2534 fn health_score_zero_penalties_suppressed() {
2535 let root = PathBuf::from("/project");
2536 let mut report = empty_report();
2537 report.health_score = Some(crate::health_types::HealthScore {
2538 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2539 score: 100.0,
2540 grade: "A",
2541 penalties: crate::health_types::HealthScorePenalties {
2542 dead_files: Some(0.0),
2543 dead_exports: Some(0.0),
2544 complexity: 0.0,
2545 p90_complexity: 0.0,
2546 maintainability: Some(0.0),
2547 hotspots: Some(0.0),
2548 unused_deps: Some(0.0),
2549 circular_deps: Some(0.0),
2550 unit_size: None,
2551 coupling: None,
2552 duplication: None,
2553 },
2554 });
2555 let lines = build_health_human_lines(&report, &root);
2556 let text = plain(&lines);
2557 assert!(!text.contains("dead files"));
2559 assert!(!text.contains("complexity -"));
2560 }
2561
2562 #[test]
2565 fn health_trend_improving_display() {
2566 let root = PathBuf::from("/project");
2567 let mut report = empty_report();
2568 report.health_trend = Some(crate::health_types::HealthTrend {
2569 compared_to: crate::health_types::TrendPoint {
2570 timestamp: "2026-03-25T14:30:00Z".into(),
2571 git_sha: Some("abc1234".into()),
2572 score: Some(72.0),
2573 grade: Some("B".into()),
2574 coverage_model: None,
2575 snapshot_schema_version: None,
2576 },
2577 metrics: vec![
2578 crate::health_types::TrendMetric {
2579 name: "score",
2580 label: "Health Score",
2581 previous: 72.0,
2582 current: 85.0,
2583 delta: 13.0,
2584 direction: crate::health_types::TrendDirection::Improving,
2585 unit: "",
2586 previous_count: None,
2587 current_count: None,
2588 },
2589 crate::health_types::TrendMetric {
2590 name: "dead_file_pct",
2591 label: "Dead Files",
2592 previous: 10.0,
2593 current: 5.0,
2594 delta: -5.0,
2595 direction: crate::health_types::TrendDirection::Improving,
2596 unit: "%",
2597 previous_count: None,
2598 current_count: None,
2599 },
2600 ],
2601 snapshots_loaded: 2,
2602 overall_direction: crate::health_types::TrendDirection::Improving,
2603 });
2604 let lines = build_health_human_lines(&report, &root);
2605 let text = plain(&lines);
2606 assert!(text.contains("Trend:"));
2607 assert!(text.contains("improving"));
2608 assert!(text.contains("vs 2026-03-25"));
2609 assert!(text.contains("abc1234"));
2610 assert!(text.contains("Health Score"));
2611 assert!(text.contains("+13"));
2612 assert!(text.contains("Dead Files"));
2613 assert!(text.contains("-5.0%"));
2614 }
2615
2616 #[test]
2617 fn health_trend_declining_display() {
2618 let root = PathBuf::from("/project");
2619 let mut report = empty_report();
2620 report.health_trend = Some(crate::health_types::HealthTrend {
2621 compared_to: crate::health_types::TrendPoint {
2622 timestamp: "2026-03-20T10:00:00Z".into(),
2623 git_sha: None,
2624 score: None,
2625 grade: None,
2626 coverage_model: None,
2627 snapshot_schema_version: None,
2628 },
2629 metrics: vec![crate::health_types::TrendMetric {
2630 name: "unused_deps",
2631 label: "Unused Deps",
2632 previous: 5.0,
2633 current: 10.0,
2634 delta: 5.0,
2635 direction: crate::health_types::TrendDirection::Declining,
2636 unit: "",
2637 previous_count: None,
2638 current_count: None,
2639 }],
2640 snapshots_loaded: 1,
2641 overall_direction: crate::health_types::TrendDirection::Declining,
2642 });
2643 let lines = build_health_human_lines(&report, &root);
2644 let text = plain(&lines);
2645 assert!(text.contains("declining"));
2646 assert!(text.contains("Unused Deps"));
2647 }
2648
2649 #[test]
2650 fn health_trend_all_stable_collapsed() {
2651 let root = PathBuf::from("/project");
2652 let mut report = empty_report();
2653 report.health_trend = Some(crate::health_types::HealthTrend {
2654 compared_to: crate::health_types::TrendPoint {
2655 timestamp: "2026-03-25T14:30:00Z".into(),
2656 git_sha: Some("def5678".into()),
2657 score: Some(80.0),
2658 grade: Some("B".into()),
2659 coverage_model: None,
2660 snapshot_schema_version: None,
2661 },
2662 metrics: vec![
2663 crate::health_types::TrendMetric {
2664 name: "score",
2665 label: "Health Score",
2666 previous: 80.0,
2667 current: 80.0,
2668 delta: 0.0,
2669 direction: crate::health_types::TrendDirection::Stable,
2670 unit: "",
2671 previous_count: None,
2672 current_count: None,
2673 },
2674 crate::health_types::TrendMetric {
2675 name: "avg_cyclomatic",
2676 label: "Avg Cyclomatic",
2677 previous: 2.0,
2678 current: 2.0,
2679 delta: 0.0,
2680 direction: crate::health_types::TrendDirection::Stable,
2681 unit: "",
2682 previous_count: None,
2683 current_count: None,
2684 },
2685 ],
2686 snapshots_loaded: 3,
2687 overall_direction: crate::health_types::TrendDirection::Stable,
2688 });
2689 let lines = build_health_human_lines(&report, &root);
2690 let text = plain(&lines);
2691 assert!(text.contains("stable"));
2692 assert!(text.contains("All 2 metrics unchanged"));
2693 assert!(!text.contains("Health Score"));
2695 }
2696
2697 #[test]
2698 fn health_trend_without_sha() {
2699 let root = PathBuf::from("/project");
2700 let mut report = empty_report();
2701 report.health_trend = Some(crate::health_types::HealthTrend {
2702 compared_to: crate::health_types::TrendPoint {
2703 timestamp: "2026-03-20T10:00:00Z".into(),
2704 git_sha: None,
2705 score: None,
2706 grade: None,
2707 coverage_model: None,
2708 snapshot_schema_version: None,
2709 },
2710 metrics: vec![crate::health_types::TrendMetric {
2711 name: "score",
2712 label: "Health Score",
2713 previous: 80.0,
2714 current: 82.0,
2715 delta: 2.0,
2716 direction: crate::health_types::TrendDirection::Improving,
2717 unit: "",
2718 previous_count: None,
2719 current_count: None,
2720 }],
2721 snapshots_loaded: 1,
2722 overall_direction: crate::health_types::TrendDirection::Improving,
2723 });
2724 let lines = build_health_human_lines(&report, &root);
2725 let text = plain(&lines);
2726 assert!(text.contains("vs 2026-03-20"));
2728 assert!(!text.contains("\u{00b7}"));
2729 }
2730
2731 #[test]
2734 fn vital_signs_shown_without_trend() {
2735 let root = PathBuf::from("/project");
2736 let mut report = empty_report();
2737 report.vital_signs = Some(crate::health_types::VitalSigns {
2738 dead_file_pct: Some(3.2),
2739 dead_export_pct: Some(8.1),
2740 avg_cyclomatic: 4.7,
2741 p90_cyclomatic: 12,
2742 duplication_pct: None,
2743 hotspot_count: Some(2),
2744 maintainability_avg: Some(72.4),
2745 unused_dep_count: Some(3),
2746 circular_dep_count: Some(1),
2747 counts: None,
2748 unit_size_profile: None,
2749 unit_interfacing_profile: None,
2750 p95_fan_in: None,
2751 coupling_high_pct: None,
2752 total_loc: 42_381,
2753 ..Default::default()
2754 });
2755 let lines = build_health_human_lines(&report, &root);
2756 let text = plain(&lines);
2757 assert!(text.contains("42,381 LOC"));
2758 assert!(text.contains("dead files 3.2%"));
2759 assert!(text.contains("dead exports 8.1%"));
2760 assert!(text.contains("avg cyclomatic 4.7"));
2761 assert!(text.contains("p90 cyclomatic 12"));
2762 assert!(text.contains("maintainability 72.4"));
2763 assert!(text.contains("2 churn hotspots"));
2764 assert!(text.contains("3 unused deps"));
2765 assert!(text.contains("1 circular dep"));
2766 }
2767
2768 #[test]
2769 fn vital_signs_suppressed_when_trend_active() {
2770 let root = PathBuf::from("/project");
2771 let mut report = empty_report();
2772 report.vital_signs = Some(crate::health_types::VitalSigns {
2773 dead_file_pct: Some(3.2),
2774 dead_export_pct: Some(8.1),
2775 avg_cyclomatic: 4.7,
2776 p90_cyclomatic: 12,
2777 duplication_pct: None,
2778 hotspot_count: Some(2),
2779 maintainability_avg: Some(72.4),
2780 unused_dep_count: None,
2781 circular_dep_count: None,
2782 counts: None,
2783 unit_size_profile: None,
2784 unit_interfacing_profile: None,
2785 p95_fan_in: None,
2786 coupling_high_pct: None,
2787 total_loc: 0,
2788 ..Default::default()
2789 });
2790 report.health_trend = Some(crate::health_types::HealthTrend {
2791 compared_to: crate::health_types::TrendPoint {
2792 timestamp: "2026-03-25T14:30:00Z".into(),
2793 git_sha: None,
2794 score: None,
2795 grade: None,
2796 coverage_model: None,
2797 snapshot_schema_version: None,
2798 },
2799 metrics: vec![],
2800 snapshots_loaded: 1,
2801 overall_direction: crate::health_types::TrendDirection::Stable,
2802 });
2803 let lines = build_health_human_lines(&report, &root);
2804 let text = plain(&lines);
2805 assert!(!text.contains("dead files"));
2807 assert!(!text.contains("avg cyclomatic"));
2808 }
2809
2810 #[test]
2811 fn vital_signs_optional_fields_omitted_when_none() {
2812 let root = PathBuf::from("/project");
2813 let mut report = empty_report();
2814 report.vital_signs = Some(crate::health_types::VitalSigns {
2815 dead_file_pct: None,
2816 dead_export_pct: None,
2817 avg_cyclomatic: 2.0,
2818 p90_cyclomatic: 5,
2819 duplication_pct: None,
2820 hotspot_count: None,
2821 maintainability_avg: None,
2822 unused_dep_count: None,
2823 circular_dep_count: None,
2824 counts: None,
2825 unit_size_profile: None,
2826 unit_interfacing_profile: None,
2827 p95_fan_in: None,
2828 coupling_high_pct: None,
2829 total_loc: 0,
2830 ..Default::default()
2831 });
2832 let lines = build_health_human_lines(&report, &root);
2833 let text = plain(&lines);
2834 assert!(!text.contains("dead files"));
2835 assert!(!text.contains("dead exports"));
2836 assert!(!text.contains("maintainability "));
2837 assert!(!text.contains("hotspot"));
2838 assert!(text.contains("avg cyclomatic 2.0"));
2839 assert!(text.contains("p90 cyclomatic 5"));
2840 }
2841
2842 #[test]
2843 fn vital_signs_zero_counts_suppressed() {
2844 let root = PathBuf::from("/project");
2845 let mut report = empty_report();
2846 report.vital_signs = Some(crate::health_types::VitalSigns {
2847 dead_file_pct: None,
2848 dead_export_pct: None,
2849 avg_cyclomatic: 1.0,
2850 p90_cyclomatic: 2,
2851 duplication_pct: None,
2852 hotspot_count: None,
2853 maintainability_avg: None,
2854 unused_dep_count: Some(0),
2855 circular_dep_count: Some(0),
2856 counts: None,
2857 unit_size_profile: None,
2858 unit_interfacing_profile: None,
2859 p95_fan_in: None,
2860 coupling_high_pct: None,
2861 total_loc: 0,
2862 ..Default::default()
2863 });
2864 let lines = build_health_human_lines(&report, &root);
2865 let text = plain(&lines);
2866 assert!(!text.contains("unused dep"));
2868 assert!(!text.contains("circular dep"));
2869 }
2870
2871 #[test]
2872 fn vital_signs_plural_vs_singular() {
2873 let root = PathBuf::from("/project");
2874 let mut report = empty_report();
2875 report.vital_signs = Some(crate::health_types::VitalSigns {
2876 dead_file_pct: None,
2877 dead_export_pct: None,
2878 avg_cyclomatic: 1.0,
2879 p90_cyclomatic: 2,
2880 duplication_pct: None,
2881 hotspot_count: Some(1),
2882 maintainability_avg: None,
2883 unused_dep_count: Some(1),
2884 circular_dep_count: Some(2),
2885 counts: None,
2886 unit_size_profile: None,
2887 unit_interfacing_profile: None,
2888 p95_fan_in: None,
2889 coupling_high_pct: None,
2890 total_loc: 0,
2891 ..Default::default()
2892 });
2893 let lines = build_health_human_lines(&report, &root);
2894 let text = plain(&lines);
2895 assert!(text.contains("1 churn hotspot"));
2896 assert!(!text.contains("1 churn hotspots"));
2897 assert!(text.contains("1 unused dep"));
2898 assert!(!text.contains("1 unused deps"));
2899 assert!(text.contains("2 circular deps"));
2900 }
2901
2902 #[test]
2905 fn file_scores_single_entry() {
2906 let root = PathBuf::from("/project");
2907 let mut report = empty_report();
2908 report.file_scores = vec![crate::health_types::FileHealthScore {
2909 path: root.join("src/utils.ts"),
2910 fan_in: 5,
2911 fan_out: 3,
2912 dead_code_ratio: 0.15,
2913 complexity_density: 0.42,
2914 maintainability_index: 85.3,
2915 total_cyclomatic: 12,
2916 total_cognitive: 8,
2917 function_count: 4,
2918 lines: 200,
2919 crap_max: 0.0,
2920 crap_above_threshold: 0,
2921 }];
2922 let lines = build_health_human_lines(&report, &root);
2923 let text = plain(&lines);
2924 assert!(text.contains("File health scores (1 files)"));
2925 assert!(text.contains("85.3"));
2926 assert!(text.contains("src/utils.ts"));
2927 assert!(text.contains("200 LOC"));
2928 assert!(text.contains("5 fan-in"));
2929 assert!(text.contains("3 fan-out"));
2930 assert!(text.contains("15% dead"));
2931 assert!(text.contains("0.42 density"));
2932 }
2933
2934 #[test]
2935 fn file_scores_mi_color_thresholds() {
2936 let root = PathBuf::from("/project");
2937 let mut report = empty_report();
2938 report.file_scores = vec![
2939 crate::health_types::FileHealthScore {
2940 path: root.join("src/good.ts"),
2941 fan_in: 1,
2942 fan_out: 1,
2943 dead_code_ratio: 0.0,
2944 complexity_density: 0.1,
2945 maintainability_index: 90.0, total_cyclomatic: 2,
2947 total_cognitive: 1,
2948 function_count: 1,
2949 lines: 50,
2950 crap_max: 0.0,
2951 crap_above_threshold: 0,
2952 },
2953 crate::health_types::FileHealthScore {
2954 path: root.join("src/okay.ts"),
2955 fan_in: 2,
2956 fan_out: 3,
2957 dead_code_ratio: 0.1,
2958 complexity_density: 0.3,
2959 maintainability_index: 65.0, total_cyclomatic: 8,
2961 total_cognitive: 5,
2962 function_count: 3,
2963 lines: 100,
2964 crap_max: 0.0,
2965 crap_above_threshold: 0,
2966 },
2967 crate::health_types::FileHealthScore {
2968 path: root.join("src/bad.ts"),
2969 fan_in: 8,
2970 fan_out: 12,
2971 dead_code_ratio: 0.5,
2972 complexity_density: 0.9,
2973 maintainability_index: 30.0, total_cyclomatic: 40,
2975 total_cognitive: 30,
2976 function_count: 10,
2977 lines: 500,
2978 crap_max: 0.0,
2979 crap_above_threshold: 0,
2980 },
2981 ];
2982 let lines = build_health_human_lines(&report, &root);
2983 let text = plain(&lines);
2984 assert!(text.contains("File health scores (3 files)"));
2985 assert!(text.contains("90.0"));
2986 assert!(text.contains("65.0"));
2987 assert!(text.contains("30.0"));
2988 }
2989
2990 #[test]
2991 fn file_scores_truncation_above_max_flat_items() {
2992 let root = PathBuf::from("/project");
2993 let mut report = empty_report();
2994 for i in 0..12 {
2996 report
2997 .file_scores
2998 .push(crate::health_types::FileHealthScore {
2999 path: root.join(format!("src/file{i}.ts")),
3000 fan_in: 1,
3001 fan_out: 1,
3002 dead_code_ratio: 0.0,
3003 complexity_density: 0.1,
3004 maintainability_index: 80.0,
3005 total_cyclomatic: 2,
3006 total_cognitive: 1,
3007 function_count: 1,
3008 lines: 50,
3009 crap_max: 0.0,
3010 crap_above_threshold: 0,
3011 });
3012 }
3013 let lines = build_health_human_lines(&report, &root);
3014 let text = plain(&lines);
3015 assert!(text.contains("File health scores (12 files)"));
3016 assert!(text.contains("... and 2 more files"));
3017 assert!(text.contains("file0.ts"));
3019 assert!(text.contains("file9.ts"));
3020 assert!(!text.contains("file10.ts"));
3022 assert!(!text.contains("file11.ts"));
3023 }
3024
3025 #[test]
3026 fn file_scores_docs_link() {
3027 let root = PathBuf::from("/project");
3028 let mut report = empty_report();
3029 report.file_scores = vec![crate::health_types::FileHealthScore {
3030 path: root.join("src/a.ts"),
3031 fan_in: 1,
3032 fan_out: 1,
3033 dead_code_ratio: 0.0,
3034 complexity_density: 0.1,
3035 maintainability_index: 80.0,
3036 total_cyclomatic: 2,
3037 total_cognitive: 1,
3038 function_count: 1,
3039 lines: 50,
3040 crap_max: 0.0,
3041 crap_above_threshold: 0,
3042 }];
3043 let lines = build_health_human_lines(&report, &root);
3044 let text = plain(&lines);
3045 assert!(text.contains("docs.fallow.tools/explanations/health#file-health-scores"));
3046 }
3047
3048 #[test]
3051 fn hotspots_accelerating_trend() {
3052 let root = PathBuf::from("/project");
3053 let mut report = empty_report();
3054 report.hotspots = vec![
3055 crate::health_types::HotspotEntry {
3056 path: root.join("src/core.ts"),
3057 score: 75.0,
3058 commits: 42,
3059 weighted_commits: 30.0,
3060 lines_added: 500,
3061 lines_deleted: 200,
3062 complexity_density: 0.85,
3063 fan_in: 10,
3064 trend: fallow_core::churn::ChurnTrend::Accelerating,
3065 ownership: None,
3066 is_test_path: false,
3067 }
3068 .into(),
3069 ];
3070 let lines = build_health_human_lines(&report, &root);
3071 let text = plain(&lines);
3072 assert!(text.contains("Hotspots (1 files)"));
3073 assert!(text.contains("75.0"));
3074 assert!(text.contains("src/core.ts"));
3075 assert!(text.contains("42 commits"));
3076 assert!(text.contains("700 churn"));
3077 assert!(text.contains("0.85 density"));
3078 assert!(text.contains("10 fan-in"));
3079 assert!(text.contains("accelerating"));
3080 }
3081
3082 #[test]
3083 fn hotspots_cooling_trend() {
3084 let root = PathBuf::from("/project");
3085 let mut report = empty_report();
3086 report.hotspots = vec![
3087 crate::health_types::HotspotEntry {
3088 path: root.join("src/old.ts"),
3089 score: 20.0,
3090 commits: 5,
3091 weighted_commits: 2.0,
3092 lines_added: 50,
3093 lines_deleted: 30,
3094 complexity_density: 0.3,
3095 fan_in: 2,
3096 trend: fallow_core::churn::ChurnTrend::Cooling,
3097 ownership: None,
3098 is_test_path: false,
3099 }
3100 .into(),
3101 ];
3102 let lines = build_health_human_lines(&report, &root);
3103 let text = plain(&lines);
3104 assert!(text.contains("20.0"));
3105 assert!(text.contains("cooling"));
3106 }
3107
3108 #[test]
3109 fn hotspots_stable_trend() {
3110 let root = PathBuf::from("/project");
3111 let mut report = empty_report();
3112 report.hotspots = vec![
3113 crate::health_types::HotspotEntry {
3114 path: root.join("src/mid.ts"),
3115 score: 45.0,
3116 commits: 15,
3117 weighted_commits: 10.0,
3118 lines_added: 200,
3119 lines_deleted: 100,
3120 complexity_density: 0.5,
3121 fan_in: 5,
3122 trend: fallow_core::churn::ChurnTrend::Stable,
3123 ownership: None,
3124 is_test_path: false,
3125 }
3126 .into(),
3127 ];
3128 let lines = build_health_human_lines(&report, &root);
3129 let text = plain(&lines);
3130 assert!(text.contains("45.0"));
3131 assert!(text.contains("stable"));
3132 }
3133
3134 #[test]
3135 fn hotspots_with_summary_and_since() {
3136 let root = PathBuf::from("/project");
3137 let mut report = empty_report();
3138 report.hotspots = vec![
3139 crate::health_types::HotspotEntry {
3140 path: root.join("src/a.ts"),
3141 score: 50.0,
3142 commits: 10,
3143 weighted_commits: 8.0,
3144 lines_added: 100,
3145 lines_deleted: 50,
3146 complexity_density: 0.4,
3147 fan_in: 3,
3148 trend: fallow_core::churn::ChurnTrend::Stable,
3149 ownership: None,
3150 is_test_path: false,
3151 }
3152 .into(),
3153 ];
3154 report.hotspot_summary = Some(crate::health_types::HotspotSummary {
3155 since: "6 months".to_string(),
3156 min_commits: 3,
3157 files_analyzed: 50,
3158 files_excluded: 20,
3159 shallow_clone: false,
3160 });
3161 let lines = build_health_human_lines(&report, &root);
3162 let text = plain(&lines);
3163 assert!(text.contains("Hotspots (1 files, since 6 months)"));
3164 assert!(text.contains("20 files excluded (< 3 commits)"));
3165 }
3166
3167 #[test]
3168 fn hotspots_summary_no_exclusions() {
3169 let root = PathBuf::from("/project");
3170 let mut report = empty_report();
3171 report.hotspots = vec![
3172 crate::health_types::HotspotEntry {
3173 path: root.join("src/a.ts"),
3174 score: 50.0,
3175 commits: 10,
3176 weighted_commits: 8.0,
3177 lines_added: 100,
3178 lines_deleted: 50,
3179 complexity_density: 0.4,
3180 fan_in: 3,
3181 trend: fallow_core::churn::ChurnTrend::Stable,
3182 ownership: None,
3183 is_test_path: false,
3184 }
3185 .into(),
3186 ];
3187 report.hotspot_summary = Some(crate::health_types::HotspotSummary {
3188 since: "3 months".to_string(),
3189 min_commits: 2,
3190 files_analyzed: 50,
3191 files_excluded: 0,
3192 shallow_clone: false,
3193 });
3194 let lines = build_health_human_lines(&report, &root);
3195 let text = plain(&lines);
3196 assert!(!text.contains("files excluded"));
3198 }
3199
3200 #[test]
3201 fn hotspots_docs_link() {
3202 let root = PathBuf::from("/project");
3203 let mut report = empty_report();
3204 report.hotspots = vec![
3205 crate::health_types::HotspotEntry {
3206 path: root.join("src/a.ts"),
3207 score: 50.0,
3208 commits: 10,
3209 weighted_commits: 8.0,
3210 lines_added: 100,
3211 lines_deleted: 50,
3212 complexity_density: 0.4,
3213 fan_in: 3,
3214 trend: fallow_core::churn::ChurnTrend::Stable,
3215 ownership: None,
3216 is_test_path: false,
3217 }
3218 .into(),
3219 ];
3220 let lines = build_health_human_lines(&report, &root);
3221 let text = plain(&lines);
3222 assert!(text.contains("docs.fallow.tools/explanations/health#hotspot-metrics"));
3223 }
3224
3225 #[test]
3228 fn refactoring_targets_single_low_effort() {
3229 let root = PathBuf::from("/project");
3230 let mut report = empty_report();
3231 report.targets = vec![
3232 crate::health_types::RefactoringTarget {
3233 path: root.join("src/legacy.ts"),
3234 priority: 65.0,
3235 efficiency: 65.0,
3236 recommendation: "Extract complex logic into helper functions".to_string(),
3237 category: crate::health_types::RecommendationCategory::ExtractComplexFunctions,
3238 effort: crate::health_types::EffortEstimate::Low,
3239 confidence: crate::health_types::Confidence::High,
3240 factors: vec![],
3241 evidence: None,
3242 }
3243 .into(),
3244 ];
3245 let lines = build_health_human_lines(&report, &root);
3246 let text = plain(&lines);
3247 assert!(text.contains("Refactoring targets (1)"));
3248 assert!(text.contains("1 low effort"));
3249 assert!(text.contains("65.0"));
3250 assert!(text.contains("pri:65.0"));
3251 assert!(text.contains("src/legacy.ts"));
3252 assert!(text.contains("complexity"));
3253 assert!(text.contains("effort:low"));
3254 assert!(text.contains("confidence:high"));
3255 assert!(text.contains("Extract complex logic into helper functions"));
3256 }
3257
3258 #[test]
3259 fn refactoring_targets_mixed_effort() {
3260 let root = PathBuf::from("/project");
3261 let mut report = empty_report();
3262 report.targets = vec![
3263 crate::health_types::RefactoringTarget {
3264 path: root.join("src/a.ts"),
3265 priority: 80.0,
3266 efficiency: 80.0,
3267 recommendation: "Remove dead exports".to_string(),
3268 category: crate::health_types::RecommendationCategory::RemoveDeadCode,
3269 effort: crate::health_types::EffortEstimate::Low,
3270 confidence: crate::health_types::Confidence::High,
3271 factors: vec![],
3272 evidence: None,
3273 }
3274 .into(),
3275 crate::health_types::RefactoringTarget {
3276 path: root.join("src/b.ts"),
3277 priority: 60.0,
3278 efficiency: 30.0,
3279 recommendation: "Split into smaller modules".to_string(),
3280 category: crate::health_types::RecommendationCategory::SplitHighImpact,
3281 effort: crate::health_types::EffortEstimate::Medium,
3282 confidence: crate::health_types::Confidence::Medium,
3283 factors: vec![],
3284 evidence: None,
3285 }
3286 .into(),
3287 crate::health_types::RefactoringTarget {
3288 path: root.join("src/c.ts"),
3289 priority: 50.0,
3290 efficiency: 16.7,
3291 recommendation: "Break circular dependency".to_string(),
3292 category: crate::health_types::RecommendationCategory::BreakCircularDependency,
3293 effort: crate::health_types::EffortEstimate::High,
3294 confidence: crate::health_types::Confidence::Low,
3295 factors: vec![],
3296 evidence: None,
3297 }
3298 .into(),
3299 ];
3300 let lines = build_health_human_lines(&report, &root);
3301 let text = plain(&lines);
3302 assert!(text.contains("Refactoring targets (3)"));
3303 assert!(text.contains("1 low effort"));
3304 assert!(text.contains("1 medium"));
3305 assert!(text.contains("1 high"));
3306 assert!(text.contains("effort:low"));
3307 assert!(text.contains("effort:medium"));
3308 assert!(text.contains("effort:high"));
3309 assert!(text.contains("confidence:high"));
3310 assert!(text.contains("confidence:medium"));
3311 assert!(text.contains("confidence:low"));
3312 }
3313
3314 #[test]
3315 fn refactoring_targets_truncation_above_max_flat_items() {
3316 let root = PathBuf::from("/project");
3317 let mut report = empty_report();
3318 for i in 0..12 {
3319 report.targets.push(
3320 crate::health_types::RefactoringTarget {
3321 path: root.join(format!("src/target{i}.ts")),
3322 priority: 50.0,
3323 efficiency: 25.0,
3324 recommendation: format!("Fix target {i}"),
3325 category: crate::health_types::RecommendationCategory::ExtractComplexFunctions,
3326 effort: crate::health_types::EffortEstimate::Medium,
3327 confidence: crate::health_types::Confidence::Medium,
3328 factors: vec![],
3329 evidence: None,
3330 }
3331 .into(),
3332 );
3333 }
3334 let lines = build_health_human_lines(&report, &root);
3335 let text = plain(&lines);
3336 assert!(text.contains("Refactoring targets (12)"));
3337 assert!(text.contains("... and 2 more targets"));
3338 assert!(text.contains("target0.ts"));
3339 assert!(text.contains("target9.ts"));
3340 assert!(!text.contains("target10.ts"));
3341 }
3342
3343 #[test]
3344 fn refactoring_targets_docs_link() {
3345 let root = PathBuf::from("/project");
3346 let mut report = empty_report();
3347 report.targets = vec![
3348 crate::health_types::RefactoringTarget {
3349 path: root.join("src/a.ts"),
3350 priority: 50.0,
3351 efficiency: 50.0,
3352 recommendation: "Fix it".to_string(),
3353 category: crate::health_types::RecommendationCategory::ExtractDependencies,
3354 effort: crate::health_types::EffortEstimate::Low,
3355 confidence: crate::health_types::Confidence::High,
3356 factors: vec![],
3357 evidence: None,
3358 }
3359 .into(),
3360 ];
3361 let lines = build_health_human_lines(&report, &root);
3362 let text = plain(&lines);
3363 assert!(text.contains("docs.fallow.tools/explanations/health#refactoring-targets"));
3364 }
3365
3366 #[test]
3367 fn refactoring_targets_all_categories() {
3368 let root = PathBuf::from("/project");
3369 let mut report = empty_report();
3370 let categories = [
3371 (
3372 crate::health_types::RecommendationCategory::UrgentChurnComplexity,
3373 "churn+complexity",
3374 ),
3375 (
3376 crate::health_types::RecommendationCategory::BreakCircularDependency,
3377 "circular dependency",
3378 ),
3379 (
3380 crate::health_types::RecommendationCategory::SplitHighImpact,
3381 "high impact",
3382 ),
3383 (
3384 crate::health_types::RecommendationCategory::RemoveDeadCode,
3385 "dead code",
3386 ),
3387 (
3388 crate::health_types::RecommendationCategory::ExtractComplexFunctions,
3389 "complexity",
3390 ),
3391 (
3392 crate::health_types::RecommendationCategory::ExtractDependencies,
3393 "coupling",
3394 ),
3395 (
3396 crate::health_types::RecommendationCategory::AddTestCoverage,
3397 "untested risk",
3398 ),
3399 ];
3400 for (i, (cat, _label)) in categories.iter().enumerate() {
3401 report.targets.push(
3402 crate::health_types::RefactoringTarget {
3403 path: root.join(format!("src/cat{i}.ts")),
3404 priority: 50.0,
3405 efficiency: 50.0,
3406 recommendation: format!("Fix cat{i}"),
3407 category: cat.clone(),
3408 effort: crate::health_types::EffortEstimate::Low,
3409 confidence: crate::health_types::Confidence::High,
3410 factors: vec![],
3411 evidence: None,
3412 }
3413 .into(),
3414 );
3415 }
3416 let lines = build_health_human_lines(&report, &root);
3417 let text = plain(&lines);
3418 for (_cat, label) in &categories {
3419 assert!(
3420 text.contains(label),
3421 "Expected category label '{label}' in output"
3422 );
3423 }
3424 }
3425
3426 #[test]
3427 fn refactoring_targets_efficiency_color_thresholds() {
3428 let root = PathBuf::from("/project");
3429 let mut report = empty_report();
3430 report.targets = vec![
3431 crate::health_types::RefactoringTarget {
3432 path: root.join("src/high.ts"),
3433 priority: 50.0,
3434 efficiency: 50.0, recommendation: "High eff".to_string(),
3436 category: crate::health_types::RecommendationCategory::RemoveDeadCode,
3437 effort: crate::health_types::EffortEstimate::Low,
3438 confidence: crate::health_types::Confidence::High,
3439 factors: vec![],
3440 evidence: None,
3441 }
3442 .into(),
3443 crate::health_types::RefactoringTarget {
3444 path: root.join("src/mid.ts"),
3445 priority: 50.0,
3446 efficiency: 25.0, recommendation: "Mid eff".to_string(),
3448 category: crate::health_types::RecommendationCategory::RemoveDeadCode,
3449 effort: crate::health_types::EffortEstimate::Medium,
3450 confidence: crate::health_types::Confidence::Medium,
3451 factors: vec![],
3452 evidence: None,
3453 }
3454 .into(),
3455 crate::health_types::RefactoringTarget {
3456 path: root.join("src/low.ts"),
3457 priority: 50.0,
3458 efficiency: 10.0, recommendation: "Low eff".to_string(),
3460 category: crate::health_types::RecommendationCategory::RemoveDeadCode,
3461 effort: crate::health_types::EffortEstimate::High,
3462 confidence: crate::health_types::Confidence::Low,
3463 factors: vec![],
3464 evidence: None,
3465 }
3466 .into(),
3467 ];
3468 let lines = build_health_human_lines(&report, &root);
3469 let text = plain(&lines);
3470 assert!(text.contains("50.0"));
3471 assert!(text.contains("25.0"));
3472 assert!(text.contains("10.0"));
3473 }
3474
3475 #[test]
3478 fn all_sections_combined() {
3479 let root = PathBuf::from("/project");
3480 let mut report = empty_report();
3481 report.summary.functions_above_threshold = 1;
3482 report.findings = vec![
3483 crate::health_types::ComplexityViolation {
3484 path: root.join("src/complex.ts"),
3485 name: "bigFn".to_string(),
3486 line: 10,
3487 col: 0,
3488 cyclomatic: 25,
3489 cognitive: 20,
3490 line_count: 80,
3491 param_count: 0,
3492 exceeded: crate::health_types::ExceededThreshold::Both,
3493 severity: crate::health_types::FindingSeverity::Moderate,
3494 crap: None,
3495 coverage_pct: None,
3496 coverage_tier: None,
3497 coverage_source: None,
3498 inherited_from: None,
3499 component_rollup: None,
3500 }
3501 .into(),
3502 ];
3503 report.health_score = Some(crate::health_types::HealthScore {
3504 formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
3505 score: 75.0,
3506 grade: "B",
3507 penalties: crate::health_types::HealthScorePenalties {
3508 dead_files: Some(5.0),
3509 dead_exports: Some(5.0),
3510 complexity: 5.0,
3511 p90_complexity: 2.0,
3512 maintainability: Some(3.0),
3513 hotspots: Some(2.0),
3514 unused_deps: Some(2.0),
3515 circular_deps: Some(1.0),
3516 unit_size: None,
3517 coupling: None,
3518 duplication: None,
3519 },
3520 });
3521 report.file_scores = vec![crate::health_types::FileHealthScore {
3522 path: root.join("src/complex.ts"),
3523 fan_in: 5,
3524 fan_out: 3,
3525 dead_code_ratio: 0.1,
3526 complexity_density: 0.5,
3527 maintainability_index: 60.0,
3528 total_cyclomatic: 15,
3529 total_cognitive: 10,
3530 function_count: 3,
3531 lines: 200,
3532 crap_max: 0.0,
3533 crap_above_threshold: 0,
3534 }];
3535 report.hotspots = vec![
3536 crate::health_types::HotspotEntry {
3537 path: root.join("src/complex.ts"),
3538 score: 65.0,
3539 commits: 20,
3540 weighted_commits: 15.0,
3541 lines_added: 300,
3542 lines_deleted: 100,
3543 complexity_density: 0.5,
3544 fan_in: 5,
3545 trend: fallow_core::churn::ChurnTrend::Accelerating,
3546 ownership: None,
3547 is_test_path: false,
3548 }
3549 .into(),
3550 ];
3551 report.targets = vec![
3552 crate::health_types::RefactoringTarget {
3553 path: root.join("src/complex.ts"),
3554 priority: 70.0,
3555 efficiency: 70.0,
3556 recommendation: "Extract complex functions".to_string(),
3557 category: crate::health_types::RecommendationCategory::ExtractComplexFunctions,
3558 effort: crate::health_types::EffortEstimate::Low,
3559 confidence: crate::health_types::Confidence::High,
3560 factors: vec![],
3561 evidence: None,
3562 }
3563 .into(),
3564 ];
3565 let lines = build_health_human_lines(&report, &root);
3566 let text = plain(&lines);
3567 assert!(text.contains("Health score:"));
3569 assert!(text.contains("High complexity functions"));
3570 assert!(text.contains("File health scores"));
3571 assert!(text.contains("Hotspots"));
3572 assert!(text.contains("Refactoring targets"));
3573 }
3574
3575 #[test]
3576 fn completely_empty_report_produces_no_lines() {
3577 let root = PathBuf::from("/project");
3578 let report = empty_report();
3579 let lines = build_health_human_lines(&report, &root);
3580 assert!(lines.is_empty());
3581 }
3582
3583 #[test]
3586 fn finding_only_cyclomatic_exceeds() {
3587 let root = PathBuf::from("/project");
3588 let mut report = empty_report();
3589 report.summary.functions_above_threshold = 1;
3590 report.findings = vec![
3591 crate::health_types::ComplexityViolation {
3592 path: root.join("src/a.ts"),
3593 name: "fn1".to_string(),
3594 line: 1,
3595 col: 0,
3596 cyclomatic: 25, cognitive: 10, line_count: 50,
3599 param_count: 0,
3600 exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
3601 severity: crate::health_types::FindingSeverity::Moderate,
3602 crap: None,
3603 coverage_pct: None,
3604 coverage_tier: None,
3605 coverage_source: None,
3606 inherited_from: None,
3607 component_rollup: None,
3608 }
3609 .into(),
3610 ];
3611 let lines = build_health_human_lines(&report, &root);
3612 let text = plain(&lines);
3613 assert!(text.contains("25 cyclomatic"));
3614 assert!(text.contains("10 cognitive"));
3615 }
3616
3617 #[test]
3618 fn finding_only_cognitive_exceeds() {
3619 let root = PathBuf::from("/project");
3620 let mut report = empty_report();
3621 report.summary.functions_above_threshold = 1;
3622 report.findings = vec![
3623 crate::health_types::ComplexityViolation {
3624 path: root.join("src/a.ts"),
3625 name: "fn1".to_string(),
3626 line: 1,
3627 col: 0,
3628 cyclomatic: 10, cognitive: 25, line_count: 50,
3631 param_count: 0,
3632 exceeded: crate::health_types::ExceededThreshold::Cognitive,
3633 severity: crate::health_types::FindingSeverity::High,
3634 crap: None,
3635 coverage_pct: None,
3636 coverage_tier: None,
3637 coverage_source: None,
3638 inherited_from: None,
3639 component_rollup: None,
3640 }
3641 .into(),
3642 ];
3643 let lines = build_health_human_lines(&report, &root);
3644 let text = plain(&lines);
3645 assert!(text.contains("10 cyclomatic"));
3646 assert!(text.contains("25 cognitive"));
3647 }
3648
3649 #[test]
3650 fn findings_across_multiple_files() {
3651 let root = PathBuf::from("/project");
3652 let mut report = empty_report();
3653 report.summary.functions_above_threshold = 2;
3654 report.findings = vec![
3655 crate::health_types::ComplexityViolation {
3656 path: root.join("src/a.ts"),
3657 name: "fn1".to_string(),
3658 line: 1,
3659 col: 0,
3660 cyclomatic: 25,
3661 cognitive: 20,
3662 line_count: 50,
3663 param_count: 0,
3664 exceeded: crate::health_types::ExceededThreshold::Both,
3665 severity: crate::health_types::FindingSeverity::Moderate,
3666 crap: None,
3667 coverage_pct: None,
3668 coverage_tier: None,
3669 coverage_source: None,
3670 inherited_from: None,
3671 component_rollup: None,
3672 }
3673 .into(),
3674 crate::health_types::ComplexityViolation {
3675 path: root.join("src/b.ts"),
3676 name: "fn2".to_string(),
3677 line: 5,
3678 col: 0,
3679 cyclomatic: 22,
3680 cognitive: 18,
3681 line_count: 40,
3682 param_count: 0,
3683 exceeded: crate::health_types::ExceededThreshold::Both,
3684 severity: crate::health_types::FindingSeverity::Moderate,
3685 crap: None,
3686 coverage_pct: None,
3687 coverage_tier: None,
3688 coverage_source: None,
3689 inherited_from: None,
3690 component_rollup: None,
3691 }
3692 .into(),
3693 ];
3694 let lines = build_health_human_lines(&report, &root);
3695 let text = plain(&lines);
3696 assert!(text.contains("src/a.ts"));
3698 assert!(text.contains("src/b.ts"));
3699 }
3700
3701 #[test]
3702 fn findings_docs_link() {
3703 let root = PathBuf::from("/project");
3704 let mut report = empty_report();
3705 report.summary.functions_above_threshold = 1;
3706 report.findings = vec![
3707 crate::health_types::ComplexityViolation {
3708 path: root.join("src/a.ts"),
3709 name: "fn1".to_string(),
3710 line: 1,
3711 col: 0,
3712 cyclomatic: 25,
3713 cognitive: 20,
3714 line_count: 50,
3715 param_count: 0,
3716 exceeded: crate::health_types::ExceededThreshold::Both,
3717 severity: crate::health_types::FindingSeverity::Moderate,
3718 crap: None,
3719 coverage_pct: None,
3720 coverage_tier: None,
3721 coverage_source: None,
3722 inherited_from: None,
3723 component_rollup: None,
3724 }
3725 .into(),
3726 ];
3727 let lines = build_health_human_lines(&report, &root);
3728 let text = plain(&lines);
3729 assert!(text.contains("docs.fallow.tools/explanations/health#complexity-metrics"));
3730 }
3731
3732 #[test]
3735 fn hotspot_score_high_medium_low() {
3736 let root = PathBuf::from("/project");
3737 let mut report = empty_report();
3738 report.hotspots = vec![
3739 crate::health_types::HotspotEntry {
3740 path: root.join("src/high.ts"),
3741 score: 80.0, commits: 30,
3743 weighted_commits: 25.0,
3744 lines_added: 400,
3745 lines_deleted: 200,
3746 complexity_density: 0.9,
3747 fan_in: 8,
3748 trend: fallow_core::churn::ChurnTrend::Accelerating,
3749 ownership: None,
3750 is_test_path: false,
3751 }
3752 .into(),
3753 crate::health_types::HotspotEntry {
3754 path: root.join("src/medium.ts"),
3755 score: 45.0, commits: 15,
3757 weighted_commits: 10.0,
3758 lines_added: 200,
3759 lines_deleted: 100,
3760 complexity_density: 0.5,
3761 fan_in: 4,
3762 trend: fallow_core::churn::ChurnTrend::Stable,
3763 ownership: None,
3764 is_test_path: false,
3765 }
3766 .into(),
3767 crate::health_types::HotspotEntry {
3768 path: root.join("src/low.ts"),
3769 score: 15.0, commits: 5,
3771 weighted_commits: 3.0,
3772 lines_added: 50,
3773 lines_deleted: 20,
3774 complexity_density: 0.2,
3775 fan_in: 1,
3776 trend: fallow_core::churn::ChurnTrend::Cooling,
3777 ownership: None,
3778 is_test_path: false,
3779 }
3780 .into(),
3781 ];
3782 let lines = build_health_human_lines(&report, &root);
3783 let text = plain(&lines);
3784 assert!(text.contains("80.0"));
3785 assert!(text.contains("45.0"));
3786 assert!(text.contains("15.0"));
3787 assert!(text.contains("Hotspots (3 files)"));
3788 }
3789
3790 #[test]
3793 fn rollup_breakdown_renders_workspace_relative_template_path() {
3794 let root = PathBuf::from("/project");
3798 let template =
3799 root.join("apps/admin/src/app/payments/payment-list/payment-list.component.html");
3800 let finding = crate::health_types::ComplexityViolation {
3801 path: root.join("apps/admin/src/app/payments/payment-list/payment-list.component.ts"),
3802 name: "<component>".to_string(),
3803 line: 1,
3804 col: 0,
3805 cyclomatic: 25,
3806 cognitive: 28,
3807 line_count: 0,
3808 param_count: 0,
3809 exceeded: crate::health_types::ExceededThreshold::Both,
3810 severity: crate::health_types::FindingSeverity::High,
3811 crap: None,
3812 coverage_pct: None,
3813 coverage_tier: None,
3814 coverage_source: None,
3815 inherited_from: None,
3816 component_rollup: Some(crate::health_types::ComponentRollup {
3817 component: "PaymentListComponent".to_string(),
3818 class_worst_function: "ngOnInit".to_string(),
3819 class_cyclomatic: 12,
3820 class_cognitive: 16,
3821 template_path: template,
3822 template_cyclomatic: 13,
3823 template_cognitive: 12,
3824 }),
3825 };
3826 let line = render_component_rollup_breakdown(&finding, &root)
3827 .expect("rollup payload should render a breakdown line");
3828 assert!(
3829 line.contains("apps/admin/src/app/payments/payment-list/payment-list.component.html"),
3830 "breakdown must include workspace-relative template path: {line}"
3831 );
3832 assert!(
3836 !line.contains(" payment-list.component.html"),
3837 "bare basename token must not be the rendered template: {line}"
3838 );
3839 }
3840
3841 #[test]
3842 fn inherited_from_renders_workspace_relative_owner_path() {
3843 let root = PathBuf::from("/project");
3848 let owner = root.join("apps/admin/src/app/auth/permissions/permissions.component.ts");
3849 let template_path =
3850 root.join("apps/admin/src/app/auth/permissions/permissions.component.html");
3851 let report = crate::health_types::HealthReport {
3852 findings: vec![
3853 crate::health_types::ComplexityViolation {
3854 path: template_path,
3855 name: "<template>".to_string(),
3856 line: 1,
3857 col: 0,
3858 cyclomatic: 12,
3859 cognitive: 14,
3860 line_count: 0,
3861 param_count: 0,
3862 exceeded: crate::health_types::ExceededThreshold::Both,
3863 severity: crate::health_types::FindingSeverity::High,
3864 crap: Some(45.0),
3865 coverage_pct: None,
3866 coverage_tier: Some(crate::health_types::CoverageTier::Partial),
3867 coverage_source: Some(
3868 crate::health_types::CoverageSource::EstimatedComponentInherited,
3869 ),
3870 inherited_from: Some(owner),
3871 component_rollup: None,
3872 }
3873 .into(),
3874 ],
3875 summary: crate::health_types::HealthSummary {
3876 files_analyzed: 1,
3877 functions_analyzed: 1,
3878 functions_above_threshold: 1,
3879 ..Default::default()
3880 },
3881 ..Default::default()
3882 };
3883 let lines = build_health_human_lines(&report, &root);
3884 let text = plain(&lines);
3885 assert!(
3886 text.contains(
3887 "(inherited from apps/admin/src/app/auth/permissions/permissions.component.ts)"
3888 ),
3889 "inherited-from suffix must use workspace-relative path: {text}"
3890 );
3891 assert!(
3893 !text.contains("(inherited from permissions.component.ts)"),
3894 "bare basename suffix must not be rendered: {text}"
3895 );
3896 }
3897}