Skip to main content

fallow_cli/report/human/
health.rs

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