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