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