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