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