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