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