Skip to main content

fallow_cli/report/human/
health.rs

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
12/// Docs base URL for health explanations.
13const 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/// Build human-readable output lines for health (complexity) findings.
119///
120/// Test-only convenience: production callers go through
121/// `print_health_human` directly so they can thread `explain` and
122/// `skip_score_and_trend` flags.
123#[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
314/// Format `seconds` as a human-readable window label like "12 min" or "6 h".
315///
316/// Used by both the terminal and markdown renderers so a multi-day window
317/// consistently reads as "N d" in both surfaces instead of diverging to
318/// "N h" in one of them.
319pub(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
335/// Render the "short window" warning when the sidecar flagged a lazy-parse risk.
336///
337/// Triggered by `CaptureQuality::lazy_parse_warning`, which the sidecar sets
338/// when the untracked-function ratio crosses its threshold. Matches the
339/// existing `note: ...` idiom used elsewhere in the health renderer for
340/// inline caveats on summary numbers; fully yellow, no prefix glyph.
341fn 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
372/// Render the quantified trial CTA at the end of a local-mode run.
373///
374/// Sales touchpoint per ADR 009 step 6b. Human-format only; never emitted
375/// from JSON / SARIF / CodeClimate / compact. Fires alongside the short-
376/// capture warning so long, clean captures do not see CTA spam on every run.
377fn 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
406// ── Section renderers ────
407
408pub 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    // Penalty breakdown: sorted by magnitude, top penalties highlighted
438    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    // Remove zero-valued penalties, then sort by magnitude (largest first)
470    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        // Highlight the top penalty; dim the rest
475        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    // Check for N/A components
494    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    // Hint for high duplication penalty
515    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
525/// Format a float for trend display: show as integer if it is one, otherwise 1dp.
526fn 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
536/// Format a delta for trend display: show with sign prefix.
537fn 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    // Section header with overall direction — the headline
555    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    // Warn if coverage model changed between snapshots
584    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    // Warn if snapshot schema version differs (new penalties may affect score)
603    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    // All-stable collapse: single dimmed line instead of N identical rows
617    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    // Metric rows — aligned columns, no arrow separator (avoids collision with direction arrow)
631    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    // Suppress when trend is active — the trend table already shows all metrics
660    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    // Show function size profile when approaching or exceeding the penalty threshold (5% very high)
735    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    // Show parameter profile only when it carries signal (any functions in high or very high bins)
747    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
814/// Append per-finding-kind suppression hints to the findings section footer.
815///
816/// External `.html` templates take a file-level HTML comment; inline
817/// `@Component` templates take a line-level TS comment placed directly above
818/// the decorator. `<component>` rollups suppress through the worst class
819/// method (the rollup anchors at that method's line). Generic function
820/// findings get the catch-all hint above a `>=3` noise threshold. Extracted
821/// from `render_findings` to keep that function under the SIG unit-size
822/// threshold.
823fn 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
876/// Render the breakdown line for a synthetic `<component>` rollup finding.
877///
878/// Returns `Some(line)` when the finding carries a `component_rollup` payload
879/// (the rollup's cyc/cog totals are `worst_class_function + template`, so this
880/// line names the pre-summation numbers + the worst-class-function identifier
881/// so readers can see why the component ranks high without re-deriving the
882/// link from the JSON payload), `None` otherwise. Extracted from
883/// `render_findings` to keep that function under the SIG unit-size threshold.
884///
885/// Renders `template_path` workspace-relative (issue #547) so Angular
886/// projects with many `*.component.html` files unambiguously identify the
887/// template fallow scored.
888fn 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        // Line 1: function name with severity badge (tag likely generated code)
961        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        // Line 2: metrics (indented, aligned like hotspots)
983        lines.push(format!(
984            "         {} cyclomatic  {} cognitive  {} lines",
985            cyc_colored,
986            cog_colored,
987            format!("{:>3}", finding.line_count).dimmed(),
988        ));
989        // Line 2b: component rollup breakdown for synthetic <component>
990        // findings.
991        if let Some(line) = render_component_rollup_breakdown(finding, root) {
992            lines.push(line);
993        }
994        // Line 3: CRAP score. Only set on findings that exceeded the CRAP
995        // threshold (merge_crap_findings guards insertion), so the score is
996        // always at/above threshold and always colored red+bold.
997        if let Some(crap) = finding.crap {
998            let crap_colored = format!("{crap:>5.1}").red().bold().to_string();
999            // Provenance suffix order: prefer the observed-coverage pct when
1000            // Istanbul matched; otherwise show the inherited-from owner when
1001            // the score was redirected from an Angular component .ts via
1002            // the inverse templateUrl edge; otherwise nothing. The two
1003            // states are mutually exclusive on the wire (Istanbul match
1004            // implies coverage_pct = Some, inherit implies coverage_pct =
1005            // None), so a single if/else chain captures the contract.
1006            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                // Workspace-relative so Angular projects with many
1014                // `app.component.ts` files unambiguously identify the
1015                // owning component (issue #547).
1016                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
1045/// Detect likely generated code based on function name patterns.
1046fn is_likely_generated(name: &str, cyclomatic: u16) -> bool {
1047    // AJV-style validators: validate0, validate10, validate123
1048    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    // Extremely high complexity with generic names suggests generated/bundled code
1055    if cyclomatic > 200 && (name == "module.exports" || name == "default" || name == "<anonymous>")
1056    {
1057        return true;
1058    }
1059    false
1060}
1061
1062/// Check if a refactoring recommendation references a likely-generated function name.
1063///
1064/// Recommendations from Rule 5 embed function names like `"Extract validate10 (cognitive: 350)"`.
1065/// This detects those patterns so the display can tag them.
1066fn recommendation_mentions_generated(recommendation: &str) -> bool {
1067    // Look for AJV-style validator names: "validate" followed immediately by digits
1068    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                // Ensure next char after digits is not alphanumeric (word boundary)
1078                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        // MI score: color-coded by quality
1113        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        // Path: dim directory, normal filename
1123        let (dir, filename) = split_dir_filename(&file_str);
1124
1125        // Line 1: MI score + path
1126        lines.push(format!("  {}    {}{}", mi_colored, dir.dimmed(), filename));
1127
1128        // Line 2: metrics (indented, dimmed) with optional CRAP risk
1129        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        // Blank line between entries
1157        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        // Group exports by file for barrel file collapsing
1250        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                // Barrel file: collapse into a single summary line
1273                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
1318/// Project-level ownership summary rendered above the hotspot list.
1319///
1320/// Answers the question "what is the organizational pattern across these
1321/// hotspots" so readers do not have to scan 10 nearly-identical rows to
1322/// notice the same two contributors own most of them. Returns `None` when
1323/// ownership data is absent (no `--ownership` flag) or when the signal
1324/// would be trivial (0 or 1 hotspot).
1325fn 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    // Count top-contributor frequency across hotspots to surface the
1342    // dominant authors organizationally. Top-3 only.
1343    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
1381/// Heuristic: does the contributor's display identifier appear to match the
1382/// declared CODEOWNERS owner? Collapses the human output when the same
1383/// person is referenced two different ways. Conservative — false negatives
1384/// are fine (we just render both labels), false positives would mislead.
1385fn 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    // Email mode: compare local-part to owner handle.
1391    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
1396/// Render a single line of ownership signals for the human hotspot view.
1397///
1398/// Format: `bus=N · top=@handle (P%) · owner=@team [drift] [unowned]`
1399/// where each segment is colored by severity. Designed to fit on one line
1400/// and stay scannable; full structured data is in the JSON output.
1401fn 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    // Conditional severity: red is reserved for the strongest signal so it
1408    // does not lose meaning when the majority of hotspots are bus=1. The
1409    // single-author-100%-share case and the bus=1+accelerating case keep
1410    // the red/bold marker; the common bus=1 case drops to dimmed, which is
1411    // still present and readable under NO_COLOR but no longer shouts.
1412    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    // Collapse `top=...` and `owner=...` into a single `owned by ...` segment
1432    // when the declared CODEOWNERS owner agrees with the recorded top
1433    // contributor (handle prefix or substring match). Avoids the "two names
1434    // for the same person" visual confusion the panel flagged.
1435    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    // Project-level ownership summary. Surfaces the organizational pattern
1496    // ("9/10 hotspots have bus=1") above the per-file list so tech leads
1497    // see the headline, not just the wall of red markers.
1498    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        // Score: color-coded by severity
1507        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        // Trend: symbol + color
1517        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        // Path: dim directory, normal filename
1530        let (dir, filename) = split_dir_filename(&file_str);
1531
1532        // Line 1: score + trend symbol + path + optional [test] tag.
1533        // The tag signals "fallow saw this is a test file and kept it
1534        // intentionally" so readers don't dismiss the tool as noisy.
1535        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        // Line 2: metrics (indented, dimmed) + trend label
1554        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        // Line 3 (optional): one-line ownership summary. Kept short by
1564        // intent, full structured detail is in the JSON output.
1565        if let Some(ownership) = &entry.ownership {
1566            lines.push(format!(
1567                "         {}",
1568                render_ownership_line(ownership, entry.trend)
1569            ));
1570        }
1571
1572        // Blank line between entries
1573        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    // When ownership is on but no CODEOWNERS file was discovered (every
1592    // hotspot has `unowned == None`), surface a one-line hint so users
1593    // understand why `owner=` and the `unowned` marker are absent.
1594    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    // Effort summary: "3 low effort · 5 medium effort · 2 high effort"
1632    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        // Efficiency score (sort key): color-coded by quick-win value
1669        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        // Path: dim directory, normal filename
1679        let (dir, filename) = split_dir_filename(&file_str);
1680
1681        // Line 1: efficiency (sort key) + priority (secondary) + path
1682        lines.push(format!(
1683            "  {}  {}    {}{}",
1684            eff_colored,
1685            format!("pri:{:.1}", target.priority).dimmed(),
1686            dir.dimmed(),
1687            filename,
1688        ));
1689
1690        // Line 2: category (yellow) + effort:label (colored) + confidence:label + recommendation (dimmed)
1691        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        // Blank line between entries
1719        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
1742/// Print a concise health summary showing only aggregate statistics.
1743pub(in crate::report) fn print_health_summary(
1744    report: &crate::health_types::HealthReport,
1745    elapsed: Duration,
1746    quiet: bool,
1747    heading: bool,
1748) {
1749    let s = &report.summary;
1750
1751    if heading {
1752        println!("{}", "Health Summary".bold());
1753        println!();
1754    }
1755    println!("  {:>6}  Functions analyzed", s.functions_analyzed);
1756    println!("  {:>6}  Above threshold", s.functions_above_threshold);
1757    if let Some(mi) = s.average_maintainability {
1758        let label = if mi >= 85.0 {
1759            "good"
1760        } else if mi >= 65.0 {
1761            "moderate"
1762        } else {
1763            "low"
1764        };
1765        println!("  {mi:>5.1}   Average maintainability ({label})");
1766    }
1767    if let Some(ref score) = report.health_score {
1768        println!("  {:>5.0} {}  Health score", score.score, score.grade);
1769    }
1770    if let Some(ref gaps) = report.coverage_gaps {
1771        println!(
1772            "  {:>6}  Untested {} ({:.1}% file coverage)",
1773            gaps.summary.untested_files,
1774            if gaps.summary.untested_files == 1 {
1775                "file"
1776            } else {
1777                "files"
1778            },
1779            gaps.summary.file_coverage_pct,
1780        );
1781        println!(
1782            "  {:>6}  Untested {}",
1783            gaps.summary.untested_exports,
1784            if gaps.summary.untested_exports == 1 {
1785                "export"
1786            } else {
1787                "exports"
1788            },
1789        );
1790    }
1791    if let Some(ref production) = report.runtime_coverage {
1792        println!(
1793            "  {:>6}  Unhit in production",
1794            production.summary.functions_unhit,
1795        );
1796        println!(
1797            "  {:>6}  Untracked by V8 (lazy-parsed / worker / dynamic)",
1798            production.summary.functions_untracked,
1799        );
1800    }
1801
1802    if !quiet {
1803        eprintln!(
1804            "{}",
1805            format!(
1806                "\u{2713} {} functions analyzed ({:.2}s)",
1807                s.functions_analyzed,
1808                elapsed.as_secs_f64()
1809            )
1810            .green()
1811            .bold()
1812        );
1813    }
1814}
1815
1816/// Render a per-group summary block beneath the project-level human report.
1817///
1818/// Layout: a header row (`key  score  grade  files  hot  p90`) followed by
1819/// one row per group. The `score`/`grade` columns are omitted entirely when
1820/// no group carries a health score (no `--score` requested). The `p90`
1821/// column is omitted entirely when no group carries vital signs
1822/// (`--score-only` was active).
1823///
1824/// When scores are present, groups are sorted ascending by score (worst
1825/// first) so the rows match the user's "where do I refactor first?"
1826/// question. Otherwise the resolver's own ordering (descending by file
1827/// count, unowned last) is preserved.
1828///
1829/// Grade is colored to match the project-level grade: A/B green, C yellow,
1830/// D/F red.
1831///
1832/// Goes to stdout (the rows are content, not progress) so the block survives
1833/// `fallow health --group-by package > out.txt`. The leading blank line,
1834/// the `(root)` legend, and the JSON-parity hint go to stderr because they
1835/// are display affordances, not data.
1836pub(in crate::report) fn print_health_grouping(
1837    grouping: &crate::health_types::HealthGrouping,
1838    _root: &Path,
1839    quiet: bool,
1840) {
1841    if grouping.groups.is_empty() {
1842        return;
1843    }
1844    if !quiet {
1845        eprintln!();
1846    }
1847    println!(
1848        "{} {}",
1849        "\u{25cf}".cyan(),
1850        format!("Per-{} health", grouping.mode).cyan().bold()
1851    );
1852    let key_width = grouping
1853        .groups
1854        .iter()
1855        .map(|g| g.key.len())
1856        .max()
1857        .unwrap_or(0)
1858        .max(8);
1859    let any_score = grouping.groups.iter().any(|g| g.health_score.is_some());
1860    let any_vitals = grouping.groups.iter().any(|g| g.vital_signs.is_some());
1861
1862    // Sort by score ascending (worst first) when scores are present so the
1863    // visual order matches "where do I refactor first?". Resolver order
1864    // (descending by file count, unowned last) is preserved otherwise.
1865    let mut ordered: Vec<&crate::health_types::HealthGroup> = grouping.groups.iter().collect();
1866    if any_score {
1867        ordered.sort_by(|a, b| {
1868            let a_score = a.health_score.as_ref().map_or(f64::INFINITY, |hs| hs.score);
1869            let b_score = b.health_score.as_ref().map_or(f64::INFINITY, |hs| hs.score);
1870            a_score
1871                .partial_cmp(&b_score)
1872                .unwrap_or(std::cmp::Ordering::Equal)
1873        });
1874    }
1875
1876    // Header row: dimmed, aligned to the data rows below.
1877    let mut header = format!("  {:<width$}", "", width = key_width);
1878    if any_score {
1879        let _ = write!(header, "  {:>9}  grade", "score");
1880    }
1881    let _ = write!(header, "  {:>5}", "files");
1882    let _ = write!(header, "  {:>3}", "hot");
1883    if any_vitals {
1884        let _ = write!(header, "  {:>3}", "p90");
1885    }
1886    println!("{}", header.dimmed());
1887
1888    let mut has_root_bucket = false;
1889    for group in ordered {
1890        if group.key == "(root)" {
1891            has_root_bucket = true;
1892        }
1893        let mut row = format!("  {:<width$}", group.key, width = key_width);
1894        if any_score {
1895            if let Some(ref hs) = group.health_score {
1896                let grade_colored = colorize_grade(hs.grade);
1897                let _ = write!(row, "  {:>9.1}  {}", hs.score, grade_colored);
1898            } else {
1899                row.push_str("                  ");
1900            }
1901        }
1902        let _ = write!(row, "  {:>5}", group.files_analyzed);
1903        let _ = write!(row, "  {:>3}", group.hotspots.len());
1904        if any_vitals {
1905            if let Some(ref vs) = group.vital_signs {
1906                let _ = write!(row, "  {:>3}", vs.p90_cyclomatic);
1907            } else {
1908                row.push_str("     ");
1909            }
1910        }
1911        println!("{row}");
1912    }
1913    if !quiet {
1914        if has_root_bucket {
1915            eprintln!(
1916                "  {}",
1917                "(root) = files outside any workspace package".dimmed()
1918            );
1919        }
1920        eprintln!(
1921            "  {}",
1922            "per-group summary only; --format json includes per-group findings, file scores, and hotspots"
1923                .dimmed()
1924        );
1925    }
1926}
1927
1928/// Color a grade letter to match the project-level grade rendering.
1929fn colorize_grade(grade: &str) -> String {
1930    match grade {
1931        "A" | "B" => grade.green().to_string(),
1932        "C" => grade.yellow().to_string(),
1933        _ => grade.red().to_string(),
1934    }
1935}
1936
1937#[cfg(test)]
1938mod tests {
1939    use super::super::plain;
1940    use super::*;
1941    use std::path::PathBuf;
1942
1943    #[test]
1944    fn health_empty_findings_produces_no_header() {
1945        let root = PathBuf::from("/project");
1946        let report = crate::health_types::HealthReport {
1947            summary: crate::health_types::HealthSummary {
1948                files_analyzed: 10,
1949                functions_analyzed: 50,
1950                ..Default::default()
1951            },
1952            ..Default::default()
1953        };
1954        let lines = build_health_human_lines(&report, &root);
1955        let text = plain(&lines);
1956        // With no findings and no file scores, no complexity header is produced
1957        assert!(!text.contains("High complexity functions"));
1958    }
1959
1960    #[test]
1961    fn health_findings_show_function_details() {
1962        let root = PathBuf::from("/project");
1963        let report = crate::health_types::HealthReport {
1964            findings: vec![
1965                crate::health_types::ComplexityViolation {
1966                    path: root.join("src/parser.ts"),
1967                    name: "parseExpression".to_string(),
1968                    line: 42,
1969                    col: 0,
1970                    cyclomatic: 25,
1971                    cognitive: 30,
1972                    line_count: 80,
1973                    param_count: 0,
1974                    exceeded: crate::health_types::ExceededThreshold::Both,
1975                    severity: crate::health_types::FindingSeverity::High,
1976                    crap: None,
1977                    coverage_pct: None,
1978                    coverage_tier: None,
1979                    coverage_source: None,
1980                    inherited_from: None,
1981                    component_rollup: None,
1982                }
1983                .into(),
1984            ],
1985            summary: crate::health_types::HealthSummary {
1986                files_analyzed: 10,
1987                functions_analyzed: 50,
1988                functions_above_threshold: 1,
1989                ..Default::default()
1990            },
1991            ..Default::default()
1992        };
1993        let lines = build_health_human_lines(&report, &root);
1994        let text = plain(&lines);
1995        assert!(text.contains("High complexity functions (1)"));
1996        assert!(text.contains("src/parser.ts"));
1997        assert!(text.contains(":42"));
1998        assert!(text.contains("parseExpression"));
1999        assert!(text.contains("25 cyclomatic"));
2000        assert!(text.contains("30 cognitive"));
2001        assert!(text.contains("80 lines"));
2002    }
2003
2004    #[test]
2005    fn health_shown_vs_total_when_truncated() {
2006        let root = PathBuf::from("/project");
2007        let report = crate::health_types::HealthReport {
2008            findings: vec![
2009                crate::health_types::ComplexityViolation {
2010                    path: root.join("src/a.ts"),
2011                    name: "fn1".to_string(),
2012                    line: 1,
2013                    col: 0,
2014                    cyclomatic: 25,
2015                    cognitive: 20,
2016                    line_count: 50,
2017                    param_count: 0,
2018                    exceeded: crate::health_types::ExceededThreshold::Both,
2019                    severity: crate::health_types::FindingSeverity::High,
2020                    crap: None,
2021                    coverage_pct: None,
2022                    coverage_tier: None,
2023                    coverage_source: None,
2024                    inherited_from: None,
2025                    component_rollup: None,
2026                }
2027                .into(),
2028            ],
2029            summary: crate::health_types::HealthSummary {
2030                files_analyzed: 100,
2031                functions_analyzed: 500,
2032                functions_above_threshold: 10,
2033                ..Default::default()
2034            },
2035            ..Default::default()
2036        };
2037        let lines = build_health_human_lines(&report, &root);
2038        let text = plain(&lines);
2039        // When shown < total, header says "N shown, M total"
2040        assert!(text.contains("1 shown, 10 total"));
2041    }
2042
2043    #[test]
2044    fn health_findings_grouped_by_file() {
2045        let root = PathBuf::from("/project");
2046        let report = crate::health_types::HealthReport {
2047            findings: vec![
2048                crate::health_types::ComplexityViolation {
2049                    path: root.join("src/parser.ts"),
2050                    name: "fn1".to_string(),
2051                    line: 10,
2052                    col: 0,
2053                    cyclomatic: 25,
2054                    cognitive: 20,
2055                    line_count: 40,
2056                    param_count: 0,
2057                    exceeded: crate::health_types::ExceededThreshold::Both,
2058                    severity: crate::health_types::FindingSeverity::High,
2059                    crap: None,
2060                    coverage_pct: None,
2061                    coverage_tier: None,
2062                    coverage_source: None,
2063                    inherited_from: None,
2064                    component_rollup: None,
2065                }
2066                .into(),
2067                crate::health_types::ComplexityViolation {
2068                    path: root.join("src/parser.ts"),
2069                    name: "fn2".to_string(),
2070                    line: 60,
2071                    col: 0,
2072                    cyclomatic: 22,
2073                    cognitive: 18,
2074                    line_count: 30,
2075                    param_count: 0,
2076                    exceeded: crate::health_types::ExceededThreshold::Both,
2077                    severity: crate::health_types::FindingSeverity::High,
2078                    crap: None,
2079                    coverage_pct: None,
2080                    coverage_tier: None,
2081                    coverage_source: None,
2082                    inherited_from: None,
2083                    component_rollup: None,
2084                }
2085                .into(),
2086            ],
2087            summary: crate::health_types::HealthSummary {
2088                files_analyzed: 10,
2089                functions_analyzed: 50,
2090                functions_above_threshold: 2,
2091                ..Default::default()
2092            },
2093            ..Default::default()
2094        };
2095        let lines = build_health_human_lines(&report, &root);
2096        let text = plain(&lines);
2097        // File path should appear once (grouping)
2098        let count = text.matches("src/parser.ts").count();
2099        assert_eq!(count, 1, "File header should appear once for grouped items");
2100    }
2101
2102    // ── Helper: build an empty base report ───────────────────────
2103
2104    fn empty_report() -> crate::health_types::HealthReport {
2105        crate::health_types::HealthReport {
2106            summary: crate::health_types::HealthSummary {
2107                files_analyzed: 10,
2108                functions_analyzed: 50,
2109                ..Default::default()
2110            },
2111            ..Default::default()
2112        }
2113    }
2114
2115    #[test]
2116    fn health_runtime_coverage_renders_section() {
2117        let root = PathBuf::from("/project");
2118        let mut report = empty_report();
2119        report.runtime_coverage = Some(crate::health_types::RuntimeCoverageReport {
2120            schema_version: crate::health_types::RuntimeCoverageSchemaVersion::V1,
2121            verdict: crate::health_types::RuntimeCoverageReportVerdict::ColdCodeDetected,
2122            signals: Vec::new(),
2123            summary: crate::health_types::RuntimeCoverageSummary {
2124                data_source: crate::health_types::RuntimeCoverageDataSource::Local,
2125                last_received_at: None,
2126                functions_tracked: 4,
2127                functions_hit: 2,
2128                functions_unhit: 1,
2129                functions_untracked: 1,
2130                coverage_percent: 50.0,
2131                trace_count: 2_847_291,
2132                period_days: 30,
2133                deployments_seen: 14,
2134                capture_quality: None,
2135            },
2136            findings: vec![crate::health_types::RuntimeCoverageFinding {
2137                id: "fallow:prod:deadbeef".to_owned(),
2138                path: root.join("src/cold.ts"),
2139                function: "coldPath".to_owned(),
2140                line: 14,
2141                verdict: crate::health_types::RuntimeCoverageVerdict::ReviewRequired,
2142                invocations: Some(0),
2143                confidence: crate::health_types::RuntimeCoverageConfidence::Medium,
2144                evidence: crate::health_types::RuntimeCoverageEvidence {
2145                    static_status: "used".to_owned(),
2146                    test_coverage: "not_covered".to_owned(),
2147                    v8_tracking: "tracked".to_owned(),
2148                    untracked_reason: None,
2149                    observation_days: 30,
2150                    deployments_observed: 14,
2151                },
2152                actions: vec![],
2153            }],
2154            hot_paths: vec![crate::health_types::RuntimeCoverageHotPath {
2155                id: "fallow:hot:cafebabe".to_owned(),
2156                path: root.join("src/hot.ts"),
2157                function: "hotPath".to_owned(),
2158                line: 3,
2159                end_line: 9,
2160                invocations: 250,
2161                percentile: 99,
2162                actions: vec![],
2163            }],
2164            blast_radius: vec![],
2165            importance: vec![],
2166            watermark: Some(crate::health_types::RuntimeCoverageWatermark::LicenseExpiredGrace),
2167            warnings: vec![],
2168        });
2169
2170        let text = plain(&build_health_human_lines(&report, &root));
2171        assert!(text.contains("Runtime coverage: cold code detected"));
2172        assert!(text.contains("src/cold.ts:14 coldPath [0 invocations, review required]"));
2173        assert!(text.contains("license expired grace active"));
2174        assert!(text.contains("hot paths:"));
2175        assert!(text.contains("src/hot.ts:3 hotPath (250 invocations, p99)"));
2176        // No capture_quality => no short-window warning, no trial CTA.
2177        assert!(!text.contains("short capture:"));
2178        assert!(!text.contains("start a trial"));
2179    }
2180
2181    fn runtime_coverage_report_with_quality(
2182        quality: Option<crate::health_types::RuntimeCoverageCaptureQuality>,
2183    ) -> crate::health_types::RuntimeCoverageReport {
2184        crate::health_types::RuntimeCoverageReport {
2185            schema_version: crate::health_types::RuntimeCoverageSchemaVersion::V1,
2186            verdict: crate::health_types::RuntimeCoverageReportVerdict::Clean,
2187            signals: Vec::new(),
2188            summary: crate::health_types::RuntimeCoverageSummary {
2189                data_source: crate::health_types::RuntimeCoverageDataSource::Local,
2190                last_received_at: None,
2191                functions_tracked: 10,
2192                functions_hit: 7,
2193                functions_unhit: 0,
2194                functions_untracked: 3,
2195                coverage_percent: 70.0,
2196                trace_count: 1_000,
2197                period_days: 1,
2198                deployments_seen: 1,
2199                capture_quality: quality,
2200            },
2201            findings: vec![],
2202            hot_paths: vec![],
2203            blast_radius: vec![],
2204            importance: vec![],
2205            watermark: None,
2206            warnings: vec![],
2207        }
2208    }
2209
2210    #[test]
2211    fn health_runtime_coverage_short_capture_shows_warning_and_prompt() {
2212        let root = PathBuf::from("/project");
2213        let mut report = empty_report();
2214        report.runtime_coverage = Some(runtime_coverage_report_with_quality(Some(
2215            crate::health_types::RuntimeCoverageCaptureQuality {
2216                window_seconds: 720, // 12 min
2217                instances_observed: 1,
2218                lazy_parse_warning: true,
2219                untracked_ratio_percent: 42.5,
2220            },
2221        )));
2222        let text = plain(&build_health_human_lines(&report, &root));
2223        assert!(
2224            text.contains(
2225                "note: short capture (12 min from 1 instance); 42.5% of functions untracked, lazy-parsed scripts may not appear."
2226            ),
2227            "warning banner missing or malformed in:\n{text}"
2228        );
2229        assert!(
2230            text.contains("extend the capture or switch to continuous monitoring"),
2231            "warning follow-up line missing in:\n{text}"
2232        );
2233        assert!(
2234            text.contains("captured 12 min from 1 instance."),
2235            "upgrade prompt header missing in:\n{text}"
2236        );
2237        assert!(
2238            text.contains("continuous monitoring over 30 days evaluates more paths"),
2239            "upgrade prompt body missing in:\n{text}"
2240        );
2241        assert!(
2242            text.contains("fallow license activate --trial --email you@company.com"),
2243            "trial CTA command missing in:\n{text}"
2244        );
2245    }
2246
2247    #[test]
2248    fn health_runtime_coverage_long_capture_shows_neither_warning_nor_prompt() {
2249        let root = PathBuf::from("/project");
2250        let mut report = empty_report();
2251        report.runtime_coverage = Some(runtime_coverage_report_with_quality(Some(
2252            crate::health_types::RuntimeCoverageCaptureQuality {
2253                window_seconds: 7 * 24 * 3600, // 7 days
2254                instances_observed: 4,
2255                lazy_parse_warning: false,
2256                untracked_ratio_percent: 3.1,
2257            },
2258        )));
2259        let text = plain(&build_health_human_lines(&report, &root));
2260        assert!(
2261            !text.contains("short capture"),
2262            "long capture should not emit short-capture warning:\n{text}"
2263        );
2264        assert!(
2265            !text.contains("start a trial"),
2266            "long capture should not emit trial CTA:\n{text}"
2267        );
2268    }
2269
2270    #[test]
2271    fn format_window_labels() {
2272        assert_eq!(super::format_window(30), "30 s");
2273        assert_eq!(super::format_window(60), "1 min");
2274        assert_eq!(super::format_window(720), "12 min");
2275        assert_eq!(super::format_window(3600 * 3), "3 h");
2276        assert_eq!(super::format_window(3600 * 24 * 3), "3 d");
2277    }
2278
2279    #[test]
2280    fn health_coverage_gaps_render_section() {
2281        use crate::health_types::*;
2282
2283        let root = PathBuf::from("/project");
2284        let mut report = empty_report();
2285        report.coverage_gaps = Some(CoverageGaps {
2286            summary: CoverageGapSummary {
2287                runtime_files: 1,
2288                covered_files: 0,
2289                file_coverage_pct: 0.0,
2290                untested_files: 1,
2291                untested_exports: 1,
2292            },
2293            files: vec![UntestedFileFinding::with_actions(
2294                UntestedFile {
2295                    path: root.join("src/app.ts"),
2296                    value_export_count: 2,
2297                },
2298                &root,
2299            )],
2300            exports: vec![UntestedExportFinding::with_actions(
2301                UntestedExport {
2302                    path: root.join("src/app.ts"),
2303                    export_name: "loader".into(),
2304                    line: 12,
2305                    col: 4,
2306                },
2307                &root,
2308            )],
2309        });
2310
2311        let text = plain(&build_health_human_lines(&report, &root));
2312        assert!(
2313            text.contains("Coverage gaps (1 untested file, 1 untested export, 0.0% file coverage)")
2314        );
2315        assert!(text.contains("src/app.ts"));
2316        assert!(text.contains("loader"));
2317    }
2318
2319    // ── fmt_trend_val / fmt_trend_delta ───────────────────────────
2320
2321    #[test]
2322    fn fmt_trend_val_percentage() {
2323        assert_eq!(fmt_trend_val(15.5, "%"), "15.5%");
2324        assert_eq!(fmt_trend_val(0.0, "%"), "0.0%");
2325    }
2326
2327    #[test]
2328    fn fmt_trend_val_integer_when_round() {
2329        assert_eq!(fmt_trend_val(72.0, ""), "72");
2330        assert_eq!(fmt_trend_val(5.0, "pts"), "5");
2331    }
2332
2333    #[test]
2334    fn fmt_trend_val_decimal_when_fractional() {
2335        assert_eq!(fmt_trend_val(4.7, ""), "4.7");
2336        assert_eq!(fmt_trend_val(1.3, "pts"), "1.3");
2337    }
2338
2339    #[test]
2340    fn fmt_trend_delta_percentage() {
2341        assert_eq!(fmt_trend_delta(2.5, "%"), "+2.5%");
2342        assert_eq!(fmt_trend_delta(-1.3, "%"), "-1.3%");
2343    }
2344
2345    #[test]
2346    fn fmt_trend_delta_integer_when_round() {
2347        assert_eq!(fmt_trend_delta(5.0, ""), "+5");
2348        assert_eq!(fmt_trend_delta(-3.0, "pts"), "-3");
2349    }
2350
2351    #[test]
2352    fn fmt_trend_delta_decimal_when_fractional() {
2353        assert_eq!(fmt_trend_delta(4.9, ""), "+4.9");
2354        assert_eq!(fmt_trend_delta(-0.7, "pts"), "-0.7");
2355    }
2356
2357    // ── render_health_score ──────────────────────────────────────
2358
2359    #[test]
2360    fn health_score_grade_a_display() {
2361        let root = PathBuf::from("/project");
2362        let mut report = empty_report();
2363        report.health_score = Some(crate::health_types::HealthScore {
2364            formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2365            score: 92.0,
2366            grade: "A",
2367            penalties: crate::health_types::HealthScorePenalties {
2368                dead_files: Some(3.0),
2369                dead_exports: Some(2.0),
2370                complexity: 1.5,
2371                p90_complexity: 1.5,
2372                maintainability: Some(0.0),
2373                hotspots: Some(0.0),
2374                unused_deps: Some(0.0),
2375                circular_deps: Some(0.0),
2376                unit_size: None,
2377                coupling: None,
2378                duplication: None,
2379            },
2380        });
2381        let lines = build_health_human_lines(&report, &root);
2382        let text = plain(&lines);
2383        assert!(text.contains("Health score:"));
2384        assert!(text.contains("92 A"));
2385        assert!(text.contains("dead files -3.0"));
2386        assert!(text.contains("dead exports -2.0"));
2387        assert!(text.contains("complexity -1.5"));
2388        assert!(text.contains("p90 -1.5"));
2389    }
2390
2391    #[test]
2392    fn health_score_grade_b_display() {
2393        let root = PathBuf::from("/project");
2394        let mut report = empty_report();
2395        report.health_score = Some(crate::health_types::HealthScore {
2396            formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2397            score: 76.0,
2398            grade: "B",
2399            penalties: crate::health_types::HealthScorePenalties {
2400                dead_files: Some(5.0),
2401                dead_exports: Some(6.0),
2402                complexity: 3.0,
2403                p90_complexity: 2.0,
2404                maintainability: Some(4.0),
2405                hotspots: Some(2.0),
2406                unused_deps: Some(1.0),
2407                circular_deps: Some(1.0),
2408                unit_size: None,
2409                coupling: None,
2410                duplication: None,
2411            },
2412        });
2413        let lines = build_health_human_lines(&report, &root);
2414        let text = plain(&lines);
2415        assert!(text.contains("76 B"));
2416        // Penalties sorted by magnitude: dead exports -6.0 is the largest
2417        assert!(text.contains("dead exports -6.0"));
2418        assert!(text.contains("maintainability -4.0"));
2419        assert!(text.contains("hotspots -2.0"));
2420        assert!(text.contains("unused deps -1.0"));
2421        assert!(text.contains("circular deps -1.0"));
2422    }
2423
2424    #[test]
2425    fn health_score_grade_c_display() {
2426        let root = PathBuf::from("/project");
2427        let mut report = empty_report();
2428        report.health_score = Some(crate::health_types::HealthScore {
2429            formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2430            score: 60.0,
2431            grade: "C",
2432            penalties: crate::health_types::HealthScorePenalties {
2433                dead_files: Some(10.0),
2434                dead_exports: Some(10.0),
2435                complexity: 10.0,
2436                p90_complexity: 5.0,
2437                maintainability: Some(5.0),
2438                hotspots: None,
2439                unused_deps: None,
2440                circular_deps: None,
2441                unit_size: None,
2442                coupling: None,
2443                duplication: None,
2444            },
2445        });
2446        let lines = build_health_human_lines(&report, &root);
2447        let text = plain(&lines);
2448        assert!(text.contains("60 C"));
2449    }
2450
2451    #[test]
2452    fn health_score_grade_f_display() {
2453        let root = PathBuf::from("/project");
2454        let mut report = empty_report();
2455        report.health_score = Some(crate::health_types::HealthScore {
2456            formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2457            score: 30.0,
2458            grade: "F",
2459            penalties: crate::health_types::HealthScorePenalties {
2460                dead_files: Some(15.0),
2461                dead_exports: Some(15.0),
2462                complexity: 20.0,
2463                p90_complexity: 10.0,
2464                maintainability: Some(10.0),
2465                hotspots: None,
2466                unused_deps: None,
2467                circular_deps: None,
2468                unit_size: None,
2469                coupling: None,
2470                duplication: None,
2471            },
2472        });
2473        let lines = build_health_human_lines(&report, &root);
2474        let text = plain(&lines);
2475        assert!(text.contains("30 F"));
2476    }
2477
2478    #[test]
2479    fn health_score_na_components_shown() {
2480        let root = PathBuf::from("/project");
2481        let mut report = empty_report();
2482        report.health_score = Some(crate::health_types::HealthScore {
2483            formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2484            score: 90.0,
2485            grade: "A",
2486            penalties: crate::health_types::HealthScorePenalties {
2487                dead_files: None,
2488                dead_exports: None,
2489                complexity: 0.0,
2490                p90_complexity: 0.0,
2491                maintainability: None,
2492                hotspots: None,
2493                unused_deps: None,
2494                circular_deps: None,
2495                unit_size: None,
2496                coupling: None,
2497                duplication: None,
2498            },
2499        });
2500        let lines = build_health_human_lines(&report, &root);
2501        let text = plain(&lines);
2502        assert!(text.contains("N/A: dead code, maintainability, hotspots"));
2503        assert!(text.contains("enable the corresponding analysis flags"));
2504    }
2505
2506    #[test]
2507    fn health_score_no_na_when_all_present() {
2508        let root = PathBuf::from("/project");
2509        let mut report = empty_report();
2510        report.health_score = Some(crate::health_types::HealthScore {
2511            formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2512            score: 85.0,
2513            grade: "A",
2514            penalties: crate::health_types::HealthScorePenalties {
2515                dead_files: Some(0.0),
2516                dead_exports: Some(0.0),
2517                complexity: 0.0,
2518                p90_complexity: 0.0,
2519                maintainability: Some(0.0),
2520                hotspots: Some(0.0),
2521                unused_deps: Some(0.0),
2522                circular_deps: Some(0.0),
2523                unit_size: None,
2524                coupling: None,
2525                duplication: None,
2526            },
2527        });
2528        let lines = build_health_human_lines(&report, &root);
2529        let text = plain(&lines);
2530        assert!(!text.contains("N/A:"));
2531    }
2532
2533    #[test]
2534    fn health_score_zero_penalties_suppressed() {
2535        let root = PathBuf::from("/project");
2536        let mut report = empty_report();
2537        report.health_score = Some(crate::health_types::HealthScore {
2538            formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2539            score: 100.0,
2540            grade: "A",
2541            penalties: crate::health_types::HealthScorePenalties {
2542                dead_files: Some(0.0),
2543                dead_exports: Some(0.0),
2544                complexity: 0.0,
2545                p90_complexity: 0.0,
2546                maintainability: Some(0.0),
2547                hotspots: Some(0.0),
2548                unused_deps: Some(0.0),
2549                circular_deps: Some(0.0),
2550                unit_size: None,
2551                coupling: None,
2552                duplication: None,
2553            },
2554        });
2555        let lines = build_health_human_lines(&report, &root);
2556        let text = plain(&lines);
2557        // No penalty line when all are zero
2558        assert!(!text.contains("dead files"));
2559        assert!(!text.contains("complexity -"));
2560    }
2561
2562    // ── render_health_trend ──────────────────────────────────────
2563
2564    #[test]
2565    fn health_trend_improving_display() {
2566        let root = PathBuf::from("/project");
2567        let mut report = empty_report();
2568        report.health_trend = Some(crate::health_types::HealthTrend {
2569            compared_to: crate::health_types::TrendPoint {
2570                timestamp: "2026-03-25T14:30:00Z".into(),
2571                git_sha: Some("abc1234".into()),
2572                score: Some(72.0),
2573                grade: Some("B".into()),
2574                coverage_model: None,
2575                snapshot_schema_version: None,
2576            },
2577            metrics: vec![
2578                crate::health_types::TrendMetric {
2579                    name: "score",
2580                    label: "Health Score",
2581                    previous: 72.0,
2582                    current: 85.0,
2583                    delta: 13.0,
2584                    direction: crate::health_types::TrendDirection::Improving,
2585                    unit: "",
2586                    previous_count: None,
2587                    current_count: None,
2588                },
2589                crate::health_types::TrendMetric {
2590                    name: "dead_file_pct",
2591                    label: "Dead Files",
2592                    previous: 10.0,
2593                    current: 5.0,
2594                    delta: -5.0,
2595                    direction: crate::health_types::TrendDirection::Improving,
2596                    unit: "%",
2597                    previous_count: None,
2598                    current_count: None,
2599                },
2600            ],
2601            snapshots_loaded: 2,
2602            overall_direction: crate::health_types::TrendDirection::Improving,
2603        });
2604        let lines = build_health_human_lines(&report, &root);
2605        let text = plain(&lines);
2606        assert!(text.contains("Trend:"));
2607        assert!(text.contains("improving"));
2608        assert!(text.contains("vs 2026-03-25"));
2609        assert!(text.contains("abc1234"));
2610        assert!(text.contains("Health Score"));
2611        assert!(text.contains("+13"));
2612        assert!(text.contains("Dead Files"));
2613        assert!(text.contains("-5.0%"));
2614    }
2615
2616    #[test]
2617    fn health_trend_declining_display() {
2618        let root = PathBuf::from("/project");
2619        let mut report = empty_report();
2620        report.health_trend = Some(crate::health_types::HealthTrend {
2621            compared_to: crate::health_types::TrendPoint {
2622                timestamp: "2026-03-20T10:00:00Z".into(),
2623                git_sha: None,
2624                score: None,
2625                grade: None,
2626                coverage_model: None,
2627                snapshot_schema_version: None,
2628            },
2629            metrics: vec![crate::health_types::TrendMetric {
2630                name: "unused_deps",
2631                label: "Unused Deps",
2632                previous: 5.0,
2633                current: 10.0,
2634                delta: 5.0,
2635                direction: crate::health_types::TrendDirection::Declining,
2636                unit: "",
2637                previous_count: None,
2638                current_count: None,
2639            }],
2640            snapshots_loaded: 1,
2641            overall_direction: crate::health_types::TrendDirection::Declining,
2642        });
2643        let lines = build_health_human_lines(&report, &root);
2644        let text = plain(&lines);
2645        assert!(text.contains("declining"));
2646        assert!(text.contains("Unused Deps"));
2647    }
2648
2649    #[test]
2650    fn health_trend_all_stable_collapsed() {
2651        let root = PathBuf::from("/project");
2652        let mut report = empty_report();
2653        report.health_trend = Some(crate::health_types::HealthTrend {
2654            compared_to: crate::health_types::TrendPoint {
2655                timestamp: "2026-03-25T14:30:00Z".into(),
2656                git_sha: Some("def5678".into()),
2657                score: Some(80.0),
2658                grade: Some("B".into()),
2659                coverage_model: None,
2660                snapshot_schema_version: None,
2661            },
2662            metrics: vec![
2663                crate::health_types::TrendMetric {
2664                    name: "score",
2665                    label: "Health Score",
2666                    previous: 80.0,
2667                    current: 80.0,
2668                    delta: 0.0,
2669                    direction: crate::health_types::TrendDirection::Stable,
2670                    unit: "",
2671                    previous_count: None,
2672                    current_count: None,
2673                },
2674                crate::health_types::TrendMetric {
2675                    name: "avg_cyclomatic",
2676                    label: "Avg Cyclomatic",
2677                    previous: 2.0,
2678                    current: 2.0,
2679                    delta: 0.0,
2680                    direction: crate::health_types::TrendDirection::Stable,
2681                    unit: "",
2682                    previous_count: None,
2683                    current_count: None,
2684                },
2685            ],
2686            snapshots_loaded: 3,
2687            overall_direction: crate::health_types::TrendDirection::Stable,
2688        });
2689        let lines = build_health_human_lines(&report, &root);
2690        let text = plain(&lines);
2691        assert!(text.contains("stable"));
2692        assert!(text.contains("All 2 metrics unchanged"));
2693        // Individual metric rows should NOT appear
2694        assert!(!text.contains("Health Score"));
2695    }
2696
2697    #[test]
2698    fn health_trend_without_sha() {
2699        let root = PathBuf::from("/project");
2700        let mut report = empty_report();
2701        report.health_trend = Some(crate::health_types::HealthTrend {
2702            compared_to: crate::health_types::TrendPoint {
2703                timestamp: "2026-03-20T10:00:00Z".into(),
2704                git_sha: None,
2705                score: None,
2706                grade: None,
2707                coverage_model: None,
2708                snapshot_schema_version: None,
2709            },
2710            metrics: vec![crate::health_types::TrendMetric {
2711                name: "score",
2712                label: "Health Score",
2713                previous: 80.0,
2714                current: 82.0,
2715                delta: 2.0,
2716                direction: crate::health_types::TrendDirection::Improving,
2717                unit: "",
2718                previous_count: None,
2719                current_count: None,
2720            }],
2721            snapshots_loaded: 1,
2722            overall_direction: crate::health_types::TrendDirection::Improving,
2723        });
2724        let lines = build_health_human_lines(&report, &root);
2725        let text = plain(&lines);
2726        // No SHA in output
2727        assert!(text.contains("vs 2026-03-20"));
2728        assert!(!text.contains("\u{00b7}"));
2729    }
2730
2731    // ── render_vital_signs ───────────────────────────────────────
2732
2733    #[test]
2734    fn vital_signs_shown_without_trend() {
2735        let root = PathBuf::from("/project");
2736        let mut report = empty_report();
2737        report.vital_signs = Some(crate::health_types::VitalSigns {
2738            dead_file_pct: Some(3.2),
2739            dead_export_pct: Some(8.1),
2740            avg_cyclomatic: 4.7,
2741            p90_cyclomatic: 12,
2742            duplication_pct: None,
2743            hotspot_count: Some(2),
2744            maintainability_avg: Some(72.4),
2745            unused_dep_count: Some(3),
2746            circular_dep_count: Some(1),
2747            counts: None,
2748            unit_size_profile: None,
2749            unit_interfacing_profile: None,
2750            p95_fan_in: None,
2751            coupling_high_pct: None,
2752            total_loc: 42_381,
2753            ..Default::default()
2754        });
2755        let lines = build_health_human_lines(&report, &root);
2756        let text = plain(&lines);
2757        assert!(text.contains("42,381 LOC"));
2758        assert!(text.contains("dead files 3.2%"));
2759        assert!(text.contains("dead exports 8.1%"));
2760        assert!(text.contains("avg cyclomatic 4.7"));
2761        assert!(text.contains("p90 cyclomatic 12"));
2762        assert!(text.contains("maintainability 72.4"));
2763        assert!(text.contains("2 churn hotspots"));
2764        assert!(text.contains("3 unused deps"));
2765        assert!(text.contains("1 circular dep"));
2766    }
2767
2768    #[test]
2769    fn vital_signs_suppressed_when_trend_active() {
2770        let root = PathBuf::from("/project");
2771        let mut report = empty_report();
2772        report.vital_signs = Some(crate::health_types::VitalSigns {
2773            dead_file_pct: Some(3.2),
2774            dead_export_pct: Some(8.1),
2775            avg_cyclomatic: 4.7,
2776            p90_cyclomatic: 12,
2777            duplication_pct: None,
2778            hotspot_count: Some(2),
2779            maintainability_avg: Some(72.4),
2780            unused_dep_count: None,
2781            circular_dep_count: None,
2782            counts: None,
2783            unit_size_profile: None,
2784            unit_interfacing_profile: None,
2785            p95_fan_in: None,
2786            coupling_high_pct: None,
2787            total_loc: 0,
2788            ..Default::default()
2789        });
2790        report.health_trend = Some(crate::health_types::HealthTrend {
2791            compared_to: crate::health_types::TrendPoint {
2792                timestamp: "2026-03-25T14:30:00Z".into(),
2793                git_sha: None,
2794                score: None,
2795                grade: None,
2796                coverage_model: None,
2797                snapshot_schema_version: None,
2798            },
2799            metrics: vec![],
2800            snapshots_loaded: 1,
2801            overall_direction: crate::health_types::TrendDirection::Stable,
2802        });
2803        let lines = build_health_human_lines(&report, &root);
2804        let text = plain(&lines);
2805        // vital signs should be suppressed when trend is active
2806        assert!(!text.contains("dead files"));
2807        assert!(!text.contains("avg cyclomatic"));
2808    }
2809
2810    #[test]
2811    fn vital_signs_optional_fields_omitted_when_none() {
2812        let root = PathBuf::from("/project");
2813        let mut report = empty_report();
2814        report.vital_signs = Some(crate::health_types::VitalSigns {
2815            dead_file_pct: None,
2816            dead_export_pct: None,
2817            avg_cyclomatic: 2.0,
2818            p90_cyclomatic: 5,
2819            duplication_pct: None,
2820            hotspot_count: None,
2821            maintainability_avg: None,
2822            unused_dep_count: None,
2823            circular_dep_count: None,
2824            counts: None,
2825            unit_size_profile: None,
2826            unit_interfacing_profile: None,
2827            p95_fan_in: None,
2828            coupling_high_pct: None,
2829            total_loc: 0,
2830            ..Default::default()
2831        });
2832        let lines = build_health_human_lines(&report, &root);
2833        let text = plain(&lines);
2834        assert!(!text.contains("dead files"));
2835        assert!(!text.contains("dead exports"));
2836        assert!(!text.contains("maintainability "));
2837        assert!(!text.contains("hotspot"));
2838        assert!(text.contains("avg cyclomatic 2.0"));
2839        assert!(text.contains("p90 cyclomatic 5"));
2840    }
2841
2842    #[test]
2843    fn vital_signs_zero_counts_suppressed() {
2844        let root = PathBuf::from("/project");
2845        let mut report = empty_report();
2846        report.vital_signs = Some(crate::health_types::VitalSigns {
2847            dead_file_pct: None,
2848            dead_export_pct: None,
2849            avg_cyclomatic: 1.0,
2850            p90_cyclomatic: 2,
2851            duplication_pct: None,
2852            hotspot_count: None,
2853            maintainability_avg: None,
2854            unused_dep_count: Some(0),
2855            circular_dep_count: Some(0),
2856            counts: None,
2857            unit_size_profile: None,
2858            unit_interfacing_profile: None,
2859            p95_fan_in: None,
2860            coupling_high_pct: None,
2861            total_loc: 0,
2862            ..Default::default()
2863        });
2864        let lines = build_health_human_lines(&report, &root);
2865        let text = plain(&lines);
2866        // Zero counts should not appear
2867        assert!(!text.contains("unused dep"));
2868        assert!(!text.contains("circular dep"));
2869    }
2870
2871    #[test]
2872    fn vital_signs_plural_vs_singular() {
2873        let root = PathBuf::from("/project");
2874        let mut report = empty_report();
2875        report.vital_signs = Some(crate::health_types::VitalSigns {
2876            dead_file_pct: None,
2877            dead_export_pct: None,
2878            avg_cyclomatic: 1.0,
2879            p90_cyclomatic: 2,
2880            duplication_pct: None,
2881            hotspot_count: Some(1),
2882            maintainability_avg: None,
2883            unused_dep_count: Some(1),
2884            circular_dep_count: Some(2),
2885            counts: None,
2886            unit_size_profile: None,
2887            unit_interfacing_profile: None,
2888            p95_fan_in: None,
2889            coupling_high_pct: None,
2890            total_loc: 0,
2891            ..Default::default()
2892        });
2893        let lines = build_health_human_lines(&report, &root);
2894        let text = plain(&lines);
2895        assert!(text.contains("1 churn hotspot"));
2896        assert!(!text.contains("1 churn hotspots"));
2897        assert!(text.contains("1 unused dep"));
2898        assert!(!text.contains("1 unused deps"));
2899        assert!(text.contains("2 circular deps"));
2900    }
2901
2902    // ── render_file_scores ───────────────────────────────────────
2903
2904    #[test]
2905    fn file_scores_single_entry() {
2906        let root = PathBuf::from("/project");
2907        let mut report = empty_report();
2908        report.file_scores = vec![crate::health_types::FileHealthScore {
2909            path: root.join("src/utils.ts"),
2910            fan_in: 5,
2911            fan_out: 3,
2912            dead_code_ratio: 0.15,
2913            complexity_density: 0.42,
2914            maintainability_index: 85.3,
2915            total_cyclomatic: 12,
2916            total_cognitive: 8,
2917            function_count: 4,
2918            lines: 200,
2919            crap_max: 0.0,
2920            crap_above_threshold: 0,
2921        }];
2922        let lines = build_health_human_lines(&report, &root);
2923        let text = plain(&lines);
2924        assert!(text.contains("File health scores (1 files)"));
2925        assert!(text.contains("85.3"));
2926        assert!(text.contains("src/utils.ts"));
2927        assert!(text.contains("200 LOC"));
2928        assert!(text.contains("5 fan-in"));
2929        assert!(text.contains("3 fan-out"));
2930        assert!(text.contains("15% dead"));
2931        assert!(text.contains("0.42 density"));
2932    }
2933
2934    #[test]
2935    fn file_scores_mi_color_thresholds() {
2936        let root = PathBuf::from("/project");
2937        let mut report = empty_report();
2938        report.file_scores = vec![
2939            crate::health_types::FileHealthScore {
2940                path: root.join("src/good.ts"),
2941                fan_in: 1,
2942                fan_out: 1,
2943                dead_code_ratio: 0.0,
2944                complexity_density: 0.1,
2945                maintainability_index: 90.0, // green: >= 80
2946                total_cyclomatic: 2,
2947                total_cognitive: 1,
2948                function_count: 1,
2949                lines: 50,
2950                crap_max: 0.0,
2951                crap_above_threshold: 0,
2952            },
2953            crate::health_types::FileHealthScore {
2954                path: root.join("src/okay.ts"),
2955                fan_in: 2,
2956                fan_out: 3,
2957                dead_code_ratio: 0.1,
2958                complexity_density: 0.3,
2959                maintainability_index: 65.0, // yellow: >= 50
2960                total_cyclomatic: 8,
2961                total_cognitive: 5,
2962                function_count: 3,
2963                lines: 100,
2964                crap_max: 0.0,
2965                crap_above_threshold: 0,
2966            },
2967            crate::health_types::FileHealthScore {
2968                path: root.join("src/bad.ts"),
2969                fan_in: 8,
2970                fan_out: 12,
2971                dead_code_ratio: 0.5,
2972                complexity_density: 0.9,
2973                maintainability_index: 30.0, // red: < 50
2974                total_cyclomatic: 40,
2975                total_cognitive: 30,
2976                function_count: 10,
2977                lines: 500,
2978                crap_max: 0.0,
2979                crap_above_threshold: 0,
2980            },
2981        ];
2982        let lines = build_health_human_lines(&report, &root);
2983        let text = plain(&lines);
2984        assert!(text.contains("File health scores (3 files)"));
2985        assert!(text.contains("90.0"));
2986        assert!(text.contains("65.0"));
2987        assert!(text.contains("30.0"));
2988    }
2989
2990    #[test]
2991    fn file_scores_truncation_above_max_flat_items() {
2992        let root = PathBuf::from("/project");
2993        let mut report = empty_report();
2994        // Create 12 file scores (MAX_FLAT_ITEMS = 10)
2995        for i in 0..12 {
2996            report
2997                .file_scores
2998                .push(crate::health_types::FileHealthScore {
2999                    path: root.join(format!("src/file{i}.ts")),
3000                    fan_in: 1,
3001                    fan_out: 1,
3002                    dead_code_ratio: 0.0,
3003                    complexity_density: 0.1,
3004                    maintainability_index: 80.0,
3005                    total_cyclomatic: 2,
3006                    total_cognitive: 1,
3007                    function_count: 1,
3008                    lines: 50,
3009                    crap_max: 0.0,
3010                    crap_above_threshold: 0,
3011                });
3012        }
3013        let lines = build_health_human_lines(&report, &root);
3014        let text = plain(&lines);
3015        assert!(text.contains("File health scores (12 files)"));
3016        assert!(text.contains("... and 2 more files"));
3017        // First 10 should be shown
3018        assert!(text.contains("file0.ts"));
3019        assert!(text.contains("file9.ts"));
3020        // 11th and 12th should not
3021        assert!(!text.contains("file10.ts"));
3022        assert!(!text.contains("file11.ts"));
3023    }
3024
3025    #[test]
3026    fn file_scores_docs_link() {
3027        let root = PathBuf::from("/project");
3028        let mut report = empty_report();
3029        report.file_scores = vec![crate::health_types::FileHealthScore {
3030            path: root.join("src/a.ts"),
3031            fan_in: 1,
3032            fan_out: 1,
3033            dead_code_ratio: 0.0,
3034            complexity_density: 0.1,
3035            maintainability_index: 80.0,
3036            total_cyclomatic: 2,
3037            total_cognitive: 1,
3038            function_count: 1,
3039            lines: 50,
3040            crap_max: 0.0,
3041            crap_above_threshold: 0,
3042        }];
3043        let lines = build_health_human_lines(&report, &root);
3044        let text = plain(&lines);
3045        assert!(text.contains("docs.fallow.tools/explanations/health#file-health-scores"));
3046    }
3047
3048    // ── render_hotspots ──────────────────────────────────────────
3049
3050    #[test]
3051    fn hotspots_accelerating_trend() {
3052        let root = PathBuf::from("/project");
3053        let mut report = empty_report();
3054        report.hotspots = vec![
3055            crate::health_types::HotspotEntry {
3056                path: root.join("src/core.ts"),
3057                score: 75.0,
3058                commits: 42,
3059                weighted_commits: 30.0,
3060                lines_added: 500,
3061                lines_deleted: 200,
3062                complexity_density: 0.85,
3063                fan_in: 10,
3064                trend: fallow_core::churn::ChurnTrend::Accelerating,
3065                ownership: None,
3066                is_test_path: false,
3067            }
3068            .into(),
3069        ];
3070        let lines = build_health_human_lines(&report, &root);
3071        let text = plain(&lines);
3072        assert!(text.contains("Hotspots (1 files)"));
3073        assert!(text.contains("75.0"));
3074        assert!(text.contains("src/core.ts"));
3075        assert!(text.contains("42 commits"));
3076        assert!(text.contains("700 churn"));
3077        assert!(text.contains("0.85 density"));
3078        assert!(text.contains("10 fan-in"));
3079        assert!(text.contains("accelerating"));
3080    }
3081
3082    #[test]
3083    fn hotspots_cooling_trend() {
3084        let root = PathBuf::from("/project");
3085        let mut report = empty_report();
3086        report.hotspots = vec![
3087            crate::health_types::HotspotEntry {
3088                path: root.join("src/old.ts"),
3089                score: 20.0,
3090                commits: 5,
3091                weighted_commits: 2.0,
3092                lines_added: 50,
3093                lines_deleted: 30,
3094                complexity_density: 0.3,
3095                fan_in: 2,
3096                trend: fallow_core::churn::ChurnTrend::Cooling,
3097                ownership: None,
3098                is_test_path: false,
3099            }
3100            .into(),
3101        ];
3102        let lines = build_health_human_lines(&report, &root);
3103        let text = plain(&lines);
3104        assert!(text.contains("20.0"));
3105        assert!(text.contains("cooling"));
3106    }
3107
3108    #[test]
3109    fn hotspots_stable_trend() {
3110        let root = PathBuf::from("/project");
3111        let mut report = empty_report();
3112        report.hotspots = vec![
3113            crate::health_types::HotspotEntry {
3114                path: root.join("src/mid.ts"),
3115                score: 45.0,
3116                commits: 15,
3117                weighted_commits: 10.0,
3118                lines_added: 200,
3119                lines_deleted: 100,
3120                complexity_density: 0.5,
3121                fan_in: 5,
3122                trend: fallow_core::churn::ChurnTrend::Stable,
3123                ownership: None,
3124                is_test_path: false,
3125            }
3126            .into(),
3127        ];
3128        let lines = build_health_human_lines(&report, &root);
3129        let text = plain(&lines);
3130        assert!(text.contains("45.0"));
3131        assert!(text.contains("stable"));
3132    }
3133
3134    #[test]
3135    fn hotspots_with_summary_and_since() {
3136        let root = PathBuf::from("/project");
3137        let mut report = empty_report();
3138        report.hotspots = vec![
3139            crate::health_types::HotspotEntry {
3140                path: root.join("src/a.ts"),
3141                score: 50.0,
3142                commits: 10,
3143                weighted_commits: 8.0,
3144                lines_added: 100,
3145                lines_deleted: 50,
3146                complexity_density: 0.4,
3147                fan_in: 3,
3148                trend: fallow_core::churn::ChurnTrend::Stable,
3149                ownership: None,
3150                is_test_path: false,
3151            }
3152            .into(),
3153        ];
3154        report.hotspot_summary = Some(crate::health_types::HotspotSummary {
3155            since: "6 months".to_string(),
3156            min_commits: 3,
3157            files_analyzed: 50,
3158            files_excluded: 20,
3159            shallow_clone: false,
3160        });
3161        let lines = build_health_human_lines(&report, &root);
3162        let text = plain(&lines);
3163        assert!(text.contains("Hotspots (1 files, since 6 months)"));
3164        assert!(text.contains("20 files excluded (< 3 commits)"));
3165    }
3166
3167    #[test]
3168    fn hotspots_summary_no_exclusions() {
3169        let root = PathBuf::from("/project");
3170        let mut report = empty_report();
3171        report.hotspots = vec![
3172            crate::health_types::HotspotEntry {
3173                path: root.join("src/a.ts"),
3174                score: 50.0,
3175                commits: 10,
3176                weighted_commits: 8.0,
3177                lines_added: 100,
3178                lines_deleted: 50,
3179                complexity_density: 0.4,
3180                fan_in: 3,
3181                trend: fallow_core::churn::ChurnTrend::Stable,
3182                ownership: None,
3183                is_test_path: false,
3184            }
3185            .into(),
3186        ];
3187        report.hotspot_summary = Some(crate::health_types::HotspotSummary {
3188            since: "3 months".to_string(),
3189            min_commits: 2,
3190            files_analyzed: 50,
3191            files_excluded: 0,
3192            shallow_clone: false,
3193        });
3194        let lines = build_health_human_lines(&report, &root);
3195        let text = plain(&lines);
3196        // No exclusion line when files_excluded == 0
3197        assert!(!text.contains("files excluded"));
3198    }
3199
3200    #[test]
3201    fn hotspots_docs_link() {
3202        let root = PathBuf::from("/project");
3203        let mut report = empty_report();
3204        report.hotspots = vec![
3205            crate::health_types::HotspotEntry {
3206                path: root.join("src/a.ts"),
3207                score: 50.0,
3208                commits: 10,
3209                weighted_commits: 8.0,
3210                lines_added: 100,
3211                lines_deleted: 50,
3212                complexity_density: 0.4,
3213                fan_in: 3,
3214                trend: fallow_core::churn::ChurnTrend::Stable,
3215                ownership: None,
3216                is_test_path: false,
3217            }
3218            .into(),
3219        ];
3220        let lines = build_health_human_lines(&report, &root);
3221        let text = plain(&lines);
3222        assert!(text.contains("docs.fallow.tools/explanations/health#hotspot-metrics"));
3223    }
3224
3225    // ── render_refactoring_targets ───────────────────────────────
3226
3227    #[test]
3228    fn refactoring_targets_single_low_effort() {
3229        let root = PathBuf::from("/project");
3230        let mut report = empty_report();
3231        report.targets = vec![
3232            crate::health_types::RefactoringTarget {
3233                path: root.join("src/legacy.ts"),
3234                priority: 65.0,
3235                efficiency: 65.0,
3236                recommendation: "Extract complex logic into helper functions".to_string(),
3237                category: crate::health_types::RecommendationCategory::ExtractComplexFunctions,
3238                effort: crate::health_types::EffortEstimate::Low,
3239                confidence: crate::health_types::Confidence::High,
3240                factors: vec![],
3241                evidence: None,
3242            }
3243            .into(),
3244        ];
3245        let lines = build_health_human_lines(&report, &root);
3246        let text = plain(&lines);
3247        assert!(text.contains("Refactoring targets (1)"));
3248        assert!(text.contains("1 low effort"));
3249        assert!(text.contains("65.0"));
3250        assert!(text.contains("pri:65.0"));
3251        assert!(text.contains("src/legacy.ts"));
3252        assert!(text.contains("complexity"));
3253        assert!(text.contains("effort:low"));
3254        assert!(text.contains("confidence:high"));
3255        assert!(text.contains("Extract complex logic into helper functions"));
3256    }
3257
3258    #[test]
3259    fn refactoring_targets_mixed_effort() {
3260        let root = PathBuf::from("/project");
3261        let mut report = empty_report();
3262        report.targets = vec![
3263            crate::health_types::RefactoringTarget {
3264                path: root.join("src/a.ts"),
3265                priority: 80.0,
3266                efficiency: 80.0,
3267                recommendation: "Remove dead exports".to_string(),
3268                category: crate::health_types::RecommendationCategory::RemoveDeadCode,
3269                effort: crate::health_types::EffortEstimate::Low,
3270                confidence: crate::health_types::Confidence::High,
3271                factors: vec![],
3272                evidence: None,
3273            }
3274            .into(),
3275            crate::health_types::RefactoringTarget {
3276                path: root.join("src/b.ts"),
3277                priority: 60.0,
3278                efficiency: 30.0,
3279                recommendation: "Split into smaller modules".to_string(),
3280                category: crate::health_types::RecommendationCategory::SplitHighImpact,
3281                effort: crate::health_types::EffortEstimate::Medium,
3282                confidence: crate::health_types::Confidence::Medium,
3283                factors: vec![],
3284                evidence: None,
3285            }
3286            .into(),
3287            crate::health_types::RefactoringTarget {
3288                path: root.join("src/c.ts"),
3289                priority: 50.0,
3290                efficiency: 16.7,
3291                recommendation: "Break circular dependency".to_string(),
3292                category: crate::health_types::RecommendationCategory::BreakCircularDependency,
3293                effort: crate::health_types::EffortEstimate::High,
3294                confidence: crate::health_types::Confidence::Low,
3295                factors: vec![],
3296                evidence: None,
3297            }
3298            .into(),
3299        ];
3300        let lines = build_health_human_lines(&report, &root);
3301        let text = plain(&lines);
3302        assert!(text.contains("Refactoring targets (3)"));
3303        assert!(text.contains("1 low effort"));
3304        assert!(text.contains("1 medium"));
3305        assert!(text.contains("1 high"));
3306        assert!(text.contains("effort:low"));
3307        assert!(text.contains("effort:medium"));
3308        assert!(text.contains("effort:high"));
3309        assert!(text.contains("confidence:high"));
3310        assert!(text.contains("confidence:medium"));
3311        assert!(text.contains("confidence:low"));
3312    }
3313
3314    #[test]
3315    fn refactoring_targets_truncation_above_max_flat_items() {
3316        let root = PathBuf::from("/project");
3317        let mut report = empty_report();
3318        for i in 0..12 {
3319            report.targets.push(
3320                crate::health_types::RefactoringTarget {
3321                    path: root.join(format!("src/target{i}.ts")),
3322                    priority: 50.0,
3323                    efficiency: 25.0,
3324                    recommendation: format!("Fix target {i}"),
3325                    category: crate::health_types::RecommendationCategory::ExtractComplexFunctions,
3326                    effort: crate::health_types::EffortEstimate::Medium,
3327                    confidence: crate::health_types::Confidence::Medium,
3328                    factors: vec![],
3329                    evidence: None,
3330                }
3331                .into(),
3332            );
3333        }
3334        let lines = build_health_human_lines(&report, &root);
3335        let text = plain(&lines);
3336        assert!(text.contains("Refactoring targets (12)"));
3337        assert!(text.contains("... and 2 more targets"));
3338        assert!(text.contains("target0.ts"));
3339        assert!(text.contains("target9.ts"));
3340        assert!(!text.contains("target10.ts"));
3341    }
3342
3343    #[test]
3344    fn refactoring_targets_docs_link() {
3345        let root = PathBuf::from("/project");
3346        let mut report = empty_report();
3347        report.targets = vec![
3348            crate::health_types::RefactoringTarget {
3349                path: root.join("src/a.ts"),
3350                priority: 50.0,
3351                efficiency: 50.0,
3352                recommendation: "Fix it".to_string(),
3353                category: crate::health_types::RecommendationCategory::ExtractDependencies,
3354                effort: crate::health_types::EffortEstimate::Low,
3355                confidence: crate::health_types::Confidence::High,
3356                factors: vec![],
3357                evidence: None,
3358            }
3359            .into(),
3360        ];
3361        let lines = build_health_human_lines(&report, &root);
3362        let text = plain(&lines);
3363        assert!(text.contains("docs.fallow.tools/explanations/health#refactoring-targets"));
3364    }
3365
3366    #[test]
3367    fn refactoring_targets_all_categories() {
3368        let root = PathBuf::from("/project");
3369        let mut report = empty_report();
3370        let categories = [
3371            (
3372                crate::health_types::RecommendationCategory::UrgentChurnComplexity,
3373                "churn+complexity",
3374            ),
3375            (
3376                crate::health_types::RecommendationCategory::BreakCircularDependency,
3377                "circular dependency",
3378            ),
3379            (
3380                crate::health_types::RecommendationCategory::SplitHighImpact,
3381                "high impact",
3382            ),
3383            (
3384                crate::health_types::RecommendationCategory::RemoveDeadCode,
3385                "dead code",
3386            ),
3387            (
3388                crate::health_types::RecommendationCategory::ExtractComplexFunctions,
3389                "complexity",
3390            ),
3391            (
3392                crate::health_types::RecommendationCategory::ExtractDependencies,
3393                "coupling",
3394            ),
3395            (
3396                crate::health_types::RecommendationCategory::AddTestCoverage,
3397                "untested risk",
3398            ),
3399        ];
3400        for (i, (cat, _label)) in categories.iter().enumerate() {
3401            report.targets.push(
3402                crate::health_types::RefactoringTarget {
3403                    path: root.join(format!("src/cat{i}.ts")),
3404                    priority: 50.0,
3405                    efficiency: 50.0,
3406                    recommendation: format!("Fix cat{i}"),
3407                    category: cat.clone(),
3408                    effort: crate::health_types::EffortEstimate::Low,
3409                    confidence: crate::health_types::Confidence::High,
3410                    factors: vec![],
3411                    evidence: None,
3412                }
3413                .into(),
3414            );
3415        }
3416        let lines = build_health_human_lines(&report, &root);
3417        let text = plain(&lines);
3418        for (_cat, label) in &categories {
3419            assert!(
3420                text.contains(label),
3421                "Expected category label '{label}' in output"
3422            );
3423        }
3424    }
3425
3426    #[test]
3427    fn refactoring_targets_efficiency_color_thresholds() {
3428        let root = PathBuf::from("/project");
3429        let mut report = empty_report();
3430        report.targets = vec![
3431            crate::health_types::RefactoringTarget {
3432                path: root.join("src/high.ts"),
3433                priority: 50.0,
3434                efficiency: 50.0, // green: >= 40
3435                recommendation: "High eff".to_string(),
3436                category: crate::health_types::RecommendationCategory::RemoveDeadCode,
3437                effort: crate::health_types::EffortEstimate::Low,
3438                confidence: crate::health_types::Confidence::High,
3439                factors: vec![],
3440                evidence: None,
3441            }
3442            .into(),
3443            crate::health_types::RefactoringTarget {
3444                path: root.join("src/mid.ts"),
3445                priority: 50.0,
3446                efficiency: 25.0, // yellow: >= 20
3447                recommendation: "Mid eff".to_string(),
3448                category: crate::health_types::RecommendationCategory::RemoveDeadCode,
3449                effort: crate::health_types::EffortEstimate::Medium,
3450                confidence: crate::health_types::Confidence::Medium,
3451                factors: vec![],
3452                evidence: None,
3453            }
3454            .into(),
3455            crate::health_types::RefactoringTarget {
3456                path: root.join("src/low.ts"),
3457                priority: 50.0,
3458                efficiency: 10.0, // dimmed: < 20
3459                recommendation: "Low eff".to_string(),
3460                category: crate::health_types::RecommendationCategory::RemoveDeadCode,
3461                effort: crate::health_types::EffortEstimate::High,
3462                confidence: crate::health_types::Confidence::Low,
3463                factors: vec![],
3464                evidence: None,
3465            }
3466            .into(),
3467        ];
3468        let lines = build_health_human_lines(&report, &root);
3469        let text = plain(&lines);
3470        assert!(text.contains("50.0"));
3471        assert!(text.contains("25.0"));
3472        assert!(text.contains("10.0"));
3473    }
3474
3475    // ── Combined sections ────────────────────────────────────────
3476
3477    #[test]
3478    fn all_sections_combined() {
3479        let root = PathBuf::from("/project");
3480        let mut report = empty_report();
3481        report.summary.functions_above_threshold = 1;
3482        report.findings = vec![
3483            crate::health_types::ComplexityViolation {
3484                path: root.join("src/complex.ts"),
3485                name: "bigFn".to_string(),
3486                line: 10,
3487                col: 0,
3488                cyclomatic: 25,
3489                cognitive: 20,
3490                line_count: 80,
3491                param_count: 0,
3492                exceeded: crate::health_types::ExceededThreshold::Both,
3493                severity: crate::health_types::FindingSeverity::Moderate,
3494                crap: None,
3495                coverage_pct: None,
3496                coverage_tier: None,
3497                coverage_source: None,
3498                inherited_from: None,
3499                component_rollup: None,
3500            }
3501            .into(),
3502        ];
3503        report.health_score = Some(crate::health_types::HealthScore {
3504            formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
3505            score: 75.0,
3506            grade: "B",
3507            penalties: crate::health_types::HealthScorePenalties {
3508                dead_files: Some(5.0),
3509                dead_exports: Some(5.0),
3510                complexity: 5.0,
3511                p90_complexity: 2.0,
3512                maintainability: Some(3.0),
3513                hotspots: Some(2.0),
3514                unused_deps: Some(2.0),
3515                circular_deps: Some(1.0),
3516                unit_size: None,
3517                coupling: None,
3518                duplication: None,
3519            },
3520        });
3521        report.file_scores = vec![crate::health_types::FileHealthScore {
3522            path: root.join("src/complex.ts"),
3523            fan_in: 5,
3524            fan_out: 3,
3525            dead_code_ratio: 0.1,
3526            complexity_density: 0.5,
3527            maintainability_index: 60.0,
3528            total_cyclomatic: 15,
3529            total_cognitive: 10,
3530            function_count: 3,
3531            lines: 200,
3532            crap_max: 0.0,
3533            crap_above_threshold: 0,
3534        }];
3535        report.hotspots = vec![
3536            crate::health_types::HotspotEntry {
3537                path: root.join("src/complex.ts"),
3538                score: 65.0,
3539                commits: 20,
3540                weighted_commits: 15.0,
3541                lines_added: 300,
3542                lines_deleted: 100,
3543                complexity_density: 0.5,
3544                fan_in: 5,
3545                trend: fallow_core::churn::ChurnTrend::Accelerating,
3546                ownership: None,
3547                is_test_path: false,
3548            }
3549            .into(),
3550        ];
3551        report.targets = vec![
3552            crate::health_types::RefactoringTarget {
3553                path: root.join("src/complex.ts"),
3554                priority: 70.0,
3555                efficiency: 70.0,
3556                recommendation: "Extract complex functions".to_string(),
3557                category: crate::health_types::RecommendationCategory::ExtractComplexFunctions,
3558                effort: crate::health_types::EffortEstimate::Low,
3559                confidence: crate::health_types::Confidence::High,
3560                factors: vec![],
3561                evidence: None,
3562            }
3563            .into(),
3564        ];
3565        let lines = build_health_human_lines(&report, &root);
3566        let text = plain(&lines);
3567        // All sections present
3568        assert!(text.contains("Health score:"));
3569        assert!(text.contains("High complexity functions"));
3570        assert!(text.contains("File health scores"));
3571        assert!(text.contains("Hotspots"));
3572        assert!(text.contains("Refactoring targets"));
3573    }
3574
3575    #[test]
3576    fn completely_empty_report_produces_no_lines() {
3577        let root = PathBuf::from("/project");
3578        let report = empty_report();
3579        let lines = build_health_human_lines(&report, &root);
3580        assert!(lines.is_empty());
3581    }
3582
3583    // ── Finding threshold coloring ───────────────────────────────
3584
3585    #[test]
3586    fn finding_only_cyclomatic_exceeds() {
3587        let root = PathBuf::from("/project");
3588        let mut report = empty_report();
3589        report.summary.functions_above_threshold = 1;
3590        report.findings = vec![
3591            crate::health_types::ComplexityViolation {
3592                path: root.join("src/a.ts"),
3593                name: "fn1".to_string(),
3594                line: 1,
3595                col: 0,
3596                cyclomatic: 25, // exceeds 20
3597                cognitive: 10,  // does not exceed 15
3598                line_count: 50,
3599                param_count: 0,
3600                exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
3601                severity: crate::health_types::FindingSeverity::Moderate,
3602                crap: None,
3603                coverage_pct: None,
3604                coverage_tier: None,
3605                coverage_source: None,
3606                inherited_from: None,
3607                component_rollup: None,
3608            }
3609            .into(),
3610        ];
3611        let lines = build_health_human_lines(&report, &root);
3612        let text = plain(&lines);
3613        assert!(text.contains("25 cyclomatic"));
3614        assert!(text.contains("10 cognitive"));
3615    }
3616
3617    #[test]
3618    fn finding_only_cognitive_exceeds() {
3619        let root = PathBuf::from("/project");
3620        let mut report = empty_report();
3621        report.summary.functions_above_threshold = 1;
3622        report.findings = vec![
3623            crate::health_types::ComplexityViolation {
3624                path: root.join("src/a.ts"),
3625                name: "fn1".to_string(),
3626                line: 1,
3627                col: 0,
3628                cyclomatic: 10, // does not exceed 20
3629                cognitive: 25,  // exceeds 15
3630                line_count: 50,
3631                param_count: 0,
3632                exceeded: crate::health_types::ExceededThreshold::Cognitive,
3633                severity: crate::health_types::FindingSeverity::High,
3634                crap: None,
3635                coverage_pct: None,
3636                coverage_tier: None,
3637                coverage_source: None,
3638                inherited_from: None,
3639                component_rollup: None,
3640            }
3641            .into(),
3642        ];
3643        let lines = build_health_human_lines(&report, &root);
3644        let text = plain(&lines);
3645        assert!(text.contains("10 cyclomatic"));
3646        assert!(text.contains("25 cognitive"));
3647    }
3648
3649    #[test]
3650    fn findings_across_multiple_files() {
3651        let root = PathBuf::from("/project");
3652        let mut report = empty_report();
3653        report.summary.functions_above_threshold = 2;
3654        report.findings = vec![
3655            crate::health_types::ComplexityViolation {
3656                path: root.join("src/a.ts"),
3657                name: "fn1".to_string(),
3658                line: 1,
3659                col: 0,
3660                cyclomatic: 25,
3661                cognitive: 20,
3662                line_count: 50,
3663                param_count: 0,
3664                exceeded: crate::health_types::ExceededThreshold::Both,
3665                severity: crate::health_types::FindingSeverity::Moderate,
3666                crap: None,
3667                coverage_pct: None,
3668                coverage_tier: None,
3669                coverage_source: None,
3670                inherited_from: None,
3671                component_rollup: None,
3672            }
3673            .into(),
3674            crate::health_types::ComplexityViolation {
3675                path: root.join("src/b.ts"),
3676                name: "fn2".to_string(),
3677                line: 5,
3678                col: 0,
3679                cyclomatic: 22,
3680                cognitive: 18,
3681                line_count: 40,
3682                param_count: 0,
3683                exceeded: crate::health_types::ExceededThreshold::Both,
3684                severity: crate::health_types::FindingSeverity::Moderate,
3685                crap: None,
3686                coverage_pct: None,
3687                coverage_tier: None,
3688                coverage_source: None,
3689                inherited_from: None,
3690                component_rollup: None,
3691            }
3692            .into(),
3693        ];
3694        let lines = build_health_human_lines(&report, &root);
3695        let text = plain(&lines);
3696        // Both file paths should appear
3697        assert!(text.contains("src/a.ts"));
3698        assert!(text.contains("src/b.ts"));
3699    }
3700
3701    #[test]
3702    fn findings_docs_link() {
3703        let root = PathBuf::from("/project");
3704        let mut report = empty_report();
3705        report.summary.functions_above_threshold = 1;
3706        report.findings = vec![
3707            crate::health_types::ComplexityViolation {
3708                path: root.join("src/a.ts"),
3709                name: "fn1".to_string(),
3710                line: 1,
3711                col: 0,
3712                cyclomatic: 25,
3713                cognitive: 20,
3714                line_count: 50,
3715                param_count: 0,
3716                exceeded: crate::health_types::ExceededThreshold::Both,
3717                severity: crate::health_types::FindingSeverity::Moderate,
3718                crap: None,
3719                coverage_pct: None,
3720                coverage_tier: None,
3721                coverage_source: None,
3722                inherited_from: None,
3723                component_rollup: None,
3724            }
3725            .into(),
3726        ];
3727        let lines = build_health_human_lines(&report, &root);
3728        let text = plain(&lines);
3729        assert!(text.contains("docs.fallow.tools/explanations/health#complexity-metrics"));
3730    }
3731
3732    // ── Hotspot score color thresholds ────────────────────────────
3733
3734    #[test]
3735    fn hotspot_score_high_medium_low() {
3736        let root = PathBuf::from("/project");
3737        let mut report = empty_report();
3738        report.hotspots = vec![
3739            crate::health_types::HotspotEntry {
3740                path: root.join("src/high.ts"),
3741                score: 80.0, // red: >= 70
3742                commits: 30,
3743                weighted_commits: 25.0,
3744                lines_added: 400,
3745                lines_deleted: 200,
3746                complexity_density: 0.9,
3747                fan_in: 8,
3748                trend: fallow_core::churn::ChurnTrend::Accelerating,
3749                ownership: None,
3750                is_test_path: false,
3751            }
3752            .into(),
3753            crate::health_types::HotspotEntry {
3754                path: root.join("src/medium.ts"),
3755                score: 45.0, // yellow: >= 30
3756                commits: 15,
3757                weighted_commits: 10.0,
3758                lines_added: 200,
3759                lines_deleted: 100,
3760                complexity_density: 0.5,
3761                fan_in: 4,
3762                trend: fallow_core::churn::ChurnTrend::Stable,
3763                ownership: None,
3764                is_test_path: false,
3765            }
3766            .into(),
3767            crate::health_types::HotspotEntry {
3768                path: root.join("src/low.ts"),
3769                score: 15.0, // green: < 30
3770                commits: 5,
3771                weighted_commits: 3.0,
3772                lines_added: 50,
3773                lines_deleted: 20,
3774                complexity_density: 0.2,
3775                fan_in: 1,
3776                trend: fallow_core::churn::ChurnTrend::Cooling,
3777                ownership: None,
3778                is_test_path: false,
3779            }
3780            .into(),
3781        ];
3782        let lines = build_health_human_lines(&report, &root);
3783        let text = plain(&lines);
3784        assert!(text.contains("80.0"));
3785        assert!(text.contains("45.0"));
3786        assert!(text.contains("15.0"));
3787        assert!(text.contains("Hotspots (3 files)"));
3788    }
3789
3790    // ── issue #547 path disambiguation regression tests ─────────────
3791
3792    #[test]
3793    fn rollup_breakdown_renders_workspace_relative_template_path() {
3794        // Angular monorepo: two `*.component.html` files share basenames. The
3795        // pre-#547 rendering printed bare `payment-list.component.html`; the
3796        // workspace-relative path resolves the ambiguity for both readers.
3797        let root = PathBuf::from("/project");
3798        let template =
3799            root.join("apps/admin/src/app/payments/payment-list/payment-list.component.html");
3800        let finding = crate::health_types::ComplexityViolation {
3801            path: root.join("apps/admin/src/app/payments/payment-list/payment-list.component.ts"),
3802            name: "<component>".to_string(),
3803            line: 1,
3804            col: 0,
3805            cyclomatic: 25,
3806            cognitive: 28,
3807            line_count: 0,
3808            param_count: 0,
3809            exceeded: crate::health_types::ExceededThreshold::Both,
3810            severity: crate::health_types::FindingSeverity::High,
3811            crap: None,
3812            coverage_pct: None,
3813            coverage_tier: None,
3814            coverage_source: None,
3815            inherited_from: None,
3816            component_rollup: Some(crate::health_types::ComponentRollup {
3817                component: "PaymentListComponent".to_string(),
3818                class_worst_function: "ngOnInit".to_string(),
3819                class_cyclomatic: 12,
3820                class_cognitive: 16,
3821                template_path: template,
3822                template_cyclomatic: 13,
3823                template_cognitive: 12,
3824            }),
3825        };
3826        let line = render_component_rollup_breakdown(&finding, &root)
3827            .expect("rollup payload should render a breakdown line");
3828        assert!(
3829            line.contains("apps/admin/src/app/payments/payment-list/payment-list.component.html"),
3830            "breakdown must include workspace-relative template path: {line}"
3831        );
3832        // Negative: bare basename must NOT be the rendered token. The
3833        // basename happens to appear at the tail of the relative path; we
3834        // assert the parent segments precede it.
3835        assert!(
3836            !line.contains(" payment-list.component.html"),
3837            "bare basename token must not be the rendered template: {line}"
3838        );
3839    }
3840
3841    #[test]
3842    fn inherited_from_renders_workspace_relative_owner_path() {
3843        // Synthetic <template> finding with CRAP redirected from the owning
3844        // component .ts. Pre-#547 the line said `(inherited from
3845        // permissions.component.ts)`; with #547 it identifies the file
3846        // unambiguously across Angular workspaces.
3847        let root = PathBuf::from("/project");
3848        let owner = root.join("apps/admin/src/app/auth/permissions/permissions.component.ts");
3849        let template_path =
3850            root.join("apps/admin/src/app/auth/permissions/permissions.component.html");
3851        let report = crate::health_types::HealthReport {
3852            findings: vec![
3853                crate::health_types::ComplexityViolation {
3854                    path: template_path,
3855                    name: "<template>".to_string(),
3856                    line: 1,
3857                    col: 0,
3858                    cyclomatic: 12,
3859                    cognitive: 14,
3860                    line_count: 0,
3861                    param_count: 0,
3862                    exceeded: crate::health_types::ExceededThreshold::Both,
3863                    severity: crate::health_types::FindingSeverity::High,
3864                    crap: Some(45.0),
3865                    coverage_pct: None,
3866                    coverage_tier: Some(crate::health_types::CoverageTier::Partial),
3867                    coverage_source: Some(
3868                        crate::health_types::CoverageSource::EstimatedComponentInherited,
3869                    ),
3870                    inherited_from: Some(owner),
3871                    component_rollup: None,
3872                }
3873                .into(),
3874            ],
3875            summary: crate::health_types::HealthSummary {
3876                files_analyzed: 1,
3877                functions_analyzed: 1,
3878                functions_above_threshold: 1,
3879                ..Default::default()
3880            },
3881            ..Default::default()
3882        };
3883        let lines = build_health_human_lines(&report, &root);
3884        let text = plain(&lines);
3885        assert!(
3886            text.contains(
3887                "(inherited from apps/admin/src/app/auth/permissions/permissions.component.ts)"
3888            ),
3889            "inherited-from suffix must use workspace-relative path: {text}"
3890        );
3891        // Negative: the bare basename suffix is the pre-#547 form.
3892        assert!(
3893            !text.contains("(inherited from permissions.component.ts)"),
3894            "bare basename suffix must not be rendered: {text}"
3895        );
3896    }
3897}