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