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