Skip to main content

fallow_cli/report/human/
health.rs

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