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 git 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                }
2045                .into(),
2046            ],
2047            summary: crate::health_types::HealthSummary {
2048                files_analyzed: 10,
2049                functions_analyzed: 50,
2050                functions_above_threshold: 1,
2051                ..Default::default()
2052            },
2053            ..Default::default()
2054        };
2055        let lines = build_health_human_lines(&report, &root);
2056        let text = plain(&lines);
2057        assert!(text.contains("High complexity functions (1)"));
2058        assert!(text.contains("src/parser.ts"));
2059        assert!(text.contains(":42"));
2060        assert!(text.contains("parseExpression"));
2061        assert!(text.contains("25 cyclomatic"));
2062        assert!(text.contains("30 cognitive"));
2063        assert!(text.contains("80 lines"));
2064    }
2065
2066    #[test]
2067    fn health_shown_vs_total_when_truncated() {
2068        let root = PathBuf::from("/project");
2069        let report = crate::health_types::HealthReport {
2070            findings: vec![
2071                crate::health_types::ComplexityViolation {
2072                    path: root.join("src/a.ts"),
2073                    name: "fn1".to_string(),
2074                    line: 1,
2075                    col: 0,
2076                    cyclomatic: 25,
2077                    cognitive: 20,
2078                    line_count: 50,
2079                    param_count: 0,
2080                    exceeded: crate::health_types::ExceededThreshold::Both,
2081                    severity: crate::health_types::FindingSeverity::High,
2082                    crap: None,
2083                    coverage_pct: None,
2084                    coverage_tier: None,
2085                    coverage_source: None,
2086                    inherited_from: None,
2087                    component_rollup: None,
2088                }
2089                .into(),
2090            ],
2091            summary: crate::health_types::HealthSummary {
2092                files_analyzed: 100,
2093                functions_analyzed: 500,
2094                functions_above_threshold: 10,
2095                ..Default::default()
2096            },
2097            ..Default::default()
2098        };
2099        let lines = build_health_human_lines(&report, &root);
2100        let text = plain(&lines);
2101        assert!(text.contains("1 shown, 10 total"));
2102    }
2103
2104    #[test]
2105    fn health_findings_explain_estimated_crap_scores() {
2106        let root = PathBuf::from("/project");
2107        let report = crate::health_types::HealthReport {
2108            findings: vec![
2109                crate::health_types::ComplexityViolation {
2110                    path: root.join("src/risky.ts"),
2111                    name: "risky".to_string(),
2112                    line: 7,
2113                    col: 0,
2114                    cyclomatic: 25,
2115                    cognitive: 20,
2116                    line_count: 80,
2117                    param_count: 0,
2118                    exceeded: crate::health_types::ExceededThreshold::Crap,
2119                    severity: crate::health_types::FindingSeverity::High,
2120                    crap: Some(650.0),
2121                    coverage_pct: None,
2122                    coverage_tier: Some(crate::health_types::CoverageTier::None),
2123                    coverage_source: Some(crate::health_types::CoverageSource::Estimated),
2124                    inherited_from: None,
2125                    component_rollup: None,
2126                }
2127                .into(),
2128            ],
2129            summary: crate::health_types::HealthSummary {
2130                files_analyzed: 1,
2131                functions_analyzed: 1,
2132                functions_above_threshold: 1,
2133                coverage_model: Some(crate::health_types::CoverageModel::StaticEstimated),
2134                coverage_source_consistency: None,
2135                ..Default::default()
2136            },
2137            ..Default::default()
2138        };
2139        let text = plain(&build_health_human_lines(&report, &root));
2140        assert!(text.contains("CRAP scores are estimated from export references"));
2141        assert!(text.contains("fallow health --coverage <coverage-final.json>"));
2142    }
2143
2144    #[test]
2145    fn health_findings_explain_mixed_istanbul_crap_scores() {
2146        let root = PathBuf::from("/project");
2147        let report = crate::health_types::HealthReport {
2148            findings: vec![
2149                crate::health_types::ComplexityViolation {
2150                    path: root.join("src/risky.ts"),
2151                    name: "risky".to_string(),
2152                    line: 7,
2153                    col: 0,
2154                    cyclomatic: 25,
2155                    cognitive: 20,
2156                    line_count: 80,
2157                    param_count: 0,
2158                    exceeded: crate::health_types::ExceededThreshold::Crap,
2159                    severity: crate::health_types::FindingSeverity::High,
2160                    crap: Some(45.0),
2161                    coverage_pct: Some(40.0),
2162                    coverage_tier: Some(crate::health_types::CoverageTier::Partial),
2163                    coverage_source: Some(crate::health_types::CoverageSource::Istanbul),
2164                    inherited_from: None,
2165                    component_rollup: None,
2166                }
2167                .into(),
2168            ],
2169            summary: crate::health_types::HealthSummary {
2170                files_analyzed: 1,
2171                functions_analyzed: 2,
2172                functions_above_threshold: 1,
2173                coverage_model: Some(crate::health_types::CoverageModel::Istanbul),
2174                coverage_source_consistency: None,
2175                istanbul_matched: Some(1),
2176                istanbul_total: Some(2),
2177                ..Default::default()
2178            },
2179            ..Default::default()
2180        };
2181        let text = plain(&build_health_human_lines(&report, &root));
2182        assert!(
2183            text.contains(
2184                "CRAP scores use Istanbul coverage where matched (1/2 functions); unmatched functions are estimated"
2185            ),
2186            "mixed Istanbul note missing from output: {text}"
2187        );
2188    }
2189
2190    #[test]
2191    fn health_findings_explain_istanbul_counts_without_summary_model() {
2192        let root = PathBuf::from("/project");
2193        let report = crate::health_types::HealthReport {
2194            findings: vec![
2195                crate::health_types::ComplexityViolation {
2196                    path: root.join("src/risky.ts"),
2197                    name: "risky".to_string(),
2198                    line: 7,
2199                    col: 0,
2200                    cyclomatic: 25,
2201                    cognitive: 20,
2202                    line_count: 80,
2203                    param_count: 0,
2204                    exceeded: crate::health_types::ExceededThreshold::Crap,
2205                    severity: crate::health_types::FindingSeverity::High,
2206                    crap: Some(45.0),
2207                    coverage_pct: None,
2208                    coverage_tier: Some(crate::health_types::CoverageTier::None),
2209                    coverage_source: Some(crate::health_types::CoverageSource::Estimated),
2210                    inherited_from: None,
2211                    component_rollup: None,
2212                }
2213                .into(),
2214            ],
2215            summary: crate::health_types::HealthSummary {
2216                files_analyzed: 1,
2217                functions_analyzed: 2,
2218                functions_above_threshold: 1,
2219                coverage_model: None,
2220                coverage_source_consistency: None,
2221                istanbul_matched: Some(1),
2222                istanbul_total: Some(2),
2223                ..Default::default()
2224            },
2225            ..Default::default()
2226        };
2227        let text = plain(&build_health_human_lines(&report, &root));
2228        assert!(
2229            text.contains(
2230                "CRAP scores use Istanbul coverage where matched (1/2 functions); unmatched functions are estimated"
2231            ),
2232            "Istanbul counts should drive the note even when coverage_model is omitted: {text}"
2233        );
2234    }
2235
2236    #[test]
2237    fn health_findings_grouped_by_file() {
2238        let root = PathBuf::from("/project");
2239        let report = crate::health_types::HealthReport {
2240            findings: vec![
2241                crate::health_types::ComplexityViolation {
2242                    path: root.join("src/parser.ts"),
2243                    name: "fn1".to_string(),
2244                    line: 10,
2245                    col: 0,
2246                    cyclomatic: 25,
2247                    cognitive: 20,
2248                    line_count: 40,
2249                    param_count: 0,
2250                    exceeded: crate::health_types::ExceededThreshold::Both,
2251                    severity: crate::health_types::FindingSeverity::High,
2252                    crap: None,
2253                    coverage_pct: None,
2254                    coverage_tier: None,
2255                    coverage_source: None,
2256                    inherited_from: None,
2257                    component_rollup: None,
2258                }
2259                .into(),
2260                crate::health_types::ComplexityViolation {
2261                    path: root.join("src/parser.ts"),
2262                    name: "fn2".to_string(),
2263                    line: 60,
2264                    col: 0,
2265                    cyclomatic: 22,
2266                    cognitive: 18,
2267                    line_count: 30,
2268                    param_count: 0,
2269                    exceeded: crate::health_types::ExceededThreshold::Both,
2270                    severity: crate::health_types::FindingSeverity::High,
2271                    crap: None,
2272                    coverage_pct: None,
2273                    coverage_tier: None,
2274                    coverage_source: None,
2275                    inherited_from: None,
2276                    component_rollup: None,
2277                }
2278                .into(),
2279            ],
2280            summary: crate::health_types::HealthSummary {
2281                files_analyzed: 10,
2282                functions_analyzed: 50,
2283                functions_above_threshold: 2,
2284                ..Default::default()
2285            },
2286            ..Default::default()
2287        };
2288        let lines = build_health_human_lines(&report, &root);
2289        let text = plain(&lines);
2290        let count = text.matches("src/parser.ts").count();
2291        assert_eq!(count, 1, "File header should appear once for grouped items");
2292    }
2293
2294    fn empty_report() -> crate::health_types::HealthReport {
2295        crate::health_types::HealthReport {
2296            summary: crate::health_types::HealthSummary {
2297                files_analyzed: 10,
2298                functions_analyzed: 50,
2299                ..Default::default()
2300            },
2301            ..Default::default()
2302        }
2303    }
2304
2305    #[test]
2306    fn health_runtime_coverage_renders_section() {
2307        let root = PathBuf::from("/project");
2308        let mut report = empty_report();
2309        report.runtime_coverage = Some(crate::health_types::RuntimeCoverageReport {
2310            schema_version: crate::health_types::RuntimeCoverageSchemaVersion::V1,
2311            verdict: crate::health_types::RuntimeCoverageReportVerdict::ColdCodeDetected,
2312            signals: Vec::new(),
2313            summary: crate::health_types::RuntimeCoverageSummary {
2314                data_source: crate::health_types::RuntimeCoverageDataSource::Local,
2315                last_received_at: None,
2316                functions_tracked: 4,
2317                functions_hit: 2,
2318                functions_unhit: 1,
2319                functions_untracked: 1,
2320                coverage_percent: 50.0,
2321                trace_count: 2_847_291,
2322                period_days: 30,
2323                deployments_seen: 14,
2324                capture_quality: None,
2325            },
2326            findings: vec![crate::health_types::RuntimeCoverageFinding {
2327                id: "fallow:prod:deadbeef".to_owned(),
2328                stable_id: None,
2329                path: root.join("src/cold.ts"),
2330                function: "coldPath".to_owned(),
2331                line: 14,
2332                verdict: crate::health_types::RuntimeCoverageVerdict::ReviewRequired,
2333                invocations: Some(0),
2334                confidence: crate::health_types::RuntimeCoverageConfidence::Medium,
2335                evidence: crate::health_types::RuntimeCoverageEvidence {
2336                    static_status: "used".to_owned(),
2337                    test_coverage: "not_covered".to_owned(),
2338                    v8_tracking: "tracked".to_owned(),
2339                    untracked_reason: None,
2340                    observation_days: 30,
2341                    deployments_observed: 14,
2342                },
2343                actions: vec![],
2344                source_hash: None,
2345            }],
2346            hot_paths: vec![crate::health_types::RuntimeCoverageHotPath {
2347                id: "fallow:hot:cafebabe".to_owned(),
2348                stable_id: None,
2349                path: root.join("src/hot.ts"),
2350                function: "hotPath".to_owned(),
2351                line: 3,
2352                end_line: 9,
2353                invocations: 250,
2354                percentile: 99,
2355                actions: vec![],
2356            }],
2357            blast_radius: vec![],
2358            importance: vec![],
2359            watermark: Some(crate::health_types::RuntimeCoverageWatermark::LicenseExpiredGrace),
2360            warnings: vec![],
2361        });
2362
2363        let text = plain(&build_health_human_lines(&report, &root));
2364        assert!(text.contains("Runtime coverage: cold code detected"));
2365        assert!(text.contains("src/cold.ts:14 coldPath [0 invocations, review required]"));
2366        assert!(text.contains("license expired grace active"));
2367        assert!(text.contains("hot paths:"));
2368        assert!(text.contains("src/hot.ts:3 hotPath (250 invocations, p99)"));
2369        assert!(!text.contains("short capture:"));
2370        assert!(!text.contains("start a trial"));
2371    }
2372
2373    #[test]
2374    fn health_coverage_intelligence_renders_findings_and_ambiguity_summary() {
2375        use crate::health_types::{
2376            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2377            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2378            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2379            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2380            CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2381        };
2382
2383        let root = PathBuf::from("/project");
2384        let mut report = empty_report();
2385        report.coverage_intelligence = Some(CoverageIntelligenceReport {
2386            schema_version: CoverageIntelligenceSchemaVersion::V1,
2387            verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2388            summary: CoverageIntelligenceSummary {
2389                findings: 1,
2390                high_confidence_deletes: 1,
2391                ..Default::default()
2392            },
2393            findings: vec![CoverageIntelligenceFinding {
2394                id: "fallow:coverage-intel:abc123".to_owned(),
2395                path: root.join("src/dead.ts"),
2396                identity: Some("deadPath".to_owned()),
2397                line: 9,
2398                verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2399                signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2400                recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
2401                confidence: CoverageIntelligenceConfidence::High,
2402                related_ids: vec![],
2403                evidence: CoverageIntelligenceEvidence {
2404                    match_confidence: CoverageIntelligenceMatchConfidence::Direct,
2405                    ..Default::default()
2406                },
2407                actions: vec![CoverageIntelligenceAction {
2408                    kind: "delete-after-confirming-owner".to_owned(),
2409                    description: "Confirm ownership before deleting".to_owned(),
2410                    auto_fixable: false,
2411                }],
2412            }],
2413        });
2414
2415        let text = plain(&build_health_human_lines(&report, &root));
2416        assert!(text.contains("Coverage intelligence"));
2417        assert!(text.contains("src/dead.ts:9 deadPath high-confidence-delete"));
2418        assert!(text.contains("Confirm ownership before deleting"));
2419
2420        report.coverage_intelligence = Some(CoverageIntelligenceReport {
2421            schema_version: CoverageIntelligenceSchemaVersion::V1,
2422            verdict: CoverageIntelligenceVerdict::Clean,
2423            summary: CoverageIntelligenceSummary {
2424                skipped_ambiguous_matches: 2,
2425                ..Default::default()
2426            },
2427            findings: vec![],
2428        });
2429        let text = plain(&build_health_human_lines(&report, &root));
2430        assert!(text.contains("skipped 2 ambiguous evidence matches"));
2431    }
2432
2433    fn runtime_coverage_report_with_quality(
2434        quality: Option<crate::health_types::RuntimeCoverageCaptureQuality>,
2435    ) -> crate::health_types::RuntimeCoverageReport {
2436        crate::health_types::RuntimeCoverageReport {
2437            schema_version: crate::health_types::RuntimeCoverageSchemaVersion::V1,
2438            verdict: crate::health_types::RuntimeCoverageReportVerdict::Clean,
2439            signals: Vec::new(),
2440            summary: crate::health_types::RuntimeCoverageSummary {
2441                data_source: crate::health_types::RuntimeCoverageDataSource::Local,
2442                last_received_at: None,
2443                functions_tracked: 10,
2444                functions_hit: 7,
2445                functions_unhit: 0,
2446                functions_untracked: 3,
2447                coverage_percent: 70.0,
2448                trace_count: 1_000,
2449                period_days: 1,
2450                deployments_seen: 1,
2451                capture_quality: quality,
2452            },
2453            findings: vec![],
2454            hot_paths: vec![],
2455            blast_radius: vec![],
2456            importance: vec![],
2457            watermark: None,
2458            warnings: vec![],
2459        }
2460    }
2461
2462    #[test]
2463    fn health_runtime_coverage_short_capture_shows_warning_and_prompt() {
2464        let root = PathBuf::from("/project");
2465        let mut report = empty_report();
2466        report.runtime_coverage = Some(runtime_coverage_report_with_quality(Some(
2467            crate::health_types::RuntimeCoverageCaptureQuality {
2468                window_seconds: 720, // 12 min
2469                instances_observed: 1,
2470                lazy_parse_warning: true,
2471                untracked_ratio_percent: 42.5,
2472            },
2473        )));
2474        let text = plain(&build_health_human_lines(&report, &root));
2475        assert!(
2476            text.contains(
2477                "note: short capture (12 min from 1 instance); 42.5% of functions untracked, lazy-parsed scripts may not appear."
2478            ),
2479            "warning banner missing or malformed in:\n{text}"
2480        );
2481        assert!(
2482            text.contains("extend the capture or switch to continuous monitoring"),
2483            "warning follow-up line missing in:\n{text}"
2484        );
2485        assert!(
2486            text.contains("captured 12 min from 1 instance."),
2487            "upgrade prompt header missing in:\n{text}"
2488        );
2489        assert!(
2490            text.contains("continuous monitoring over 30 days evaluates more paths"),
2491            "upgrade prompt body missing in:\n{text}"
2492        );
2493        assert!(
2494            text.contains("fallow license activate --trial --email you@company.com"),
2495            "trial CTA command missing in:\n{text}"
2496        );
2497    }
2498
2499    #[test]
2500    fn health_runtime_coverage_long_capture_shows_neither_warning_nor_prompt() {
2501        let root = PathBuf::from("/project");
2502        let mut report = empty_report();
2503        report.runtime_coverage = Some(runtime_coverage_report_with_quality(Some(
2504            crate::health_types::RuntimeCoverageCaptureQuality {
2505                window_seconds: 7 * 24 * 3600, // 7 days
2506                instances_observed: 4,
2507                lazy_parse_warning: false,
2508                untracked_ratio_percent: 3.1,
2509            },
2510        )));
2511        let text = plain(&build_health_human_lines(&report, &root));
2512        assert!(
2513            !text.contains("short capture"),
2514            "long capture should not emit short-capture warning:\n{text}"
2515        );
2516        assert!(
2517            !text.contains("start a trial"),
2518            "long capture should not emit trial CTA:\n{text}"
2519        );
2520    }
2521
2522    #[test]
2523    fn format_window_labels() {
2524        assert_eq!(super::format_window(30), "30 s");
2525        assert_eq!(super::format_window(60), "1 min");
2526        assert_eq!(super::format_window(720), "12 min");
2527        assert_eq!(super::format_window(3600 * 3), "3 h");
2528        assert_eq!(super::format_window(3600 * 24 * 3), "3 d");
2529    }
2530
2531    #[test]
2532    fn health_coverage_gaps_render_section() {
2533        use crate::health_types::*;
2534
2535        let root = PathBuf::from("/project");
2536        let mut report = empty_report();
2537        report.coverage_gaps = Some(CoverageGaps {
2538            summary: CoverageGapSummary {
2539                runtime_files: 1,
2540                covered_files: 0,
2541                file_coverage_pct: 0.0,
2542                untested_files: 1,
2543                untested_exports: 1,
2544            },
2545            files: vec![UntestedFileFinding::with_actions(
2546                UntestedFile {
2547                    path: root.join("src/app.ts"),
2548                    value_export_count: 2,
2549                },
2550                &root,
2551            )],
2552            exports: vec![UntestedExportFinding::with_actions(
2553                UntestedExport {
2554                    path: root.join("src/app.ts"),
2555                    export_name: "loader".into(),
2556                    line: 12,
2557                    col: 4,
2558                },
2559                &root,
2560            )],
2561        });
2562
2563        let text = plain(&build_health_human_lines(&report, &root));
2564        assert!(
2565            text.contains("Coverage gaps (1 untested file, 1 untested export, 0.0% file coverage)")
2566        );
2567        assert!(text.contains("src/app.ts"));
2568        assert!(text.contains("loader"));
2569    }
2570
2571    #[test]
2572    fn fmt_trend_val_percentage() {
2573        assert_eq!(fmt_trend_val(15.5, "%"), "15.5%");
2574        assert_eq!(fmt_trend_val(0.0, "%"), "0.0%");
2575    }
2576
2577    #[test]
2578    fn fmt_trend_val_integer_when_round() {
2579        assert_eq!(fmt_trend_val(72.0, ""), "72");
2580        assert_eq!(fmt_trend_val(5.0, "pts"), "5");
2581    }
2582
2583    #[test]
2584    fn fmt_trend_val_decimal_when_fractional() {
2585        assert_eq!(fmt_trend_val(4.7, ""), "4.7");
2586        assert_eq!(fmt_trend_val(1.3, "pts"), "1.3");
2587    }
2588
2589    #[test]
2590    fn fmt_trend_delta_percentage() {
2591        assert_eq!(fmt_trend_delta(2.5, "%"), "+2.5%");
2592        assert_eq!(fmt_trend_delta(-1.3, "%"), "-1.3%");
2593    }
2594
2595    #[test]
2596    fn fmt_trend_delta_integer_when_round() {
2597        assert_eq!(fmt_trend_delta(5.0, ""), "+5");
2598        assert_eq!(fmt_trend_delta(-3.0, "pts"), "-3");
2599    }
2600
2601    #[test]
2602    fn fmt_trend_delta_decimal_when_fractional() {
2603        assert_eq!(fmt_trend_delta(4.9, ""), "+4.9");
2604        assert_eq!(fmt_trend_delta(-0.7, "pts"), "-0.7");
2605    }
2606
2607    #[test]
2608    fn health_score_grade_a_display() {
2609        let root = PathBuf::from("/project");
2610        let mut report = empty_report();
2611        report.health_score = Some(crate::health_types::HealthScore {
2612            formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2613            score: 92.0,
2614            grade: "A",
2615            penalties: crate::health_types::HealthScorePenalties {
2616                dead_files: Some(3.0),
2617                dead_exports: Some(2.0),
2618                complexity: 1.5,
2619                p90_complexity: 1.5,
2620                maintainability: Some(0.0),
2621                hotspots: Some(0.0),
2622                unused_deps: Some(0.0),
2623                circular_deps: Some(0.0),
2624                unit_size: None,
2625                coupling: None,
2626                duplication: None,
2627            },
2628        });
2629        let lines = build_health_human_lines(&report, &root);
2630        let text = plain(&lines);
2631        assert!(text.contains("Health score:"));
2632        assert!(text.contains("92 A"));
2633        assert!(text.contains("dead files -3.0"));
2634        assert!(text.contains("dead exports -2.0"));
2635        assert!(text.contains("complexity -1.5"));
2636        assert!(text.contains("p90 -1.5"));
2637    }
2638
2639    #[test]
2640    fn health_score_grade_b_display() {
2641        let root = PathBuf::from("/project");
2642        let mut report = empty_report();
2643        report.health_score = Some(crate::health_types::HealthScore {
2644            formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2645            score: 76.0,
2646            grade: "B",
2647            penalties: crate::health_types::HealthScorePenalties {
2648                dead_files: Some(5.0),
2649                dead_exports: Some(6.0),
2650                complexity: 3.0,
2651                p90_complexity: 2.0,
2652                maintainability: Some(4.0),
2653                hotspots: Some(2.0),
2654                unused_deps: Some(1.0),
2655                circular_deps: Some(1.0),
2656                unit_size: None,
2657                coupling: None,
2658                duplication: None,
2659            },
2660        });
2661        let lines = build_health_human_lines(&report, &root);
2662        let text = plain(&lines);
2663        assert!(text.contains("76 B"));
2664        assert!(text.contains("dead exports -6.0"));
2665        assert!(text.contains("maintainability -4.0"));
2666        assert!(text.contains("hotspots -2.0"));
2667        assert!(text.contains("unused deps -1.0"));
2668        assert!(text.contains("circular deps -1.0"));
2669    }
2670
2671    #[test]
2672    fn health_score_grade_c_display() {
2673        let root = PathBuf::from("/project");
2674        let mut report = empty_report();
2675        report.health_score = Some(crate::health_types::HealthScore {
2676            formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2677            score: 60.0,
2678            grade: "C",
2679            penalties: crate::health_types::HealthScorePenalties {
2680                dead_files: Some(10.0),
2681                dead_exports: Some(10.0),
2682                complexity: 10.0,
2683                p90_complexity: 5.0,
2684                maintainability: Some(5.0),
2685                hotspots: None,
2686                unused_deps: None,
2687                circular_deps: None,
2688                unit_size: None,
2689                coupling: None,
2690                duplication: None,
2691            },
2692        });
2693        let lines = build_health_human_lines(&report, &root);
2694        let text = plain(&lines);
2695        assert!(text.contains("60 C"));
2696    }
2697
2698    #[test]
2699    fn health_score_grade_f_display() {
2700        let root = PathBuf::from("/project");
2701        let mut report = empty_report();
2702        report.health_score = Some(crate::health_types::HealthScore {
2703            formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2704            score: 30.0,
2705            grade: "F",
2706            penalties: crate::health_types::HealthScorePenalties {
2707                dead_files: Some(15.0),
2708                dead_exports: Some(15.0),
2709                complexity: 20.0,
2710                p90_complexity: 10.0,
2711                maintainability: Some(10.0),
2712                hotspots: None,
2713                unused_deps: None,
2714                circular_deps: None,
2715                unit_size: None,
2716                coupling: None,
2717                duplication: None,
2718            },
2719        });
2720        let lines = build_health_human_lines(&report, &root);
2721        let text = plain(&lines);
2722        assert!(text.contains("30 F"));
2723    }
2724
2725    #[test]
2726    fn health_score_na_components_shown() {
2727        let root = PathBuf::from("/project");
2728        let mut report = empty_report();
2729        report.health_score = Some(crate::health_types::HealthScore {
2730            formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2731            score: 90.0,
2732            grade: "A",
2733            penalties: crate::health_types::HealthScorePenalties {
2734                dead_files: None,
2735                dead_exports: None,
2736                complexity: 0.0,
2737                p90_complexity: 0.0,
2738                maintainability: None,
2739                hotspots: None,
2740                unused_deps: None,
2741                circular_deps: None,
2742                unit_size: None,
2743                coupling: None,
2744                duplication: None,
2745            },
2746        });
2747        let lines = build_health_human_lines(&report, &root);
2748        let text = plain(&lines);
2749        assert!(text.contains("N/A: dead code, maintainability, hotspots"));
2750        assert!(text.contains("enable the corresponding analysis flags"));
2751    }
2752
2753    #[test]
2754    fn health_score_no_na_when_all_present() {
2755        let root = PathBuf::from("/project");
2756        let mut report = empty_report();
2757        report.health_score = Some(crate::health_types::HealthScore {
2758            formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2759            score: 85.0,
2760            grade: "A",
2761            penalties: crate::health_types::HealthScorePenalties {
2762                dead_files: Some(0.0),
2763                dead_exports: Some(0.0),
2764                complexity: 0.0,
2765                p90_complexity: 0.0,
2766                maintainability: Some(0.0),
2767                hotspots: Some(0.0),
2768                unused_deps: Some(0.0),
2769                circular_deps: Some(0.0),
2770                unit_size: None,
2771                coupling: None,
2772                duplication: None,
2773            },
2774        });
2775        let lines = build_health_human_lines(&report, &root);
2776        let text = plain(&lines);
2777        assert!(!text.contains("N/A:"));
2778    }
2779
2780    #[test]
2781    fn health_score_zero_penalties_suppressed() {
2782        let root = PathBuf::from("/project");
2783        let mut report = empty_report();
2784        report.health_score = Some(crate::health_types::HealthScore {
2785            formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
2786            score: 100.0,
2787            grade: "A",
2788            penalties: crate::health_types::HealthScorePenalties {
2789                dead_files: Some(0.0),
2790                dead_exports: Some(0.0),
2791                complexity: 0.0,
2792                p90_complexity: 0.0,
2793                maintainability: Some(0.0),
2794                hotspots: Some(0.0),
2795                unused_deps: Some(0.0),
2796                circular_deps: Some(0.0),
2797                unit_size: None,
2798                coupling: None,
2799                duplication: None,
2800            },
2801        });
2802        let lines = build_health_human_lines(&report, &root);
2803        let text = plain(&lines);
2804        assert!(!text.contains("dead files"));
2805        assert!(!text.contains("complexity -"));
2806    }
2807
2808    #[test]
2809    fn health_trend_improving_display() {
2810        let root = PathBuf::from("/project");
2811        let mut report = empty_report();
2812        report.health_trend = Some(crate::health_types::HealthTrend {
2813            compared_to: crate::health_types::TrendPoint {
2814                timestamp: "2026-03-25T14:30:00Z".into(),
2815                git_sha: Some("abc1234".into()),
2816                score: Some(72.0),
2817                grade: Some("B".into()),
2818                coverage_model: None,
2819                snapshot_schema_version: None,
2820            },
2821            metrics: vec![
2822                crate::health_types::TrendMetric {
2823                    name: "score",
2824                    label: "Health Score",
2825                    previous: 72.0,
2826                    current: 85.0,
2827                    delta: 13.0,
2828                    direction: crate::health_types::TrendDirection::Improving,
2829                    unit: "",
2830                    previous_count: None,
2831                    current_count: None,
2832                },
2833                crate::health_types::TrendMetric {
2834                    name: "dead_file_pct",
2835                    label: "Dead Files",
2836                    previous: 10.0,
2837                    current: 5.0,
2838                    delta: -5.0,
2839                    direction: crate::health_types::TrendDirection::Improving,
2840                    unit: "%",
2841                    previous_count: None,
2842                    current_count: None,
2843                },
2844            ],
2845            snapshots_loaded: 2,
2846            overall_direction: crate::health_types::TrendDirection::Improving,
2847        });
2848        let lines = build_health_human_lines(&report, &root);
2849        let text = plain(&lines);
2850        assert!(text.contains("Trend:"));
2851        assert!(text.contains("improving"));
2852        assert!(text.contains("vs 2026-03-25"));
2853        assert!(text.contains("abc1234"));
2854        assert!(text.contains("Health Score"));
2855        assert!(text.contains("+13"));
2856        assert!(text.contains("Dead Files"));
2857        assert!(text.contains("-5.0%"));
2858    }
2859
2860    #[test]
2861    fn health_trend_declining_display() {
2862        let root = PathBuf::from("/project");
2863        let mut report = empty_report();
2864        report.health_trend = Some(crate::health_types::HealthTrend {
2865            compared_to: crate::health_types::TrendPoint {
2866                timestamp: "2026-03-20T10:00:00Z".into(),
2867                git_sha: None,
2868                score: None,
2869                grade: None,
2870                coverage_model: None,
2871                snapshot_schema_version: None,
2872            },
2873            metrics: vec![crate::health_types::TrendMetric {
2874                name: "unused_deps",
2875                label: "Unused Deps",
2876                previous: 5.0,
2877                current: 10.0,
2878                delta: 5.0,
2879                direction: crate::health_types::TrendDirection::Declining,
2880                unit: "",
2881                previous_count: None,
2882                current_count: None,
2883            }],
2884            snapshots_loaded: 1,
2885            overall_direction: crate::health_types::TrendDirection::Declining,
2886        });
2887        let lines = build_health_human_lines(&report, &root);
2888        let text = plain(&lines);
2889        assert!(text.contains("declining"));
2890        assert!(text.contains("Unused Deps"));
2891    }
2892
2893    #[test]
2894    fn health_trend_all_stable_collapsed() {
2895        let root = PathBuf::from("/project");
2896        let mut report = empty_report();
2897        report.health_trend = Some(crate::health_types::HealthTrend {
2898            compared_to: crate::health_types::TrendPoint {
2899                timestamp: "2026-03-25T14:30:00Z".into(),
2900                git_sha: Some("def5678".into()),
2901                score: Some(80.0),
2902                grade: Some("B".into()),
2903                coverage_model: None,
2904                snapshot_schema_version: None,
2905            },
2906            metrics: vec![
2907                crate::health_types::TrendMetric {
2908                    name: "score",
2909                    label: "Health Score",
2910                    previous: 80.0,
2911                    current: 80.0,
2912                    delta: 0.0,
2913                    direction: crate::health_types::TrendDirection::Stable,
2914                    unit: "",
2915                    previous_count: None,
2916                    current_count: None,
2917                },
2918                crate::health_types::TrendMetric {
2919                    name: "avg_cyclomatic",
2920                    label: "Avg Cyclomatic",
2921                    previous: 2.0,
2922                    current: 2.0,
2923                    delta: 0.0,
2924                    direction: crate::health_types::TrendDirection::Stable,
2925                    unit: "",
2926                    previous_count: None,
2927                    current_count: None,
2928                },
2929            ],
2930            snapshots_loaded: 3,
2931            overall_direction: crate::health_types::TrendDirection::Stable,
2932        });
2933        let lines = build_health_human_lines(&report, &root);
2934        let text = plain(&lines);
2935        assert!(text.contains("stable"));
2936        assert!(text.contains("All 2 metrics unchanged"));
2937        assert!(!text.contains("Health Score"));
2938    }
2939
2940    #[test]
2941    fn health_trend_without_sha() {
2942        let root = PathBuf::from("/project");
2943        let mut report = empty_report();
2944        report.health_trend = Some(crate::health_types::HealthTrend {
2945            compared_to: crate::health_types::TrendPoint {
2946                timestamp: "2026-03-20T10:00:00Z".into(),
2947                git_sha: None,
2948                score: None,
2949                grade: None,
2950                coverage_model: None,
2951                snapshot_schema_version: None,
2952            },
2953            metrics: vec![crate::health_types::TrendMetric {
2954                name: "score",
2955                label: "Health Score",
2956                previous: 80.0,
2957                current: 82.0,
2958                delta: 2.0,
2959                direction: crate::health_types::TrendDirection::Improving,
2960                unit: "",
2961                previous_count: None,
2962                current_count: None,
2963            }],
2964            snapshots_loaded: 1,
2965            overall_direction: crate::health_types::TrendDirection::Improving,
2966        });
2967        let lines = build_health_human_lines(&report, &root);
2968        let text = plain(&lines);
2969        assert!(text.contains("vs 2026-03-20"));
2970        assert!(!text.contains("\u{00b7}"));
2971    }
2972
2973    #[test]
2974    fn vital_signs_shown_without_trend() {
2975        let root = PathBuf::from("/project");
2976        let mut report = empty_report();
2977        report.vital_signs = Some(crate::health_types::VitalSigns {
2978            dead_file_pct: Some(3.2),
2979            dead_export_pct: Some(8.1),
2980            avg_cyclomatic: 4.7,
2981            p90_cyclomatic: 12,
2982            duplication_pct: None,
2983            hotspot_count: Some(2),
2984            maintainability_avg: Some(72.4),
2985            unused_dep_count: Some(3),
2986            circular_dep_count: Some(1),
2987            counts: None,
2988            unit_size_profile: None,
2989            unit_interfacing_profile: None,
2990            p95_fan_in: None,
2991            coupling_high_pct: None,
2992            total_loc: 42_381,
2993            ..Default::default()
2994        });
2995        report.hotspot_summary = Some(crate::health_types::HotspotSummary {
2996            since: "6 months".to_string(),
2997            min_commits: 3,
2998            files_analyzed: 50,
2999            files_excluded: 20,
3000            shallow_clone: false,
3001        });
3002        let lines = build_health_human_lines(&report, &root);
3003        let text = plain(&lines);
3004        assert!(text.contains("42,381 LOC"));
3005        assert!(text.contains("dead files 3.2%"));
3006        assert!(text.contains("dead exports 8.1%"));
3007        assert!(text.contains("avg cyclomatic 4.7"));
3008        assert!(text.contains("p90 cyclomatic 12"));
3009        assert!(text.contains("maintainability 72.4"));
3010        assert!(text.contains("2 churn hotspots (since 6 months)"));
3011        assert!(text.contains("3 unused deps"));
3012        assert!(text.contains("1 circular dep"));
3013    }
3014
3015    #[test]
3016    fn vital_signs_zero_hotspots_still_show_window() {
3017        let root = PathBuf::from("/project");
3018        let mut report = empty_report();
3019        report.vital_signs = Some(crate::health_types::VitalSigns {
3020            avg_cyclomatic: 2.0,
3021            p90_cyclomatic: 5,
3022            hotspot_count: Some(0),
3023            total_loc: 1_000,
3024            ..Default::default()
3025        });
3026        report.hotspot_summary = Some(crate::health_types::HotspotSummary {
3027            since: "90 days".to_string(),
3028            min_commits: 3,
3029            files_analyzed: 10,
3030            files_excluded: 0,
3031            shallow_clone: false,
3032        });
3033        let lines = build_health_human_lines(&report, &root);
3034        let text = plain(&lines);
3035        assert!(text.contains("0 churn hotspots (since 90 days)"));
3036        assert!(!text.contains("Hotspots ("));
3037    }
3038
3039    #[test]
3040    fn vital_signs_hotspot_count_without_summary_omits_window() {
3041        let root = PathBuf::from("/project");
3042        let mut report = empty_report();
3043        report.vital_signs = Some(crate::health_types::VitalSigns {
3044            avg_cyclomatic: 2.0,
3045            p90_cyclomatic: 5,
3046            hotspot_count: Some(1),
3047            total_loc: 1_000,
3048            ..Default::default()
3049        });
3050        report.hotspot_summary = None;
3051        let lines = build_health_human_lines(&report, &root);
3052        let text = plain(&lines);
3053        assert!(text.contains("1 churn hotspot"));
3054        assert!(!text.contains("(since"));
3055    }
3056
3057    #[test]
3058    fn vital_signs_suppressed_when_trend_active() {
3059        let root = PathBuf::from("/project");
3060        let mut report = empty_report();
3061        report.vital_signs = Some(crate::health_types::VitalSigns {
3062            dead_file_pct: Some(3.2),
3063            dead_export_pct: Some(8.1),
3064            avg_cyclomatic: 4.7,
3065            p90_cyclomatic: 12,
3066            duplication_pct: None,
3067            hotspot_count: Some(2),
3068            maintainability_avg: Some(72.4),
3069            unused_dep_count: None,
3070            circular_dep_count: None,
3071            counts: None,
3072            unit_size_profile: None,
3073            unit_interfacing_profile: None,
3074            p95_fan_in: None,
3075            coupling_high_pct: None,
3076            total_loc: 0,
3077            ..Default::default()
3078        });
3079        report.health_trend = Some(crate::health_types::HealthTrend {
3080            compared_to: crate::health_types::TrendPoint {
3081                timestamp: "2026-03-25T14:30:00Z".into(),
3082                git_sha: None,
3083                score: None,
3084                grade: None,
3085                coverage_model: None,
3086                snapshot_schema_version: None,
3087            },
3088            metrics: vec![],
3089            snapshots_loaded: 1,
3090            overall_direction: crate::health_types::TrendDirection::Stable,
3091        });
3092        let lines = build_health_human_lines(&report, &root);
3093        let text = plain(&lines);
3094        assert!(!text.contains("dead files"));
3095        assert!(!text.contains("avg cyclomatic"));
3096    }
3097
3098    #[test]
3099    fn vital_signs_optional_fields_omitted_when_none() {
3100        let root = PathBuf::from("/project");
3101        let mut report = empty_report();
3102        report.vital_signs = Some(crate::health_types::VitalSigns {
3103            dead_file_pct: None,
3104            dead_export_pct: None,
3105            avg_cyclomatic: 2.0,
3106            p90_cyclomatic: 5,
3107            duplication_pct: None,
3108            hotspot_count: None,
3109            maintainability_avg: None,
3110            unused_dep_count: None,
3111            circular_dep_count: None,
3112            counts: None,
3113            unit_size_profile: None,
3114            unit_interfacing_profile: None,
3115            p95_fan_in: None,
3116            coupling_high_pct: None,
3117            total_loc: 0,
3118            ..Default::default()
3119        });
3120        let lines = build_health_human_lines(&report, &root);
3121        let text = plain(&lines);
3122        assert!(!text.contains("dead files"));
3123        assert!(!text.contains("dead exports"));
3124        assert!(!text.contains("maintainability "));
3125        assert!(!text.contains("hotspot"));
3126        assert!(text.contains("avg cyclomatic 2.0"));
3127        assert!(text.contains("p90 cyclomatic 5"));
3128    }
3129
3130    #[test]
3131    fn vital_signs_zero_counts_suppressed() {
3132        let root = PathBuf::from("/project");
3133        let mut report = empty_report();
3134        report.vital_signs = Some(crate::health_types::VitalSigns {
3135            dead_file_pct: None,
3136            dead_export_pct: None,
3137            avg_cyclomatic: 1.0,
3138            p90_cyclomatic: 2,
3139            duplication_pct: None,
3140            hotspot_count: None,
3141            maintainability_avg: None,
3142            unused_dep_count: Some(0),
3143            circular_dep_count: Some(0),
3144            counts: None,
3145            unit_size_profile: None,
3146            unit_interfacing_profile: None,
3147            p95_fan_in: None,
3148            coupling_high_pct: None,
3149            total_loc: 0,
3150            ..Default::default()
3151        });
3152        let lines = build_health_human_lines(&report, &root);
3153        let text = plain(&lines);
3154        assert!(!text.contains("unused dep"));
3155        assert!(!text.contains("circular dep"));
3156    }
3157
3158    #[test]
3159    fn vital_signs_plural_vs_singular() {
3160        let root = PathBuf::from("/project");
3161        let mut report = empty_report();
3162        report.vital_signs = Some(crate::health_types::VitalSigns {
3163            dead_file_pct: None,
3164            dead_export_pct: None,
3165            avg_cyclomatic: 1.0,
3166            p90_cyclomatic: 2,
3167            duplication_pct: None,
3168            hotspot_count: Some(1),
3169            maintainability_avg: None,
3170            unused_dep_count: Some(1),
3171            circular_dep_count: Some(2),
3172            counts: None,
3173            unit_size_profile: None,
3174            unit_interfacing_profile: None,
3175            p95_fan_in: None,
3176            coupling_high_pct: None,
3177            total_loc: 0,
3178            ..Default::default()
3179        });
3180        let lines = build_health_human_lines(&report, &root);
3181        let text = plain(&lines);
3182        assert!(text.contains("1 churn hotspot"));
3183        assert!(!text.contains("1 churn hotspots"));
3184        assert!(text.contains("1 unused dep"));
3185        assert!(!text.contains("1 unused deps"));
3186        assert!(text.contains("2 circular deps"));
3187    }
3188
3189    #[test]
3190    fn file_scores_single_entry() {
3191        let root = PathBuf::from("/project");
3192        let mut report = empty_report();
3193        report.file_scores = vec![crate::health_types::FileHealthScore {
3194            path: root.join("src/utils.ts"),
3195            fan_in: 5,
3196            fan_out: 3,
3197            dead_code_ratio: 0.15,
3198            complexity_density: 0.42,
3199            maintainability_index: 85.3,
3200            total_cyclomatic: 12,
3201            total_cognitive: 8,
3202            function_count: 4,
3203            lines: 200,
3204            crap_max: 0.0,
3205            crap_above_threshold: 0,
3206        }];
3207        let lines = build_health_human_lines(&report, &root);
3208        let text = plain(&lines);
3209        assert!(text.contains("File health scores (1 files)"));
3210        assert!(text.contains("85.3"));
3211        assert!(text.contains("src/utils.ts"));
3212        assert!(text.contains("200 LOC"));
3213        assert!(text.contains("5 fan-in"));
3214        assert!(text.contains("3 fan-out"));
3215        assert!(text.contains("15% dead"));
3216        assert!(text.contains("0.42 density"));
3217    }
3218
3219    #[test]
3220    fn file_scores_concern_tag_marks_risk_vs_structure() {
3221        let root = PathBuf::from("/project");
3222        let mut report = empty_report();
3223        report.file_scores = vec![
3224            crate::health_types::FileHealthScore {
3225                path: root.join("src/risky.ts"),
3226                fan_in: 0,
3227                fan_out: 0,
3228                dead_code_ratio: 0.0,
3229                complexity_density: 0.2,
3230                maintainability_index: 85.0,
3231                total_cyclomatic: 10,
3232                total_cognitive: 8,
3233                function_count: 1,
3234                lines: 100,
3235                crap_max: 552.0,
3236                crap_above_threshold: 1,
3237            },
3238            crate::health_types::FileHealthScore {
3239                path: root.join("src/messy.ts"),
3240                fan_in: 0,
3241                fan_out: 0,
3242                dead_code_ratio: 0.0,
3243                complexity_density: 0.3,
3244                maintainability_index: 30.0,
3245                total_cyclomatic: 5,
3246                total_cognitive: 3,
3247                function_count: 1,
3248                lines: 100,
3249                crap_max: 2.0,
3250                crap_above_threshold: 0,
3251            },
3252        ];
3253        let text = plain(&build_health_human_lines(&report, &root));
3254        let risky_line = text
3255            .lines()
3256            .find(|l| l.contains("risky.ts"))
3257            .expect("risky path line");
3258        assert!(
3259            risky_line.trim_end().ends_with("risk"),
3260            "expected risk tag, got: {risky_line:?}"
3261        );
3262        let messy_line = text
3263            .lines()
3264            .find(|l| l.contains("messy.ts"))
3265            .expect("messy path line");
3266        assert!(
3267            messy_line.trim_end().ends_with("structure"),
3268            "expected structure tag, got: {messy_line:?}"
3269        );
3270    }
3271
3272    #[test]
3273    fn file_scores_mi_color_thresholds() {
3274        let root = PathBuf::from("/project");
3275        let mut report = empty_report();
3276        report.file_scores = vec![
3277            crate::health_types::FileHealthScore {
3278                path: root.join("src/good.ts"),
3279                fan_in: 1,
3280                fan_out: 1,
3281                dead_code_ratio: 0.0,
3282                complexity_density: 0.1,
3283                maintainability_index: 90.0, // green: >= 80
3284                total_cyclomatic: 2,
3285                total_cognitive: 1,
3286                function_count: 1,
3287                lines: 50,
3288                crap_max: 0.0,
3289                crap_above_threshold: 0,
3290            },
3291            crate::health_types::FileHealthScore {
3292                path: root.join("src/okay.ts"),
3293                fan_in: 2,
3294                fan_out: 3,
3295                dead_code_ratio: 0.1,
3296                complexity_density: 0.3,
3297                maintainability_index: 65.0, // yellow: >= 50
3298                total_cyclomatic: 8,
3299                total_cognitive: 5,
3300                function_count: 3,
3301                lines: 100,
3302                crap_max: 0.0,
3303                crap_above_threshold: 0,
3304            },
3305            crate::health_types::FileHealthScore {
3306                path: root.join("src/bad.ts"),
3307                fan_in: 8,
3308                fan_out: 12,
3309                dead_code_ratio: 0.5,
3310                complexity_density: 0.9,
3311                maintainability_index: 30.0, // red: < 50
3312                total_cyclomatic: 40,
3313                total_cognitive: 30,
3314                function_count: 10,
3315                lines: 500,
3316                crap_max: 0.0,
3317                crap_above_threshold: 0,
3318            },
3319        ];
3320        let lines = build_health_human_lines(&report, &root);
3321        let text = plain(&lines);
3322        assert!(text.contains("File health scores (3 files)"));
3323        assert!(text.contains("90.0"));
3324        assert!(text.contains("65.0"));
3325        assert!(text.contains("30.0"));
3326    }
3327
3328    #[test]
3329    fn file_scores_truncation_above_max_flat_items() {
3330        let root = PathBuf::from("/project");
3331        let mut report = empty_report();
3332        for i in 0..12 {
3333            report
3334                .file_scores
3335                .push(crate::health_types::FileHealthScore {
3336                    path: root.join(format!("src/file{i}.ts")),
3337                    fan_in: 1,
3338                    fan_out: 1,
3339                    dead_code_ratio: 0.0,
3340                    complexity_density: 0.1,
3341                    maintainability_index: 80.0,
3342                    total_cyclomatic: 2,
3343                    total_cognitive: 1,
3344                    function_count: 1,
3345                    lines: 50,
3346                    crap_max: 0.0,
3347                    crap_above_threshold: 0,
3348                });
3349        }
3350        let lines = build_health_human_lines(&report, &root);
3351        let text = plain(&lines);
3352        assert!(text.contains("File health scores (12 files)"));
3353        assert!(text.contains("... and 2 more files"));
3354        assert!(text.contains("file0.ts"));
3355        assert!(text.contains("file9.ts"));
3356        assert!(!text.contains("file10.ts"));
3357        assert!(!text.contains("file11.ts"));
3358    }
3359
3360    #[test]
3361    fn file_scores_docs_link() {
3362        let root = PathBuf::from("/project");
3363        let mut report = empty_report();
3364        report.file_scores = vec![crate::health_types::FileHealthScore {
3365            path: root.join("src/a.ts"),
3366            fan_in: 1,
3367            fan_out: 1,
3368            dead_code_ratio: 0.0,
3369            complexity_density: 0.1,
3370            maintainability_index: 80.0,
3371            total_cyclomatic: 2,
3372            total_cognitive: 1,
3373            function_count: 1,
3374            lines: 50,
3375            crap_max: 0.0,
3376            crap_above_threshold: 0,
3377        }];
3378        let lines = build_health_human_lines(&report, &root);
3379        let text = plain(&lines);
3380        assert!(text.contains("docs.fallow.tools/explanations/health#file-health-scores"));
3381    }
3382
3383    #[test]
3384    fn hotspots_accelerating_trend() {
3385        let root = PathBuf::from("/project");
3386        let mut report = empty_report();
3387        report.hotspots = vec![
3388            crate::health_types::HotspotEntry {
3389                path: root.join("src/core.ts"),
3390                score: 75.0,
3391                commits: 42,
3392                weighted_commits: 30.0,
3393                lines_added: 500,
3394                lines_deleted: 200,
3395                complexity_density: 0.85,
3396                fan_in: 10,
3397                trend: fallow_core::churn::ChurnTrend::Accelerating,
3398                ownership: None,
3399                is_test_path: false,
3400            }
3401            .into(),
3402        ];
3403        let lines = build_health_human_lines(&report, &root);
3404        let text = plain(&lines);
3405        assert!(text.contains("Hotspots (1 files)"));
3406        assert!(text.contains("75.0"));
3407        assert!(text.contains("src/core.ts"));
3408        assert!(text.contains("42 commits"));
3409        assert!(text.contains("700 churn"));
3410        assert!(text.contains("0.85 density"));
3411        assert!(text.contains("10 fan-in"));
3412        assert!(text.contains("accelerating"));
3413    }
3414
3415    #[test]
3416    fn hotspots_cooling_trend() {
3417        let root = PathBuf::from("/project");
3418        let mut report = empty_report();
3419        report.hotspots = vec![
3420            crate::health_types::HotspotEntry {
3421                path: root.join("src/old.ts"),
3422                score: 20.0,
3423                commits: 5,
3424                weighted_commits: 2.0,
3425                lines_added: 50,
3426                lines_deleted: 30,
3427                complexity_density: 0.3,
3428                fan_in: 2,
3429                trend: fallow_core::churn::ChurnTrend::Cooling,
3430                ownership: None,
3431                is_test_path: false,
3432            }
3433            .into(),
3434        ];
3435        let lines = build_health_human_lines(&report, &root);
3436        let text = plain(&lines);
3437        assert!(text.contains("20.0"));
3438        assert!(text.contains("cooling"));
3439    }
3440
3441    #[test]
3442    fn hotspots_stable_trend() {
3443        let root = PathBuf::from("/project");
3444        let mut report = empty_report();
3445        report.hotspots = vec![
3446            crate::health_types::HotspotEntry {
3447                path: root.join("src/mid.ts"),
3448                score: 45.0,
3449                commits: 15,
3450                weighted_commits: 10.0,
3451                lines_added: 200,
3452                lines_deleted: 100,
3453                complexity_density: 0.5,
3454                fan_in: 5,
3455                trend: fallow_core::churn::ChurnTrend::Stable,
3456                ownership: None,
3457                is_test_path: false,
3458            }
3459            .into(),
3460        ];
3461        let lines = build_health_human_lines(&report, &root);
3462        let text = plain(&lines);
3463        assert!(text.contains("45.0"));
3464        assert!(text.contains("stable"));
3465    }
3466
3467    #[test]
3468    fn hotspots_with_summary_and_since() {
3469        let root = PathBuf::from("/project");
3470        let mut report = empty_report();
3471        report.hotspots = vec![
3472            crate::health_types::HotspotEntry {
3473                path: root.join("src/a.ts"),
3474                score: 50.0,
3475                commits: 10,
3476                weighted_commits: 8.0,
3477                lines_added: 100,
3478                lines_deleted: 50,
3479                complexity_density: 0.4,
3480                fan_in: 3,
3481                trend: fallow_core::churn::ChurnTrend::Stable,
3482                ownership: None,
3483                is_test_path: false,
3484            }
3485            .into(),
3486        ];
3487        report.hotspot_summary = Some(crate::health_types::HotspotSummary {
3488            since: "6 months".to_string(),
3489            min_commits: 3,
3490            files_analyzed: 50,
3491            files_excluded: 20,
3492            shallow_clone: false,
3493        });
3494        let lines = build_health_human_lines(&report, &root);
3495        let text = plain(&lines);
3496        assert!(text.contains("Hotspots (1 files, since 6 months)"));
3497        assert!(text.contains("20 files excluded (< 3 commits)"));
3498    }
3499
3500    #[test]
3501    fn hotspots_summary_no_exclusions() {
3502        let root = PathBuf::from("/project");
3503        let mut report = empty_report();
3504        report.hotspots = vec![
3505            crate::health_types::HotspotEntry {
3506                path: root.join("src/a.ts"),
3507                score: 50.0,
3508                commits: 10,
3509                weighted_commits: 8.0,
3510                lines_added: 100,
3511                lines_deleted: 50,
3512                complexity_density: 0.4,
3513                fan_in: 3,
3514                trend: fallow_core::churn::ChurnTrend::Stable,
3515                ownership: None,
3516                is_test_path: false,
3517            }
3518            .into(),
3519        ];
3520        report.hotspot_summary = Some(crate::health_types::HotspotSummary {
3521            since: "3 months".to_string(),
3522            min_commits: 2,
3523            files_analyzed: 50,
3524            files_excluded: 0,
3525            shallow_clone: false,
3526        });
3527        let lines = build_health_human_lines(&report, &root);
3528        let text = plain(&lines);
3529        assert!(!text.contains("files excluded"));
3530    }
3531
3532    #[test]
3533    fn hotspots_docs_link() {
3534        let root = PathBuf::from("/project");
3535        let mut report = empty_report();
3536        report.hotspots = vec![
3537            crate::health_types::HotspotEntry {
3538                path: root.join("src/a.ts"),
3539                score: 50.0,
3540                commits: 10,
3541                weighted_commits: 8.0,
3542                lines_added: 100,
3543                lines_deleted: 50,
3544                complexity_density: 0.4,
3545                fan_in: 3,
3546                trend: fallow_core::churn::ChurnTrend::Stable,
3547                ownership: None,
3548                is_test_path: false,
3549            }
3550            .into(),
3551        ];
3552        let lines = build_health_human_lines(&report, &root);
3553        let text = plain(&lines);
3554        assert!(text.contains("docs.fallow.tools/explanations/health#hotspot-metrics"));
3555    }
3556
3557    #[test]
3558    fn refactoring_targets_single_low_effort() {
3559        let root = PathBuf::from("/project");
3560        let mut report = empty_report();
3561        report.targets = vec![
3562            crate::health_types::RefactoringTarget {
3563                path: root.join("src/legacy.ts"),
3564                priority: 65.0,
3565                efficiency: 65.0,
3566                recommendation: "Extract complex logic into helper functions".to_string(),
3567                category: crate::health_types::RecommendationCategory::ExtractComplexFunctions,
3568                effort: crate::health_types::EffortEstimate::Low,
3569                confidence: crate::health_types::Confidence::High,
3570                factors: vec![],
3571                evidence: None,
3572            }
3573            .into(),
3574        ];
3575        let lines = build_health_human_lines(&report, &root);
3576        let text = plain(&lines);
3577        assert!(text.contains("Refactoring targets (1)"));
3578        assert!(text.contains("1 low effort"));
3579        assert!(text.contains("65.0"));
3580        assert!(text.contains("pri:65.0"));
3581        assert!(text.contains("src/legacy.ts"));
3582        assert!(text.contains("complexity"));
3583        assert!(text.contains("effort:low"));
3584        assert!(text.contains("confidence:high"));
3585        assert!(text.contains("Extract complex logic into helper functions"));
3586    }
3587
3588    #[test]
3589    fn refactoring_targets_mixed_effort() {
3590        let root = PathBuf::from("/project");
3591        let mut report = empty_report();
3592        report.targets = vec![
3593            crate::health_types::RefactoringTarget {
3594                path: root.join("src/a.ts"),
3595                priority: 80.0,
3596                efficiency: 80.0,
3597                recommendation: "Remove dead exports".to_string(),
3598                category: crate::health_types::RecommendationCategory::RemoveDeadCode,
3599                effort: crate::health_types::EffortEstimate::Low,
3600                confidence: crate::health_types::Confidence::High,
3601                factors: vec![],
3602                evidence: None,
3603            }
3604            .into(),
3605            crate::health_types::RefactoringTarget {
3606                path: root.join("src/b.ts"),
3607                priority: 60.0,
3608                efficiency: 30.0,
3609                recommendation: "Split into smaller modules".to_string(),
3610                category: crate::health_types::RecommendationCategory::SplitHighImpact,
3611                effort: crate::health_types::EffortEstimate::Medium,
3612                confidence: crate::health_types::Confidence::Medium,
3613                factors: vec![],
3614                evidence: None,
3615            }
3616            .into(),
3617            crate::health_types::RefactoringTarget {
3618                path: root.join("src/c.ts"),
3619                priority: 50.0,
3620                efficiency: 16.7,
3621                recommendation: "Break circular dependency".to_string(),
3622                category: crate::health_types::RecommendationCategory::BreakCircularDependency,
3623                effort: crate::health_types::EffortEstimate::High,
3624                confidence: crate::health_types::Confidence::Low,
3625                factors: vec![],
3626                evidence: None,
3627            }
3628            .into(),
3629        ];
3630        let lines = build_health_human_lines(&report, &root);
3631        let text = plain(&lines);
3632        assert!(text.contains("Refactoring targets (3)"));
3633        assert!(text.contains("1 low effort"));
3634        assert!(text.contains("1 medium"));
3635        assert!(text.contains("1 high"));
3636        assert!(text.contains("effort:low"));
3637        assert!(text.contains("effort:medium"));
3638        assert!(text.contains("effort:high"));
3639        assert!(text.contains("confidence:high"));
3640        assert!(text.contains("confidence:medium"));
3641        assert!(text.contains("confidence:low"));
3642    }
3643
3644    #[test]
3645    fn refactoring_targets_truncation_above_max_flat_items() {
3646        let root = PathBuf::from("/project");
3647        let mut report = empty_report();
3648        for i in 0..12 {
3649            report.targets.push(
3650                crate::health_types::RefactoringTarget {
3651                    path: root.join(format!("src/target{i}.ts")),
3652                    priority: 50.0,
3653                    efficiency: 25.0,
3654                    recommendation: format!("Fix target {i}"),
3655                    category: crate::health_types::RecommendationCategory::ExtractComplexFunctions,
3656                    effort: crate::health_types::EffortEstimate::Medium,
3657                    confidence: crate::health_types::Confidence::Medium,
3658                    factors: vec![],
3659                    evidence: None,
3660                }
3661                .into(),
3662            );
3663        }
3664        let lines = build_health_human_lines(&report, &root);
3665        let text = plain(&lines);
3666        assert!(text.contains("Refactoring targets (12)"));
3667        assert!(text.contains("... and 2 more targets"));
3668        assert!(text.contains("target0.ts"));
3669        assert!(text.contains("target9.ts"));
3670        assert!(!text.contains("target10.ts"));
3671    }
3672
3673    #[test]
3674    fn refactoring_targets_docs_link() {
3675        let root = PathBuf::from("/project");
3676        let mut report = empty_report();
3677        report.targets = vec![
3678            crate::health_types::RefactoringTarget {
3679                path: root.join("src/a.ts"),
3680                priority: 50.0,
3681                efficiency: 50.0,
3682                recommendation: "Fix it".to_string(),
3683                category: crate::health_types::RecommendationCategory::ExtractDependencies,
3684                effort: crate::health_types::EffortEstimate::Low,
3685                confidence: crate::health_types::Confidence::High,
3686                factors: vec![],
3687                evidence: None,
3688            }
3689            .into(),
3690        ];
3691        let lines = build_health_human_lines(&report, &root);
3692        let text = plain(&lines);
3693        assert!(text.contains("docs.fallow.tools/explanations/health#refactoring-targets"));
3694    }
3695
3696    #[test]
3697    fn refactoring_targets_all_categories() {
3698        let root = PathBuf::from("/project");
3699        let mut report = empty_report();
3700        let categories = [
3701            (
3702                crate::health_types::RecommendationCategory::UrgentChurnComplexity,
3703                "churn+complexity",
3704            ),
3705            (
3706                crate::health_types::RecommendationCategory::BreakCircularDependency,
3707                "circular dependency",
3708            ),
3709            (
3710                crate::health_types::RecommendationCategory::SplitHighImpact,
3711                "high impact",
3712            ),
3713            (
3714                crate::health_types::RecommendationCategory::RemoveDeadCode,
3715                "dead code",
3716            ),
3717            (
3718                crate::health_types::RecommendationCategory::ExtractComplexFunctions,
3719                "complexity",
3720            ),
3721            (
3722                crate::health_types::RecommendationCategory::ExtractDependencies,
3723                "coupling",
3724            ),
3725            (
3726                crate::health_types::RecommendationCategory::AddTestCoverage,
3727                "untested risk",
3728            ),
3729        ];
3730        for (i, (cat, _label)) in categories.iter().enumerate() {
3731            report.targets.push(
3732                crate::health_types::RefactoringTarget {
3733                    path: root.join(format!("src/cat{i}.ts")),
3734                    priority: 50.0,
3735                    efficiency: 50.0,
3736                    recommendation: format!("Fix cat{i}"),
3737                    category: cat.clone(),
3738                    effort: crate::health_types::EffortEstimate::Low,
3739                    confidence: crate::health_types::Confidence::High,
3740                    factors: vec![],
3741                    evidence: None,
3742                }
3743                .into(),
3744            );
3745        }
3746        let lines = build_health_human_lines(&report, &root);
3747        let text = plain(&lines);
3748        for (_cat, label) in &categories {
3749            assert!(
3750                text.contains(label),
3751                "Expected category label '{label}' in output"
3752            );
3753        }
3754    }
3755
3756    #[test]
3757    fn refactoring_targets_efficiency_color_thresholds() {
3758        let root = PathBuf::from("/project");
3759        let mut report = empty_report();
3760        report.targets = vec![
3761            crate::health_types::RefactoringTarget {
3762                path: root.join("src/high.ts"),
3763                priority: 50.0,
3764                efficiency: 50.0, // green: >= 40
3765                recommendation: "High eff".to_string(),
3766                category: crate::health_types::RecommendationCategory::RemoveDeadCode,
3767                effort: crate::health_types::EffortEstimate::Low,
3768                confidence: crate::health_types::Confidence::High,
3769                factors: vec![],
3770                evidence: None,
3771            }
3772            .into(),
3773            crate::health_types::RefactoringTarget {
3774                path: root.join("src/mid.ts"),
3775                priority: 50.0,
3776                efficiency: 25.0, // yellow: >= 20
3777                recommendation: "Mid eff".to_string(),
3778                category: crate::health_types::RecommendationCategory::RemoveDeadCode,
3779                effort: crate::health_types::EffortEstimate::Medium,
3780                confidence: crate::health_types::Confidence::Medium,
3781                factors: vec![],
3782                evidence: None,
3783            }
3784            .into(),
3785            crate::health_types::RefactoringTarget {
3786                path: root.join("src/low.ts"),
3787                priority: 50.0,
3788                efficiency: 10.0, // dimmed: < 20
3789                recommendation: "Low eff".to_string(),
3790                category: crate::health_types::RecommendationCategory::RemoveDeadCode,
3791                effort: crate::health_types::EffortEstimate::High,
3792                confidence: crate::health_types::Confidence::Low,
3793                factors: vec![],
3794                evidence: None,
3795            }
3796            .into(),
3797        ];
3798        let lines = build_health_human_lines(&report, &root);
3799        let text = plain(&lines);
3800        assert!(text.contains("50.0"));
3801        assert!(text.contains("25.0"));
3802        assert!(text.contains("10.0"));
3803    }
3804
3805    #[test]
3806    fn all_sections_combined() {
3807        let root = PathBuf::from("/project");
3808        let mut report = empty_report();
3809        report.summary.functions_above_threshold = 1;
3810        report.findings = vec![
3811            crate::health_types::ComplexityViolation {
3812                path: root.join("src/complex.ts"),
3813                name: "bigFn".to_string(),
3814                line: 10,
3815                col: 0,
3816                cyclomatic: 25,
3817                cognitive: 20,
3818                line_count: 80,
3819                param_count: 0,
3820                exceeded: crate::health_types::ExceededThreshold::Both,
3821                severity: crate::health_types::FindingSeverity::Moderate,
3822                crap: None,
3823                coverage_pct: None,
3824                coverage_tier: None,
3825                coverage_source: None,
3826                inherited_from: None,
3827                component_rollup: None,
3828            }
3829            .into(),
3830        ];
3831        report.health_score = Some(crate::health_types::HealthScore {
3832            formula_version: crate::health_types::HEALTH_SCORE_FORMULA_VERSION,
3833            score: 75.0,
3834            grade: "B",
3835            penalties: crate::health_types::HealthScorePenalties {
3836                dead_files: Some(5.0),
3837                dead_exports: Some(5.0),
3838                complexity: 5.0,
3839                p90_complexity: 2.0,
3840                maintainability: Some(3.0),
3841                hotspots: Some(2.0),
3842                unused_deps: Some(2.0),
3843                circular_deps: Some(1.0),
3844                unit_size: None,
3845                coupling: None,
3846                duplication: None,
3847            },
3848        });
3849        report.file_scores = vec![crate::health_types::FileHealthScore {
3850            path: root.join("src/complex.ts"),
3851            fan_in: 5,
3852            fan_out: 3,
3853            dead_code_ratio: 0.1,
3854            complexity_density: 0.5,
3855            maintainability_index: 60.0,
3856            total_cyclomatic: 15,
3857            total_cognitive: 10,
3858            function_count: 3,
3859            lines: 200,
3860            crap_max: 0.0,
3861            crap_above_threshold: 0,
3862        }];
3863        report.hotspots = vec![
3864            crate::health_types::HotspotEntry {
3865                path: root.join("src/complex.ts"),
3866                score: 65.0,
3867                commits: 20,
3868                weighted_commits: 15.0,
3869                lines_added: 300,
3870                lines_deleted: 100,
3871                complexity_density: 0.5,
3872                fan_in: 5,
3873                trend: fallow_core::churn::ChurnTrend::Accelerating,
3874                ownership: None,
3875                is_test_path: false,
3876            }
3877            .into(),
3878        ];
3879        report.targets = vec![
3880            crate::health_types::RefactoringTarget {
3881                path: root.join("src/complex.ts"),
3882                priority: 70.0,
3883                efficiency: 70.0,
3884                recommendation: "Extract complex functions".to_string(),
3885                category: crate::health_types::RecommendationCategory::ExtractComplexFunctions,
3886                effort: crate::health_types::EffortEstimate::Low,
3887                confidence: crate::health_types::Confidence::High,
3888                factors: vec![],
3889                evidence: None,
3890            }
3891            .into(),
3892        ];
3893        let lines = build_health_human_lines(&report, &root);
3894        let text = plain(&lines);
3895        assert!(text.contains("Health score:"));
3896        assert!(text.contains("High complexity functions"));
3897        assert!(text.contains("File health scores"));
3898        assert!(text.contains("Hotspots"));
3899        assert!(text.contains("Refactoring targets"));
3900    }
3901
3902    #[test]
3903    fn completely_empty_report_produces_no_lines() {
3904        let root = PathBuf::from("/project");
3905        let report = empty_report();
3906        let lines = build_health_human_lines(&report, &root);
3907        assert!(lines.is_empty());
3908    }
3909
3910    #[test]
3911    fn finding_only_cyclomatic_exceeds() {
3912        let root = PathBuf::from("/project");
3913        let mut report = empty_report();
3914        report.summary.functions_above_threshold = 1;
3915        report.findings = vec![
3916            crate::health_types::ComplexityViolation {
3917                path: root.join("src/a.ts"),
3918                name: "fn1".to_string(),
3919                line: 1,
3920                col: 0,
3921                cyclomatic: 25, // exceeds 20
3922                cognitive: 10,  // does not exceed 15
3923                line_count: 50,
3924                param_count: 0,
3925                exceeded: crate::health_types::ExceededThreshold::Cyclomatic,
3926                severity: crate::health_types::FindingSeverity::Moderate,
3927                crap: None,
3928                coverage_pct: None,
3929                coverage_tier: None,
3930                coverage_source: None,
3931                inherited_from: None,
3932                component_rollup: None,
3933            }
3934            .into(),
3935        ];
3936        let lines = build_health_human_lines(&report, &root);
3937        let text = plain(&lines);
3938        assert!(text.contains("25 cyclomatic"));
3939        assert!(text.contains("10 cognitive"));
3940    }
3941
3942    #[test]
3943    fn finding_only_cognitive_exceeds() {
3944        let root = PathBuf::from("/project");
3945        let mut report = empty_report();
3946        report.summary.functions_above_threshold = 1;
3947        report.findings = vec![
3948            crate::health_types::ComplexityViolation {
3949                path: root.join("src/a.ts"),
3950                name: "fn1".to_string(),
3951                line: 1,
3952                col: 0,
3953                cyclomatic: 10, // does not exceed 20
3954                cognitive: 25,  // exceeds 15
3955                line_count: 50,
3956                param_count: 0,
3957                exceeded: crate::health_types::ExceededThreshold::Cognitive,
3958                severity: crate::health_types::FindingSeverity::High,
3959                crap: None,
3960                coverage_pct: None,
3961                coverage_tier: None,
3962                coverage_source: None,
3963                inherited_from: None,
3964                component_rollup: None,
3965            }
3966            .into(),
3967        ];
3968        let lines = build_health_human_lines(&report, &root);
3969        let text = plain(&lines);
3970        assert!(text.contains("10 cyclomatic"));
3971        assert!(text.contains("25 cognitive"));
3972    }
3973
3974    #[test]
3975    fn findings_across_multiple_files() {
3976        let root = PathBuf::from("/project");
3977        let mut report = empty_report();
3978        report.summary.functions_above_threshold = 2;
3979        report.findings = vec![
3980            crate::health_types::ComplexityViolation {
3981                path: root.join("src/a.ts"),
3982                name: "fn1".to_string(),
3983                line: 1,
3984                col: 0,
3985                cyclomatic: 25,
3986                cognitive: 20,
3987                line_count: 50,
3988                param_count: 0,
3989                exceeded: crate::health_types::ExceededThreshold::Both,
3990                severity: crate::health_types::FindingSeverity::Moderate,
3991                crap: None,
3992                coverage_pct: None,
3993                coverage_tier: None,
3994                coverage_source: None,
3995                inherited_from: None,
3996                component_rollup: None,
3997            }
3998            .into(),
3999            crate::health_types::ComplexityViolation {
4000                path: root.join("src/b.ts"),
4001                name: "fn2".to_string(),
4002                line: 5,
4003                col: 0,
4004                cyclomatic: 22,
4005                cognitive: 18,
4006                line_count: 40,
4007                param_count: 0,
4008                exceeded: crate::health_types::ExceededThreshold::Both,
4009                severity: crate::health_types::FindingSeverity::Moderate,
4010                crap: None,
4011                coverage_pct: None,
4012                coverage_tier: None,
4013                coverage_source: None,
4014                inherited_from: None,
4015                component_rollup: None,
4016            }
4017            .into(),
4018        ];
4019        let lines = build_health_human_lines(&report, &root);
4020        let text = plain(&lines);
4021        assert!(text.contains("src/a.ts"));
4022        assert!(text.contains("src/b.ts"));
4023    }
4024
4025    #[test]
4026    fn findings_docs_link() {
4027        let root = PathBuf::from("/project");
4028        let mut report = empty_report();
4029        report.summary.functions_above_threshold = 1;
4030        report.findings = vec![
4031            crate::health_types::ComplexityViolation {
4032                path: root.join("src/a.ts"),
4033                name: "fn1".to_string(),
4034                line: 1,
4035                col: 0,
4036                cyclomatic: 25,
4037                cognitive: 20,
4038                line_count: 50,
4039                param_count: 0,
4040                exceeded: crate::health_types::ExceededThreshold::Both,
4041                severity: crate::health_types::FindingSeverity::Moderate,
4042                crap: None,
4043                coverage_pct: None,
4044                coverage_tier: None,
4045                coverage_source: None,
4046                inherited_from: None,
4047                component_rollup: None,
4048            }
4049            .into(),
4050        ];
4051        let lines = build_health_human_lines(&report, &root);
4052        let text = plain(&lines);
4053        assert!(text.contains("docs.fallow.tools/explanations/health#complexity-metrics"));
4054    }
4055
4056    #[test]
4057    fn hotspot_score_high_medium_low() {
4058        let root = PathBuf::from("/project");
4059        let mut report = empty_report();
4060        report.hotspots = vec![
4061            crate::health_types::HotspotEntry {
4062                path: root.join("src/high.ts"),
4063                score: 80.0, // red: >= 70
4064                commits: 30,
4065                weighted_commits: 25.0,
4066                lines_added: 400,
4067                lines_deleted: 200,
4068                complexity_density: 0.9,
4069                fan_in: 8,
4070                trend: fallow_core::churn::ChurnTrend::Accelerating,
4071                ownership: None,
4072                is_test_path: false,
4073            }
4074            .into(),
4075            crate::health_types::HotspotEntry {
4076                path: root.join("src/medium.ts"),
4077                score: 45.0, // yellow: >= 30
4078                commits: 15,
4079                weighted_commits: 10.0,
4080                lines_added: 200,
4081                lines_deleted: 100,
4082                complexity_density: 0.5,
4083                fan_in: 4,
4084                trend: fallow_core::churn::ChurnTrend::Stable,
4085                ownership: None,
4086                is_test_path: false,
4087            }
4088            .into(),
4089            crate::health_types::HotspotEntry {
4090                path: root.join("src/low.ts"),
4091                score: 15.0, // green: < 30
4092                commits: 5,
4093                weighted_commits: 3.0,
4094                lines_added: 50,
4095                lines_deleted: 20,
4096                complexity_density: 0.2,
4097                fan_in: 1,
4098                trend: fallow_core::churn::ChurnTrend::Cooling,
4099                ownership: None,
4100                is_test_path: false,
4101            }
4102            .into(),
4103        ];
4104        let lines = build_health_human_lines(&report, &root);
4105        let text = plain(&lines);
4106        assert!(text.contains("80.0"));
4107        assert!(text.contains("45.0"));
4108        assert!(text.contains("15.0"));
4109        assert!(text.contains("Hotspots (3 files)"));
4110    }
4111
4112    #[test]
4113    fn rollup_breakdown_renders_workspace_relative_template_path() {
4114        let root = PathBuf::from("/project");
4115        let template =
4116            root.join("apps/admin/src/app/payments/payment-list/payment-list.component.html");
4117        let finding = crate::health_types::ComplexityViolation {
4118            path: root.join("apps/admin/src/app/payments/payment-list/payment-list.component.ts"),
4119            name: "<component>".to_string(),
4120            line: 1,
4121            col: 0,
4122            cyclomatic: 25,
4123            cognitive: 28,
4124            line_count: 0,
4125            param_count: 0,
4126            exceeded: crate::health_types::ExceededThreshold::Both,
4127            severity: crate::health_types::FindingSeverity::High,
4128            crap: None,
4129            coverage_pct: None,
4130            coverage_tier: None,
4131            coverage_source: None,
4132            inherited_from: None,
4133            component_rollup: Some(crate::health_types::ComponentRollup {
4134                component: "PaymentListComponent".to_string(),
4135                class_worst_function: "ngOnInit".to_string(),
4136                class_cyclomatic: 12,
4137                class_cognitive: 16,
4138                template_path: template,
4139                template_cyclomatic: 13,
4140                template_cognitive: 12,
4141            }),
4142        };
4143        let line = render_component_rollup_breakdown(&finding, &root)
4144            .expect("rollup payload should render a breakdown line");
4145        assert!(
4146            line.contains("apps/admin/src/app/payments/payment-list/payment-list.component.html"),
4147            "breakdown must include workspace-relative template path: {line}"
4148        );
4149        assert!(
4150            !line.contains(" payment-list.component.html"),
4151            "bare basename token must not be the rendered template: {line}"
4152        );
4153    }
4154
4155    #[test]
4156    fn inherited_from_renders_workspace_relative_owner_path() {
4157        let root = PathBuf::from("/project");
4158        let owner = root.join("apps/admin/src/app/auth/permissions/permissions.component.ts");
4159        let template_path =
4160            root.join("apps/admin/src/app/auth/permissions/permissions.component.html");
4161        let report = crate::health_types::HealthReport {
4162            findings: vec![
4163                crate::health_types::ComplexityViolation {
4164                    path: template_path,
4165                    name: "<template>".to_string(),
4166                    line: 1,
4167                    col: 0,
4168                    cyclomatic: 12,
4169                    cognitive: 14,
4170                    line_count: 0,
4171                    param_count: 0,
4172                    exceeded: crate::health_types::ExceededThreshold::Both,
4173                    severity: crate::health_types::FindingSeverity::High,
4174                    crap: Some(45.0),
4175                    coverage_pct: None,
4176                    coverage_tier: Some(crate::health_types::CoverageTier::Partial),
4177                    coverage_source: Some(
4178                        crate::health_types::CoverageSource::EstimatedComponentInherited,
4179                    ),
4180                    inherited_from: Some(owner),
4181                    component_rollup: None,
4182                }
4183                .into(),
4184            ],
4185            summary: crate::health_types::HealthSummary {
4186                files_analyzed: 1,
4187                functions_analyzed: 1,
4188                functions_above_threshold: 1,
4189                ..Default::default()
4190            },
4191            ..Default::default()
4192        };
4193        let lines = build_health_human_lines(&report, &root);
4194        let text = plain(&lines);
4195        assert!(
4196            text.contains(
4197                "(inherited from apps/admin/src/app/auth/permissions/permissions.component.ts)"
4198            ),
4199            "inherited-from suffix must use workspace-relative path: {text}"
4200        );
4201        assert!(
4202            !text.contains("(inherited from permissions.component.ts)"),
4203            "bare basename suffix must not be rendered: {text}"
4204        );
4205    }
4206}