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