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