Skip to main content

fallow_cli/report/human/
health.rs

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