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    let unit_ceiling = report.summary.max_unit_size_threshold;
1464    let unit_word = if unit_ceiling == 1 { "line" } else { "lines" };
1465    lines.push(format!(
1466        "  {}",
1467        format!(
1468            "Functions exceeding {unit_ceiling} {unit_word} of code (very high risk): {DOCS_HEALTH}#unit-size"
1469        )
1470        .dimmed()
1471    ));
1472    if shown < total {
1473        lines.push(format!(
1474            "  {}",
1475            format!("use --top {total} to see all").dimmed()
1476        ));
1477    }
1478    lines.push(String::new());
1479}
1480
1481/// Append per-finding-kind suppression hints to the findings section footer.
1482///
1483/// External `.html` templates take a file-level HTML comment; inline
1484/// `@Component` templates take a line-level TS comment placed directly above
1485/// the decorator. `<component>` rollups suppress through the worst class
1486/// method (the rollup anchors at that method's line). Generic function
1487/// findings get the catch-all hint above a `>=3` noise threshold. Extracted
1488/// from `render_findings` to keep that function under the SIG unit-size
1489/// threshold.
1490fn append_suppression_hints(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
1491    let has_html_template = report.findings.iter().any(|finding| {
1492        finding.name == "<template>"
1493            && finding
1494                .path
1495                .extension()
1496                .and_then(|ext| ext.to_str())
1497                .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
1498    });
1499    let has_inline_template = report.findings.iter().any(|finding| {
1500        finding.name == "<template>"
1501            && finding
1502                .path
1503                .extension()
1504                .and_then(|ext| ext.to_str())
1505                .is_none_or(|ext| !ext.eq_ignore_ascii_case("html"))
1506    });
1507    let has_component_rollup = report
1508        .findings
1509        .iter()
1510        .any(|finding| finding.name == "<component>");
1511    let has_function_finding = report
1512        .findings
1513        .iter()
1514        .any(|finding| finding.name != "<template>" && finding.name != "<component>");
1515    if has_html_template {
1516        lines.push(format!(
1517            "  {}",
1518            "To suppress HTML templates: <!-- fallow-ignore-file complexity -->".dimmed()
1519        ));
1520    }
1521    if has_inline_template {
1522        lines.push(format!(
1523            "  {}",
1524            "To suppress inline templates: // fallow-ignore-next-line complexity (above @Component)"
1525                .dimmed()
1526        ));
1527    }
1528    if has_component_rollup {
1529        lines.push(format!(
1530            "  {}",
1531            "To suppress a <component> rollup: suppress the worst class method (// fallow-ignore-next-line complexity above it hides both)"
1532                .dimmed()
1533        ));
1534    }
1535    if has_function_finding && report.findings.len() >= 3 {
1536        lines.push(format!(
1537            "  {}",
1538            "To suppress: // fallow-ignore-next-line complexity".dimmed()
1539        ));
1540    }
1541}
1542
1543/// Render the breakdown line for a synthetic `<component>` rollup finding.
1544///
1545/// Returns `Some(line)` when the finding carries a `component_rollup` payload
1546/// (the rollup's cyc/cog totals are `worst_class_function + template`, so this
1547/// line names the pre-summation numbers + the worst-class-function identifier
1548/// so readers can see why the component ranks high without re-deriving the
1549/// link from the JSON payload), `None` otherwise. Extracted from
1550/// `render_findings` to keep that function under the SIG unit-size threshold.
1551///
1552/// Renders `template_path` workspace-relative (issue #547) so Angular
1553/// projects with many `*.component.html` files unambiguously identify the
1554/// template fallow scored.
1555fn render_component_rollup_breakdown(
1556    finding: &fallow_output::ComplexityViolation,
1557    root: &Path,
1558) -> Option<String> {
1559    let rollup = finding.component_rollup.as_ref()?;
1560    let template_display = crate::report::format_display_path(&rollup.template_path, root);
1561    Some(format!(
1562        "         {}",
1563        format!(
1564            "rolled up: {}cyc {}cog on `{}.{}` + {}cyc {}cog on {}",
1565            rollup.class_cyclomatic,
1566            rollup.class_cognitive,
1567            rollup.component,
1568            rollup.class_worst_function,
1569            rollup.template_cyclomatic,
1570            rollup.template_cognitive,
1571            template_display,
1572        )
1573        .dimmed(),
1574    ))
1575}
1576
1577fn render_findings(lines: &mut Vec<String>, report: &fallow_output::HealthReport, root: &Path) {
1578    if report.findings.is_empty() {
1579        return;
1580    }
1581
1582    push_findings_header(lines, report);
1583    if let Some(note) = crap_coverage_note(report) {
1584        lines.push(format!("  {}", note.dimmed()));
1585    }
1586
1587    let mut last_file = String::new();
1588    for finding in &report.findings {
1589        push_finding_file_header(lines, finding, root, &mut last_file);
1590        push_finding_metric_rows(lines, finding, report, root);
1591    }
1592    let scope = if has_synthetic_complexity_entries(report) {
1593        "Functions and synthetic template or component entries"
1594    } else {
1595        "Functions"
1596    };
1597    lines.push(format!(
1598        "  {}",
1599        format!("{scope} exceeding cyclomatic, cognitive, or CRAP thresholds ({DOCS_HEALTH}#complexity-metrics)")
1600            .dimmed()
1601    ));
1602    append_suppression_hints(lines, report);
1603    if report.findings.len() < report.summary.functions_above_threshold {
1604        let total = report.summary.functions_above_threshold;
1605        lines.push(format!(
1606            "  {}",
1607            format!("use --top {total} to see all").dimmed()
1608        ));
1609    }
1610    lines.push(String::new());
1611}
1612
1613fn push_findings_header(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
1614    let subject = if has_synthetic_complexity_entries(report) {
1615        "High complexity findings"
1616    } else {
1617        "High complexity functions"
1618    };
1619    let title = if report.findings.len() < report.summary.functions_above_threshold {
1620        format!(
1621            "{subject} ({} shown, {} total)",
1622            report.findings.len(),
1623            report.summary.functions_above_threshold
1624        )
1625    } else {
1626        format!("{subject} ({})", report.summary.functions_above_threshold)
1627    };
1628    lines.push(format!("{} {}", "\u{25cf}".red(), title.red().bold()));
1629}
1630
1631fn has_synthetic_complexity_entries(report: &fallow_output::HealthReport) -> bool {
1632    report
1633        .findings
1634        .iter()
1635        .any(|finding| matches!(finding.name.as_str(), "<template>" | "<component>"))
1636}
1637
1638fn display_complexity_entry_name(name: &str) -> Cow<'_, str> {
1639    match name {
1640        "<template>" => Cow::Borrowed("<template> (template complexity)"),
1641        "<component>" => Cow::Borrowed("<component> (component rollup)"),
1642        _ => Cow::Borrowed(name),
1643    }
1644}
1645
1646fn push_finding_file_header(
1647    lines: &mut Vec<String>,
1648    finding: &fallow_output::ComplexityViolation,
1649    root: &Path,
1650    last_file: &mut String,
1651) {
1652    let file_str = crate::report::format_display_path(&finding.path, root);
1653    if file_str != *last_file {
1654        lines.push(format!("  {}", format_path(&file_str)));
1655        *last_file = file_str;
1656    }
1657}
1658
1659fn push_finding_metric_rows(
1660    lines: &mut Vec<String>,
1661    finding: &fallow_output::ComplexityViolation,
1662    report: &fallow_output::HealthReport,
1663    root: &Path,
1664) {
1665    let thresholds = finding_thresholds(finding, report);
1666    lines.push(format!(
1667        "    {} {}{}{}",
1668        format!(":{}", finding.line).dimmed(),
1669        display_complexity_entry_name(&finding.name).as_ref().bold(),
1670        finding_severity_tag(finding),
1671        finding_generated_tag(finding),
1672    ));
1673    lines.push(format!(
1674        "         {} cyclomatic  {} cognitive  {} lines",
1675        threshold_colored(finding.cyclomatic, thresholds.max_cyclomatic),
1676        threshold_colored(finding.cognitive, thresholds.max_cognitive),
1677        format!("{:>3}", finding.line_count).dimmed(),
1678    ));
1679    if let Some(line) = render_react_context(finding) {
1680        lines.push(line);
1681    }
1682    if let Some(line) = render_blast_radius_context(finding, report) {
1683        lines.push(line);
1684    }
1685    if let Some(line) = render_component_rollup_breakdown(finding, root) {
1686        lines.push(line);
1687    }
1688    if let Some(line) = finding_crap_line(finding, root) {
1689        lines.push(line);
1690    }
1691}
1692
1693/// Render the descriptive React-shape context line for a complexity finding:
1694/// `react: 14 props, 9 hooks (3 state, 4 effect, 2 memo), max effect deps 5,
1695/// JSX depth 7`. Purely descriptive, never a threshold. Returns `None` when the
1696/// finding has no React
1697/// signals, so non-React findings render unchanged. The per-kind breakdown and
1698/// max effect deps come from the cached `hook_uses` IR (component-scope hooks
1699/// only) and are shown only when a profile was attributed; `react_hook_count`
1700/// stays the headline so the documented breakdown-vs-total divergence is a
1701/// non-issue.
1702fn render_react_context(finding: &fallow_output::ComplexityViolation) -> Option<String> {
1703    if finding.react_prop_count == 0
1704        && finding.react_hook_count == 0
1705        && finding.react_jsx_max_depth == 0
1706    {
1707        return None;
1708    }
1709    let mut parts: Vec<String> = Vec::new();
1710    if finding.react_prop_count > 0 {
1711        parts.push(format!("{} props", finding.react_prop_count));
1712    }
1713    if finding.react_hook_count > 0 {
1714        let breakdown = finding
1715            .react_hook_profile
1716            .as_ref()
1717            .map(hook_breakdown_fragment)
1718            .filter(|b| !b.is_empty());
1719        match breakdown {
1720            Some(breakdown) => {
1721                parts.push(format!("{} hooks ({breakdown})", finding.react_hook_count));
1722            }
1723            None => parts.push(format!("{} hooks", finding.react_hook_count)),
1724        }
1725    }
1726    if let Some(arity) = finding
1727        .react_hook_profile
1728        .as_ref()
1729        .and_then(|p| p.max_effect_dep_arity)
1730    {
1731        parts.push(format!("max effect deps {arity}"));
1732    }
1733    if finding.react_jsx_max_depth > 0 {
1734        parts.push(format!("JSX depth {}", finding.react_jsx_max_depth));
1735    }
1736    Some(format!(
1737        "         {}",
1738        format!("react: {}", parts.join(", ")).dimmed()
1739    ))
1740}
1741
1742/// The minimum render-SITE count for the descriptive blast-radius line. A
1743/// component rendered in `<= 1` place is not a blast-radius amplifier (editing
1744/// it ripples to at most one site), so the line only surfaces for components
1745/// rendered in MULTIPLE places. Descriptive floor only, not a gate or threshold.
1746const BLAST_RADIUS_MIN_SITES: u32 = 2;
1747
1748/// Render the descriptive blast-radius line for a complexity finding whose file
1749/// declares a high-render-fan-in component: `rendered in N places`. This is the
1750/// component-graph analogue of fan-in surfaced as context (a hotspot file
1751/// containing a shared component is a higher blast-radius hotspot: editing it
1752/// ripples to every render site). Purely descriptive (dimmed, sibling to the
1753/// `react: ...` line), never a threshold or finding. Returns `None` when the
1754/// file has no inspectable high-fan-in component (so non-React findings render
1755/// unchanged) or the metric was not computed (non-React run / score-only).
1756fn render_blast_radius_context(
1757    finding: &fallow_output::ComplexityViolation,
1758    report: &fallow_output::HealthReport,
1759) -> Option<String> {
1760    let (component, render_sites) = report.render_fan_in_top.get(&finding.path)?;
1761    if *render_sites < BLAST_RADIUS_MIN_SITES {
1762        return None;
1763    }
1764    Some(format!(
1765        "         {}",
1766        format!("blast radius: <{component}> rendered in {render_sites} places").dimmed()
1767    ))
1768}
1769
1770/// Build the per-kind hook breakdown parenthetical (`3 state, 4 effect, 2
1771/// memo`), gating each segment on `> 0` so empty kinds never produce dangling
1772/// fragments. Returns an empty string when no kind was attributed.
1773fn hook_breakdown_fragment(profile: &fallow_output::ReactHookProfile) -> String {
1774    let mut segments: Vec<String> = Vec::new();
1775    if profile.state > 0 {
1776        segments.push(format!("{} state", profile.state));
1777    }
1778    if profile.effect > 0 {
1779        segments.push(format!("{} effect", profile.effect));
1780    }
1781    if profile.memo > 0 {
1782        segments.push(format!("{} memo", profile.memo));
1783    }
1784    if profile.callback > 0 {
1785        segments.push(format!("{} callback", profile.callback));
1786    }
1787    if profile.custom > 0 {
1788        segments.push(format!("{} custom", profile.custom));
1789    }
1790    segments.join(", ")
1791}
1792
1793fn finding_thresholds(
1794    finding: &fallow_output::ComplexityViolation,
1795    report: &fallow_output::HealthReport,
1796) -> fallow_output::HealthEffectiveThresholds {
1797    finding
1798        .effective_thresholds
1799        .unwrap_or(fallow_output::HealthEffectiveThresholds {
1800            max_cyclomatic: report.summary.max_cyclomatic_threshold,
1801            max_cognitive: report.summary.max_cognitive_threshold,
1802            max_crap: report.summary.max_crap_threshold,
1803            // Not rendered on complexity findings today, but carry the run's
1804            // configured global unit-size ceiling (not the static default) so
1805            // the fallback stays consistent with the other three thresholds.
1806            max_unit_size: report.summary.max_unit_size_threshold,
1807        })
1808}
1809
1810fn threshold_colored(value: u16, threshold: u16) -> String {
1811    let formatted = format!("{value:>3}");
1812    if value > threshold {
1813        formatted.red().bold().to_string()
1814    } else {
1815        formatted.dimmed().to_string()
1816    }
1817}
1818
1819fn finding_severity_tag(finding: &fallow_output::ComplexityViolation) -> String {
1820    match finding.severity {
1821        fallow_output::FindingSeverity::Critical => format!(" {}", "CRITICAL".red().bold()),
1822        fallow_output::FindingSeverity::High => format!(" {}", "HIGH".yellow().bold()),
1823        fallow_output::FindingSeverity::Moderate => String::new(),
1824    }
1825}
1826
1827fn finding_generated_tag(finding: &fallow_output::ComplexityViolation) -> String {
1828    if is_likely_generated(&finding.name, finding.cyclomatic) {
1829        format!(" {}", "(generated)".dimmed())
1830    } else {
1831        String::new()
1832    }
1833}
1834
1835fn finding_crap_line(finding: &fallow_output::ComplexityViolation, root: &Path) -> Option<String> {
1836    let crap = finding.crap?;
1837    let crap_colored = format!("{crap:>5.1}").red().bold().to_string();
1838    let coverage_suffix = if let Some(pct) = finding.coverage_pct {
1839        format!("  ({pct:.0}% tested)")
1840    } else if matches!(
1841        finding.coverage_source,
1842        Some(fallow_output::CoverageSource::EstimatedComponentInherited)
1843    ) && let Some(ref owner) = finding.inherited_from
1844    {
1845        let owner_display = crate::report::format_display_path(owner, root);
1846        format!("  (inherited from {owner_display})")
1847    } else {
1848        String::new()
1849    };
1850    Some(format!(
1851        "         {crap_colored} CRAP{}",
1852        coverage_suffix.dimmed(),
1853    ))
1854}
1855
1856fn render_threshold_overrides(
1857    lines: &mut Vec<String>,
1858    report: &fallow_output::HealthReport,
1859    root: &Path,
1860) {
1861    if report.threshold_overrides.is_empty() {
1862        return;
1863    }
1864
1865    lines.push(format!(
1866        "{} {}",
1867        "\u{25cf}".yellow(),
1868        format!(
1869            "Health threshold overrides ({})",
1870            report.threshold_overrides.len()
1871        )
1872        .yellow()
1873        .bold()
1874    ));
1875    for entry in &report.threshold_overrides {
1876        let status = match entry.status {
1877            fallow_output::ThresholdOverrideStatus::Active => "active",
1878            fallow_output::ThresholdOverrideStatus::Stale => "stale",
1879            fallow_output::ThresholdOverrideStatus::NoMatch => "no_match",
1880        };
1881        let target = entry.path.as_ref().map_or_else(
1882            || "<no matching file or function>".to_string(),
1883            |path| {
1884                let display = crate::report::format_display_path(path, root);
1885                entry
1886                    .function
1887                    .as_ref()
1888                    .map_or_else(|| display.clone(), |name| format!("{display}:{name}"))
1889            },
1890        );
1891        let metrics = entry.metrics.map_or(String::new(), |metrics| {
1892            let crap = metrics
1893                .crap
1894                .map_or(String::new(), |value| format!(" crap={value:.1}"));
1895            format!(
1896                " cyclomatic={} cognitive={}{}",
1897                metrics.cyclomatic, metrics.cognitive, crap
1898            )
1899        });
1900        lines.push(format!(
1901            "    #{idx} {status} {target}{metrics}",
1902            idx = entry.override_index
1903        ));
1904    }
1905    lines.push(String::new());
1906}
1907
1908fn crap_coverage_note(report: &fallow_output::HealthReport) -> Option<String> {
1909    if !report.findings.iter().any(|finding| finding.crap.is_some()) {
1910        return None;
1911    }
1912
1913    let istanbul_counts = (
1914        report.summary.istanbul_matched,
1915        report.summary.istanbul_total,
1916    );
1917    let has_istanbul_counts = matches!(istanbul_counts, (Some(_), Some(total)) if total > 0);
1918
1919    if matches!(
1920        report.summary.coverage_model,
1921        Some(fallow_output::CoverageModel::Istanbul)
1922    ) || has_istanbul_counts
1923    {
1924        let match_info = match (
1925            report.summary.istanbul_matched,
1926            report.summary.istanbul_total,
1927        ) {
1928            (Some(matched), Some(total)) if total > 0 && matched < total => {
1929                return Some(format!(
1930                    "CRAP scores use Istanbul coverage where matched ({matched}/{total} functions); unmatched functions are estimated from export references."
1931                ));
1932            }
1933            (Some(matched), Some(total)) if total > 0 => {
1934                format!(" ({matched}/{total} functions matched)")
1935            }
1936            _ => String::new(),
1937        };
1938        return Some(format!(
1939            "CRAP scores use Istanbul coverage data{match_info}."
1940        ));
1941    }
1942
1943    Some(
1944        "CRAP scores are estimated from export references; run `fallow health --coverage <coverage-final.json>` for exact scores."
1945            .to_string(),
1946    )
1947}
1948
1949/// Detect likely generated code based on function name patterns.
1950fn is_likely_generated(name: &str, cyclomatic: u16) -> bool {
1951    if name.starts_with("validate")
1952        && name.len() > 8
1953        && name[8..].chars().all(|c| c.is_ascii_digit())
1954    {
1955        return true;
1956    }
1957    if cyclomatic > 200 && (name == "module.exports" || name == "default" || name == "<anonymous>")
1958    {
1959        return true;
1960    }
1961    false
1962}
1963
1964fn render_file_scores(lines: &mut Vec<String>, report: &fallow_output::HealthReport, root: &Path) {
1965    if report.file_scores.is_empty() {
1966        return;
1967    }
1968
1969    push_file_scores_header(lines, report.file_scores.len());
1970
1971    let shown_scores = report.file_scores.len().min(MAX_FLAT_ITEMS);
1972    for score in &report.file_scores[..shown_scores] {
1973        render_file_score_row(lines, score, root);
1974    }
1975    push_file_scores_overflow(lines, report.file_scores.len());
1976    let crap_note = file_scores_crap_note(report);
1977    lines.push(format!(
1978        "  {}",
1979        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()
1980    ));
1981    lines.push(String::new());
1982}
1983
1984fn push_file_scores_header(lines: &mut Vec<String>, score_count: usize) {
1985    lines.push(format!(
1986        "{} {} {}",
1987        "\u{25cf}".cyan(),
1988        format!("File health scores ({score_count} files)")
1989            .cyan()
1990            .bold(),
1991        "\u{b7} sorted by triage concern".dimmed(),
1992    ));
1993    lines.push(String::new());
1994}
1995
1996fn render_file_score_row(
1997    lines: &mut Vec<String>,
1998    score: &fallow_output::FileHealthScore,
1999    root: &Path,
2000) {
2001    let file_str = relative_path(&score.path, root).display().to_string();
2002    let (dir, filename) = split_dir_filename(&file_str);
2003    const CONCERN_TAG_COLUMN: usize = 48;
2004    let pad = CONCERN_TAG_COLUMN
2005        .saturating_sub(file_str.chars().count())
2006        .max(2);
2007    lines.push(format!(
2008        "  {}    {}{}{}{}",
2009        maintainability_colored(score.maintainability_index),
2010        dir.dimmed(),
2011        filename,
2012        " ".repeat(pad),
2013        file_score_concern_colored(score),
2014    ));
2015    lines.push(format!(
2016        "         {} LOC  {} fan-in  {} fan-out  {} dead  {} density{}",
2017        format!("{:>6}", score.lines).dimmed(),
2018        format!("{:>3}", score.fan_in).dimmed(),
2019        format!("{:>3}", score.fan_out).dimmed(),
2020        format!("{:>3.0}%", score.dead_code_ratio * 100.0).dimmed(),
2021        format!("{:.2}", score.complexity_density).dimmed(),
2022        file_score_risk_suffix(score),
2023    ));
2024    lines.push(String::new());
2025}
2026
2027fn maintainability_colored(mi: f64) -> String {
2028    let mi_str = format!("{mi:>5.1}");
2029    if mi >= 80.0 {
2030        mi_str.green().to_string()
2031    } else if mi >= 50.0 {
2032        mi_str.yellow().to_string()
2033    } else {
2034        mi_str.red().bold().to_string()
2035    }
2036}
2037
2038fn file_score_concern_colored(score: &fallow_output::FileHealthScore) -> String {
2039    let label = file_score_concern_axis(score).label();
2040    match file_score_concern_axis(score) {
2041        FileScoreConcern::Risk => {
2042            if score.crap_max >= 30.0 {
2043                label.red().bold().to_string()
2044            } else if score.crap_max >= 15.0 {
2045                label.yellow().to_string()
2046            } else {
2047                label.dimmed().to_string()
2048            }
2049        }
2050        FileScoreConcern::Structural => {
2051            if score.maintainability_index < 50.0 {
2052                label.red().bold().to_string()
2053            } else if score.maintainability_index < 80.0 {
2054                label.yellow().to_string()
2055            } else {
2056                label.dimmed().to_string()
2057            }
2058        }
2059    }
2060}
2061
2062fn file_score_risk_suffix(score: &fallow_output::FileHealthScore) -> String {
2063    if score.crap_max <= 0.0 {
2064        return String::new();
2065    }
2066    let risk_str = if score.crap_max > 999.0 {
2067        ">999".to_string()
2068    } else {
2069        format!("{:.1}", score.crap_max)
2070    };
2071    let risk_colored = if score.crap_max >= 30.0 {
2072        risk_str.red().bold().to_string()
2073    } else if score.crap_max >= 15.0 {
2074        risk_str.yellow().to_string()
2075    } else {
2076        risk_str.dimmed().to_string()
2077    };
2078    format!("  {risk_colored} risk")
2079}
2080
2081fn push_file_scores_overflow(lines: &mut Vec<String>, score_count: usize) {
2082    if score_count <= MAX_FLAT_ITEMS {
2083        return;
2084    }
2085    lines.push(format!(
2086        "  {}",
2087        format!(
2088            "... and {} more files (--format json for full list)",
2089            score_count - MAX_FLAT_ITEMS
2090        )
2091        .dimmed()
2092    ));
2093    lines.push(String::new());
2094}
2095
2096fn file_scores_crap_note(report: &fallow_output::HealthReport) -> String {
2097    if matches!(
2098        report.summary.coverage_model,
2099        Some(fallow_output::CoverageModel::Istanbul)
2100    ) {
2101        let match_info = match (
2102            report.summary.istanbul_matched,
2103            report.summary.istanbul_total,
2104        ) {
2105            (Some(m), Some(t)) if t > 0 => format!(" ({m}/{t} functions matched)"),
2106            _ => String::new(),
2107        };
2108        format!("CRAP from Istanbul coverage data{match_info}.")
2109    } else {
2110        "CRAP estimated from export references (85% direct, 40% indirect, 0% untested). Run `fallow health --coverage <coverage-final.json>` for exact scores.".to_string()
2111    }
2112}
2113
2114fn render_coverage_gaps(
2115    lines: &mut Vec<String>,
2116    report: &fallow_output::HealthReport,
2117    root: &Path,
2118) {
2119    let Some(ref gaps) = report.coverage_gaps else {
2120        return;
2121    };
2122
2123    push_coverage_gaps_header(lines, gaps);
2124    push_coverage_gap_files(lines, gaps, root);
2125    push_coverage_gap_exports(lines, gaps, root);
2126    lines.push(format!(
2127        "  {}",
2128        format!(
2129            "Static test dependency gaps (not line-level coverage): {DOCS_HEALTH}#coverage-gaps"
2130        )
2131        .dimmed()
2132    ));
2133    lines.push(String::new());
2134}
2135
2136fn push_coverage_gaps_header(lines: &mut Vec<String>, gaps: &fallow_output::CoverageGaps) {
2137    lines.push(format!(
2138        "{} {}",
2139        "\u{25cf}".yellow(),
2140        format!(
2141            "Coverage gaps ({} untested {}, {} untested {}, {:.1}% file coverage)",
2142            gaps.summary.untested_files,
2143            if gaps.summary.untested_files == 1 {
2144                "file"
2145            } else {
2146                "files"
2147            },
2148            gaps.summary.untested_exports,
2149            if gaps.summary.untested_exports == 1 {
2150                "export"
2151            } else {
2152                "exports"
2153            },
2154            gaps.summary.file_coverage_pct,
2155        )
2156        .yellow()
2157        .bold()
2158    ));
2159    lines.push(String::new());
2160}
2161
2162fn push_coverage_gap_files(
2163    lines: &mut Vec<String>,
2164    gaps: &fallow_output::CoverageGaps,
2165    root: &Path,
2166) {
2167    if gaps.files.is_empty() {
2168        return;
2169    }
2170    let shown_files = gaps.files.len().min(MAX_FLAT_ITEMS);
2171    lines.push(format!("  {}", "Files".dimmed()));
2172    for item in &gaps.files[..shown_files] {
2173        let file_str = relative_path(&item.file.path, root).display().to_string();
2174        let (dir, filename) = split_dir_filename(&file_str);
2175        lines.push(format!("  {}{}", dir.dimmed(), filename));
2176    }
2177    if gaps.files.len() > MAX_FLAT_ITEMS {
2178        lines.push(format!(
2179            "  {}",
2180            format!(
2181                "... and {} more files (--format json for full list)",
2182                gaps.files.len() - MAX_FLAT_ITEMS
2183            )
2184            .dimmed()
2185        ));
2186    }
2187    lines.push(String::new());
2188}
2189
2190fn push_coverage_gap_exports(
2191    lines: &mut Vec<String>,
2192    gaps: &fallow_output::CoverageGaps,
2193    root: &Path,
2194) {
2195    if gaps.exports.is_empty() {
2196        return;
2197    }
2198    lines.push(format!("  {}", "Exports".dimmed()));
2199
2200    let by_file = group_coverage_gap_exports_by_file(&gaps.exports);
2201    let shown = push_coverage_gap_export_rows(lines, &by_file, root);
2202    let total_exports = gaps.exports.len();
2203    if total_exports > shown {
2204        lines.push(format!(
2205            "  {}",
2206            format!(
2207                "... and {} more exports (--format json for full list)",
2208                total_exports - shown
2209            )
2210            .dimmed()
2211        ));
2212    }
2213    lines.push(String::new());
2214}
2215
2216fn group_coverage_gap_exports_by_file(
2217    exports: &[fallow_output::UntestedExportFinding],
2218) -> Vec<(&std::path::Path, Vec<&fallow_output::UntestedExportFinding>)> {
2219    let mut by_file: Vec<(&std::path::Path, Vec<&fallow_output::UntestedExportFinding>)> =
2220        Vec::new();
2221    for item in exports {
2222        match by_file.last_mut() {
2223            Some((path, items)) if *path == item.export.path.as_path() => items.push(item),
2224            _ => by_file.push((item.export.path.as_path(), vec![item])),
2225        }
2226    }
2227    by_file
2228}
2229
2230fn push_coverage_gap_export_rows(
2231    lines: &mut Vec<String>,
2232    by_file: &[(&std::path::Path, Vec<&fallow_output::UntestedExportFinding>)],
2233    root: &Path,
2234) -> usize {
2235    let mut shown = 0;
2236    for (file_path, exports) in by_file {
2237        if shown >= MAX_FLAT_ITEMS {
2238            break;
2239        }
2240        let file_str = relative_path(file_path, root).display().to_string();
2241        if exports.len() > 10 {
2242            lines.push(format!(
2243                "  {} ({} untested re-exports)",
2244                file_str.dimmed(),
2245                exports.len(),
2246            ));
2247            shown += 1;
2248        } else {
2249            shown += push_coverage_gap_export_names(lines, exports, &file_str, shown);
2250        }
2251    }
2252    shown
2253}
2254
2255fn push_coverage_gap_export_names(
2256    lines: &mut Vec<String>,
2257    exports: &[&fallow_output::UntestedExportFinding],
2258    file_str: &str,
2259    mut shown: usize,
2260) -> usize {
2261    let before = shown;
2262    for item in exports {
2263        if shown >= MAX_FLAT_ITEMS {
2264            break;
2265        }
2266        lines.push(format!(
2267            "  {}:{} `{}`",
2268            file_str.dimmed(),
2269            item.export.line,
2270            item.export.export_name,
2271        ));
2272        shown += 1;
2273    }
2274    shown - before
2275}
2276
2277/// Print a concise health summary showing only aggregate statistics.
2278pub(in crate::report) fn print_health_summary(
2279    report: &fallow_output::HealthReport,
2280    elapsed: Duration,
2281    quiet: bool,
2282    heading: bool,
2283) {
2284    let s = &report.summary;
2285
2286    if heading {
2287        outln!("{}", "Health Summary".bold());
2288        outln!();
2289    }
2290    print_health_summary_metrics(report);
2291    print_health_summary_coverage(report);
2292
2293    if !quiet {
2294        eprintln!(
2295            "{}",
2296            format!(
2297                "\u{2713} {} functions analyzed ({:.2}s)",
2298                s.functions_analyzed,
2299                elapsed.as_secs_f64()
2300            )
2301            .green()
2302            .bold()
2303        );
2304    }
2305}
2306
2307/// Prints the function-count, maintainability, and health-score summary rows.
2308fn print_health_summary_metrics(report: &fallow_output::HealthReport) {
2309    let s = &report.summary;
2310    outln!("  {:>6}  Functions analyzed", s.functions_analyzed);
2311    outln!("  {:>6}  Above threshold", s.functions_above_threshold);
2312    if let Some(mi) = s.average_maintainability {
2313        let label = if mi >= 85.0 {
2314            "good"
2315        } else if mi >= 65.0 {
2316            "moderate"
2317        } else {
2318            "low"
2319        };
2320        outln!("  {mi:>5.1}   Average maintainability ({label})");
2321    }
2322    if let Some(ref score) = report.health_score {
2323        outln!("  {:>5.0} {}  Health score", score.score, score.grade);
2324    }
2325}
2326
2327/// Prints the coverage-gap and runtime-coverage summary rows.
2328fn print_health_summary_coverage(report: &fallow_output::HealthReport) {
2329    if let Some(ref gaps) = report.coverage_gaps {
2330        outln!(
2331            "  {:>6}  Untested {} ({:.1}% file coverage)",
2332            gaps.summary.untested_files,
2333            if gaps.summary.untested_files == 1 {
2334                "file"
2335            } else {
2336                "files"
2337            },
2338            gaps.summary.file_coverage_pct,
2339        );
2340        outln!(
2341            "  {:>6}  Untested {}",
2342            gaps.summary.untested_exports,
2343            if gaps.summary.untested_exports == 1 {
2344                "export"
2345            } else {
2346                "exports"
2347            },
2348        );
2349    }
2350    if let Some(ref production) = report.runtime_coverage {
2351        outln!(
2352            "  {:>6}  Unhit in production",
2353            production.summary.functions_unhit,
2354        );
2355        outln!(
2356            "  {:>6}  Untracked by V8 (lazy-parsed / worker / dynamic)",
2357            production.summary.functions_untracked,
2358        );
2359    }
2360}
2361
2362/// Render a per-group summary block beneath the project-level human report.
2363///
2364/// Layout: a header row (`key  score  grade  files  hot  p90`) followed by
2365/// one row per group. The `score`/`grade` columns are omitted entirely when
2366/// no group carries a health score (no `--score` requested). The `p90`
2367/// column is omitted entirely when no group carries vital signs
2368/// (`--score-only` was active).
2369///
2370/// When scores are present, groups are sorted ascending by score (worst
2371/// first) so the rows match the user's "where do I refactor first?"
2372/// question. Otherwise the resolver's own ordering (descending by file
2373/// count, unowned last) is preserved.
2374///
2375/// Grade is colored to match the project-level grade: A/B green, C yellow,
2376/// D/F red.
2377///
2378/// Goes to stdout (the rows are content, not progress) so the block survives
2379/// `fallow health --group-by package > out.txt`. The leading blank line,
2380/// the `(root)` legend, and the JSON-parity hint go to stderr because they
2381/// are display affordances, not data.
2382pub(in crate::report) fn print_health_grouping(
2383    grouping: &fallow_output::HealthGrouping,
2384    _root: &Path,
2385    quiet: bool,
2386) {
2387    if grouping.groups.is_empty() {
2388        return;
2389    }
2390    if !quiet {
2391        eprintln!();
2392    }
2393    outln!(
2394        "{} {}",
2395        "\u{25cf}".cyan(),
2396        format!("Per-{} health", grouping.mode).cyan().bold()
2397    );
2398    let key_width = grouping
2399        .groups
2400        .iter()
2401        .map(|g| g.key.len())
2402        .max()
2403        .unwrap_or(0)
2404        .max(8);
2405    let any_score = grouping.groups.iter().any(|g| g.health_score.is_some());
2406    let any_vitals = grouping.groups.iter().any(|g| g.vital_signs.is_some());
2407
2408    let mut ordered: Vec<&fallow_output::HealthGroup> = grouping.groups.iter().collect();
2409    if any_score {
2410        ordered.sort_by(|a, b| {
2411            let a_score = a.health_score.as_ref().map_or(f64::INFINITY, |hs| hs.score);
2412            let b_score = b.health_score.as_ref().map_or(f64::INFINITY, |hs| hs.score);
2413            a_score
2414                .partial_cmp(&b_score)
2415                .unwrap_or(std::cmp::Ordering::Equal)
2416        });
2417    }
2418
2419    outln!(
2420        "{}",
2421        grouping_header(key_width, any_score, any_vitals).dimmed()
2422    );
2423
2424    let mut has_root_bucket = false;
2425    for group in ordered {
2426        if group.key == "(root)" {
2427            has_root_bucket = true;
2428        }
2429        outln!("{}", grouping_row(group, key_width, any_score, any_vitals));
2430    }
2431    if !quiet {
2432        if has_root_bucket {
2433            eprintln!(
2434                "  {}",
2435                "(root) = files outside any workspace package".dimmed()
2436            );
2437        }
2438        eprintln!(
2439            "  {}",
2440            "per-group summary only; --format json includes per-group findings, file scores, and hotspots"
2441                .dimmed()
2442        );
2443    }
2444}
2445
2446/// Builds the per-group header row, omitting score/grade and p90 columns when
2447/// no group carries that data.
2448fn grouping_header(key_width: usize, any_score: bool, any_vitals: bool) -> String {
2449    let mut header = format!("  {:<width$}", "", width = key_width);
2450    if any_score {
2451        let _ = write!(header, "  {:>9}  grade", "score");
2452    }
2453    let _ = write!(header, "  {:>5}", "files");
2454    let _ = write!(header, "  {:>3}", "hot");
2455    if any_vitals {
2456        let _ = write!(header, "  {:>3}", "p90");
2457    }
2458    header
2459}
2460
2461/// Builds one per-group data row, matching the column layout of `grouping_header`.
2462fn grouping_row(
2463    group: &fallow_output::HealthGroup,
2464    key_width: usize,
2465    any_score: bool,
2466    any_vitals: bool,
2467) -> String {
2468    let mut row = format!("  {:<width$}", group.key, width = key_width);
2469    if any_score {
2470        if let Some(ref hs) = group.health_score {
2471            let grade_colored = colorize_grade(hs.grade);
2472            let _ = write!(row, "  {:>9.1}  {}", hs.score, grade_colored);
2473        } else {
2474            row.push_str("                  ");
2475        }
2476    }
2477    let _ = write!(row, "  {:>5}", group.files_analyzed);
2478    let _ = write!(row, "  {:>3}", group.hotspots.len());
2479    if any_vitals {
2480        if let Some(ref vs) = group.vital_signs {
2481            let _ = write!(row, "  {:>3}", vs.p90_cyclomatic);
2482        } else {
2483            row.push_str("     ");
2484        }
2485    }
2486    row
2487}
2488
2489/// Color a grade letter to match the project-level grade rendering.
2490fn colorize_grade(grade: &str) -> String {
2491    match grade {
2492        "A" | "B" => grade.green().to_string(),
2493        "C" => grade.yellow().to_string(),
2494        _ => grade.red().to_string(),
2495    }
2496}
2497
2498#[cfg(test)]
2499mod tests {
2500    use super::super::{plain, strip_ansi};
2501    use super::*;
2502    use std::path::PathBuf;
2503
2504    #[test]
2505    fn health_empty_findings_produces_no_header() {
2506        let root = PathBuf::from("/project");
2507        let report = fallow_output::HealthReport {
2508            summary: fallow_output::HealthSummary {
2509                files_analyzed: 10,
2510                functions_analyzed: 50,
2511                ..Default::default()
2512            },
2513            ..Default::default()
2514        };
2515        let lines = build_health_human_lines(&report, &root);
2516        let text = plain(&lines);
2517        assert!(!text.contains("High complexity functions"));
2518    }
2519
2520    #[test]
2521    fn health_findings_show_function_details() {
2522        let root = PathBuf::from("/project");
2523        let report = fallow_output::HealthReport {
2524            findings: vec![
2525                fallow_output::ComplexityViolation {
2526                    path: root.join("src/parser.ts"),
2527                    name: "parseExpression".to_string(),
2528                    line: 42,
2529                    col: 0,
2530                    cyclomatic: 25,
2531                    cognitive: 30,
2532                    line_count: 80,
2533                    param_count: 0,
2534                    react_hook_count: 0,
2535                    react_jsx_max_depth: 0,
2536                    react_prop_count: 0,
2537                    react_hook_profile: None,
2538                    exceeded: fallow_output::ExceededThreshold::Both,
2539                    severity: fallow_output::FindingSeverity::High,
2540                    crap: None,
2541                    coverage_pct: None,
2542                    coverage_tier: None,
2543                    coverage_source: None,
2544                    inherited_from: None,
2545                    component_rollup: None,
2546                    contributions: Vec::new(),
2547                    effective_thresholds: None,
2548                    threshold_source: None,
2549                }
2550                .into(),
2551            ],
2552            summary: fallow_output::HealthSummary {
2553                files_analyzed: 10,
2554                functions_analyzed: 50,
2555                functions_above_threshold: 1,
2556                ..Default::default()
2557            },
2558            ..Default::default()
2559        };
2560        let lines = build_health_human_lines(&report, &root);
2561        let text = plain(&lines);
2562        assert!(text.contains("High complexity functions (1)"));
2563        assert!(text.contains("src/parser.ts"));
2564        assert!(text.contains(":42"));
2565        assert!(text.contains("parseExpression"));
2566        assert!(text.contains("25 cyclomatic"));
2567        assert!(text.contains("30 cognitive"));
2568        assert!(text.contains("80 lines"));
2569    }
2570
2571    #[test]
2572    fn health_findings_label_template_complexity_entries() {
2573        let root = PathBuf::from("/project");
2574        let report = fallow_output::HealthReport {
2575            findings: vec![
2576                fallow_output::ComplexityViolation {
2577                    path: root.join("src/Card.vue"),
2578                    name: "<template>".to_string(),
2579                    line: 1,
2580                    col: 0,
2581                    cyclomatic: 8,
2582                    cognitive: 12,
2583                    line_count: 40,
2584                    param_count: 0,
2585                    react_hook_count: 0,
2586                    react_jsx_max_depth: 0,
2587                    react_prop_count: 0,
2588                    react_hook_profile: None,
2589                    exceeded: fallow_output::ExceededThreshold::Cognitive,
2590                    severity: fallow_output::FindingSeverity::Moderate,
2591                    crap: None,
2592                    coverage_pct: None,
2593                    coverage_tier: None,
2594                    coverage_source: None,
2595                    inherited_from: None,
2596                    component_rollup: None,
2597                    contributions: Vec::new(),
2598                    effective_thresholds: None,
2599                    threshold_source: None,
2600                }
2601                .into(),
2602            ],
2603            summary: fallow_output::HealthSummary {
2604                files_analyzed: 1,
2605                functions_analyzed: 1,
2606                functions_above_threshold: 1,
2607                ..Default::default()
2608            },
2609            ..Default::default()
2610        };
2611        let lines = build_health_human_lines(&report, &root);
2612        let text = plain(&lines);
2613        assert!(text.contains("High complexity findings (1)"));
2614        assert!(text.contains("<template> (template complexity)"));
2615        assert!(text.contains("Functions and synthetic template or component entries"));
2616    }
2617
2618    #[test]
2619    fn health_shown_vs_total_when_truncated() {
2620        let root = PathBuf::from("/project");
2621        let report = fallow_output::HealthReport {
2622            findings: vec![
2623                fallow_output::ComplexityViolation {
2624                    path: root.join("src/a.ts"),
2625                    name: "fn1".to_string(),
2626                    line: 1,
2627                    col: 0,
2628                    cyclomatic: 25,
2629                    cognitive: 20,
2630                    line_count: 50,
2631                    param_count: 0,
2632                    react_hook_count: 0,
2633                    react_jsx_max_depth: 0,
2634                    react_prop_count: 0,
2635                    react_hook_profile: None,
2636                    exceeded: fallow_output::ExceededThreshold::Both,
2637                    severity: fallow_output::FindingSeverity::High,
2638                    crap: None,
2639                    coverage_pct: None,
2640                    coverage_tier: None,
2641                    coverage_source: None,
2642                    inherited_from: None,
2643                    component_rollup: None,
2644                    contributions: Vec::new(),
2645                    effective_thresholds: None,
2646                    threshold_source: None,
2647                }
2648                .into(),
2649            ],
2650            summary: fallow_output::HealthSummary {
2651                files_analyzed: 100,
2652                functions_analyzed: 500,
2653                functions_above_threshold: 10,
2654                ..Default::default()
2655            },
2656            ..Default::default()
2657        };
2658        let lines = build_health_human_lines(&report, &root);
2659        let text = plain(&lines);
2660        assert!(text.contains("1 shown, 10 total"));
2661    }
2662
2663    #[test]
2664    fn health_findings_explain_estimated_crap_scores() {
2665        let root = PathBuf::from("/project");
2666        let report = fallow_output::HealthReport {
2667            findings: vec![
2668                fallow_output::ComplexityViolation {
2669                    path: root.join("src/risky.ts"),
2670                    name: "risky".to_string(),
2671                    line: 7,
2672                    col: 0,
2673                    cyclomatic: 25,
2674                    cognitive: 20,
2675                    line_count: 80,
2676                    param_count: 0,
2677                    react_hook_count: 0,
2678                    react_jsx_max_depth: 0,
2679                    react_prop_count: 0,
2680                    react_hook_profile: None,
2681                    exceeded: fallow_output::ExceededThreshold::Crap,
2682                    severity: fallow_output::FindingSeverity::High,
2683                    crap: Some(650.0),
2684                    coverage_pct: None,
2685                    coverage_tier: Some(fallow_output::CoverageTier::None),
2686                    coverage_source: Some(fallow_output::CoverageSource::Estimated),
2687                    inherited_from: None,
2688                    component_rollup: None,
2689                    contributions: Vec::new(),
2690                    effective_thresholds: None,
2691                    threshold_source: None,
2692                }
2693                .into(),
2694            ],
2695            summary: fallow_output::HealthSummary {
2696                files_analyzed: 1,
2697                functions_analyzed: 1,
2698                functions_above_threshold: 1,
2699                coverage_model: Some(fallow_output::CoverageModel::StaticEstimated),
2700                coverage_source_consistency: None,
2701                ..Default::default()
2702            },
2703            ..Default::default()
2704        };
2705        let text = plain(&build_health_human_lines(&report, &root));
2706        assert!(text.contains("CRAP scores are estimated from export references"));
2707        assert!(text.contains("fallow health --coverage <coverage-final.json>"));
2708    }
2709
2710    #[test]
2711    fn health_findings_explain_mixed_istanbul_crap_scores() {
2712        let root = PathBuf::from("/project");
2713        let report = fallow_output::HealthReport {
2714            findings: vec![
2715                fallow_output::ComplexityViolation {
2716                    path: root.join("src/risky.ts"),
2717                    name: "risky".to_string(),
2718                    line: 7,
2719                    col: 0,
2720                    cyclomatic: 25,
2721                    cognitive: 20,
2722                    line_count: 80,
2723                    param_count: 0,
2724                    react_hook_count: 0,
2725                    react_jsx_max_depth: 0,
2726                    react_prop_count: 0,
2727                    react_hook_profile: None,
2728                    exceeded: fallow_output::ExceededThreshold::Crap,
2729                    severity: fallow_output::FindingSeverity::High,
2730                    crap: Some(45.0),
2731                    coverage_pct: Some(40.0),
2732                    coverage_tier: Some(fallow_output::CoverageTier::Partial),
2733                    coverage_source: Some(fallow_output::CoverageSource::Istanbul),
2734                    inherited_from: None,
2735                    component_rollup: None,
2736                    contributions: Vec::new(),
2737                    effective_thresholds: None,
2738                    threshold_source: None,
2739                }
2740                .into(),
2741            ],
2742            summary: fallow_output::HealthSummary {
2743                files_analyzed: 1,
2744                functions_analyzed: 2,
2745                functions_above_threshold: 1,
2746                coverage_model: Some(fallow_output::CoverageModel::Istanbul),
2747                coverage_source_consistency: None,
2748                istanbul_matched: Some(1),
2749                istanbul_total: Some(2),
2750                ..Default::default()
2751            },
2752            ..Default::default()
2753        };
2754        let text = plain(&build_health_human_lines(&report, &root));
2755        assert!(
2756            text.contains(
2757                "CRAP scores use Istanbul coverage where matched (1/2 functions); unmatched functions are estimated"
2758            ),
2759            "mixed Istanbul note missing from output: {text}"
2760        );
2761    }
2762
2763    #[test]
2764    fn health_findings_explain_istanbul_counts_without_summary_model() {
2765        let root = PathBuf::from("/project");
2766        let report = fallow_output::HealthReport {
2767            findings: vec![
2768                fallow_output::ComplexityViolation {
2769                    path: root.join("src/risky.ts"),
2770                    name: "risky".to_string(),
2771                    line: 7,
2772                    col: 0,
2773                    cyclomatic: 25,
2774                    cognitive: 20,
2775                    line_count: 80,
2776                    param_count: 0,
2777                    react_hook_count: 0,
2778                    react_jsx_max_depth: 0,
2779                    react_prop_count: 0,
2780                    react_hook_profile: None,
2781                    exceeded: fallow_output::ExceededThreshold::Crap,
2782                    severity: fallow_output::FindingSeverity::High,
2783                    crap: Some(45.0),
2784                    coverage_pct: None,
2785                    coverage_tier: Some(fallow_output::CoverageTier::None),
2786                    coverage_source: Some(fallow_output::CoverageSource::Estimated),
2787                    inherited_from: None,
2788                    component_rollup: None,
2789                    contributions: Vec::new(),
2790                    effective_thresholds: None,
2791                    threshold_source: None,
2792                }
2793                .into(),
2794            ],
2795            summary: fallow_output::HealthSummary {
2796                files_analyzed: 1,
2797                functions_analyzed: 2,
2798                functions_above_threshold: 1,
2799                coverage_model: None,
2800                coverage_source_consistency: None,
2801                istanbul_matched: Some(1),
2802                istanbul_total: Some(2),
2803                ..Default::default()
2804            },
2805            ..Default::default()
2806        };
2807        let text = plain(&build_health_human_lines(&report, &root));
2808        assert!(
2809            text.contains(
2810                "CRAP scores use Istanbul coverage where matched (1/2 functions); unmatched functions are estimated"
2811            ),
2812            "Istanbul counts should drive the note even when coverage_model is omitted: {text}"
2813        );
2814    }
2815
2816    #[test]
2817    fn health_findings_grouped_by_file() {
2818        let root = PathBuf::from("/project");
2819        let report = fallow_output::HealthReport {
2820            findings: vec![
2821                fallow_output::ComplexityViolation {
2822                    path: root.join("src/parser.ts"),
2823                    name: "fn1".to_string(),
2824                    line: 10,
2825                    col: 0,
2826                    cyclomatic: 25,
2827                    cognitive: 20,
2828                    line_count: 40,
2829                    param_count: 0,
2830                    react_hook_count: 0,
2831                    react_jsx_max_depth: 0,
2832                    react_prop_count: 0,
2833                    react_hook_profile: None,
2834                    exceeded: fallow_output::ExceededThreshold::Both,
2835                    severity: fallow_output::FindingSeverity::High,
2836                    crap: None,
2837                    coverage_pct: None,
2838                    coverage_tier: None,
2839                    coverage_source: None,
2840                    inherited_from: None,
2841                    component_rollup: None,
2842                    contributions: Vec::new(),
2843                    effective_thresholds: None,
2844                    threshold_source: None,
2845                }
2846                .into(),
2847                fallow_output::ComplexityViolation {
2848                    path: root.join("src/parser.ts"),
2849                    name: "fn2".to_string(),
2850                    line: 60,
2851                    col: 0,
2852                    cyclomatic: 22,
2853                    cognitive: 18,
2854                    line_count: 30,
2855                    param_count: 0,
2856                    react_hook_count: 0,
2857                    react_jsx_max_depth: 0,
2858                    react_prop_count: 0,
2859                    react_hook_profile: None,
2860                    exceeded: fallow_output::ExceededThreshold::Both,
2861                    severity: fallow_output::FindingSeverity::High,
2862                    crap: None,
2863                    coverage_pct: None,
2864                    coverage_tier: None,
2865                    coverage_source: None,
2866                    inherited_from: None,
2867                    component_rollup: None,
2868                    contributions: Vec::new(),
2869                    effective_thresholds: None,
2870                    threshold_source: None,
2871                }
2872                .into(),
2873            ],
2874            summary: fallow_output::HealthSummary {
2875                files_analyzed: 10,
2876                functions_analyzed: 50,
2877                functions_above_threshold: 2,
2878                ..Default::default()
2879            },
2880            ..Default::default()
2881        };
2882        let lines = build_health_human_lines(&report, &root);
2883        let text = plain(&lines);
2884        let count = text.matches("src/parser.ts").count();
2885        assert_eq!(count, 1, "File header should appear once for grouped items");
2886    }
2887
2888    fn empty_report() -> fallow_output::HealthReport {
2889        fallow_output::HealthReport {
2890            summary: fallow_output::HealthSummary {
2891                files_analyzed: 10,
2892                functions_analyzed: 50,
2893                ..Default::default()
2894            },
2895            ..Default::default()
2896        }
2897    }
2898
2899    #[test]
2900    fn health_runtime_coverage_renders_section() {
2901        let root = PathBuf::from("/project");
2902        let mut report = empty_report();
2903        report.runtime_coverage = Some(fallow_output::RuntimeCoverageReport {
2904            schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
2905            verdict: fallow_output::RuntimeCoverageReportVerdict::ColdCodeDetected,
2906            signals: Vec::new(),
2907            summary: fallow_output::RuntimeCoverageSummary {
2908                data_source: fallow_output::RuntimeCoverageDataSource::Local,
2909                last_received_at: None,
2910                functions_tracked: 4,
2911                functions_hit: 2,
2912                functions_unhit: 1,
2913                functions_untracked: 1,
2914                coverage_percent: 50.0,
2915                trace_count: 2_847_291,
2916                period_days: 30,
2917                deployments_seen: 14,
2918                capture_quality: None,
2919            },
2920            findings: vec![fallow_output::RuntimeCoverageFinding {
2921                id: "fallow:prod:deadbeef".to_owned(),
2922                stable_id: None,
2923                path: root.join("src/cold.ts"),
2924                function: "coldPath".to_owned(),
2925                line: 14,
2926                verdict: fallow_output::RuntimeCoverageVerdict::ReviewRequired,
2927                invocations: Some(0),
2928                confidence: fallow_output::RuntimeCoverageConfidence::Medium,
2929                evidence: fallow_output::RuntimeCoverageEvidence {
2930                    static_status: "used".to_owned(),
2931                    test_coverage: "not_covered".to_owned(),
2932                    v8_tracking: "tracked".to_owned(),
2933                    untracked_reason: None,
2934                    observation_days: 30,
2935                    deployments_observed: 14,
2936                },
2937                actions: vec![],
2938                source_hash: None,
2939                discriminators: None,
2940            }],
2941            hot_paths: vec![fallow_output::RuntimeCoverageHotPath {
2942                id: "fallow:hot:cafebabe".to_owned(),
2943                stable_id: None,
2944                path: root.join("src/hot.ts"),
2945                function: "hotPath".to_owned(),
2946                line: 3,
2947                end_line: 9,
2948                invocations: 250,
2949                percentile: 99,
2950                actions: vec![],
2951            }],
2952            blast_radius: vec![],
2953            importance: vec![],
2954            watermark: Some(fallow_output::RuntimeCoverageWatermark::LicenseExpiredGrace),
2955            warnings: vec![],
2956            actionable: true,
2957            actionability_reason: None,
2958            actionability_verdict: None,
2959            provenance: fallow_output::RuntimeCoverageProvenance::default(),
2960        });
2961
2962        let text = plain(&build_health_human_lines(&report, &root));
2963        assert!(text.contains("Runtime coverage: cold code detected"));
2964        assert!(text.contains("src/cold.ts:14 coldPath [0 invocations, review required]"));
2965        assert!(text.contains("license expired grace active"));
2966        assert!(text.contains("hot paths:"));
2967        assert!(text.contains("src/hot.ts:3 hotPath (250 invocations, p99)"));
2968        assert!(!text.contains("short capture:"));
2969        assert!(!text.contains("start a trial"));
2970    }
2971
2972    #[test]
2973    fn health_coverage_intelligence_renders_findings_and_ambiguity_summary() {
2974        use fallow_output::{
2975            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
2976            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
2977            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
2978            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
2979            CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
2980        };
2981
2982        let root = PathBuf::from("/project");
2983        let mut report = empty_report();
2984        report.coverage_intelligence = Some(CoverageIntelligenceReport {
2985            schema_version: CoverageIntelligenceSchemaVersion::V1,
2986            verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2987            summary: CoverageIntelligenceSummary {
2988                findings: 1,
2989                high_confidence_deletes: 1,
2990                ..Default::default()
2991            },
2992            findings: vec![CoverageIntelligenceFinding {
2993                id: "fallow:coverage-intel:abc123".to_owned(),
2994                path: root.join("src/dead.ts"),
2995                identity: Some("deadPath".to_owned()),
2996                line: 9,
2997                verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
2998                signals: vec![CoverageIntelligenceSignal::RuntimeCold],
2999                recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
3000                confidence: CoverageIntelligenceConfidence::High,
3001                related_ids: vec![],
3002                evidence: CoverageIntelligenceEvidence {
3003                    match_confidence: CoverageIntelligenceMatchConfidence::Direct,
3004                    ..Default::default()
3005                },
3006                actions: vec![CoverageIntelligenceAction {
3007                    kind: "delete-after-confirming-owner".to_owned(),
3008                    description: "Confirm ownership before deleting".to_owned(),
3009                    auto_fixable: false,
3010                }],
3011            }],
3012        });
3013
3014        let text = plain(&build_health_human_lines(&report, &root));
3015        assert!(text.contains("Coverage intelligence"));
3016        assert!(text.contains("src/dead.ts:9 deadPath high-confidence-delete"));
3017        assert!(text.contains("Confirm ownership before deleting"));
3018
3019        report.coverage_intelligence = Some(CoverageIntelligenceReport {
3020            schema_version: CoverageIntelligenceSchemaVersion::V1,
3021            verdict: CoverageIntelligenceVerdict::Clean,
3022            summary: CoverageIntelligenceSummary {
3023                skipped_ambiguous_matches: 2,
3024                ..Default::default()
3025            },
3026            findings: vec![],
3027        });
3028        let text = plain(&build_health_human_lines(&report, &root));
3029        assert!(text.contains("skipped 2 ambiguous evidence matches"));
3030    }
3031
3032    fn runtime_coverage_report_with_quality(
3033        quality: Option<fallow_output::RuntimeCoverageCaptureQuality>,
3034    ) -> fallow_output::RuntimeCoverageReport {
3035        fallow_output::RuntimeCoverageReport {
3036            schema_version: fallow_output::RuntimeCoverageSchemaVersion::V1,
3037            verdict: fallow_output::RuntimeCoverageReportVerdict::Clean,
3038            signals: Vec::new(),
3039            summary: fallow_output::RuntimeCoverageSummary {
3040                data_source: fallow_output::RuntimeCoverageDataSource::Local,
3041                last_received_at: None,
3042                functions_tracked: 10,
3043                functions_hit: 7,
3044                functions_unhit: 0,
3045                functions_untracked: 3,
3046                coverage_percent: 70.0,
3047                trace_count: 1_000,
3048                period_days: 1,
3049                deployments_seen: 1,
3050                capture_quality: quality,
3051            },
3052            findings: vec![],
3053            hot_paths: vec![],
3054            blast_radius: vec![],
3055            importance: vec![],
3056            watermark: None,
3057            warnings: vec![],
3058            actionable: true,
3059            actionability_reason: None,
3060            actionability_verdict: None,
3061            provenance: fallow_output::RuntimeCoverageProvenance::default(),
3062        }
3063    }
3064
3065    #[test]
3066    fn health_runtime_coverage_short_capture_shows_warning_and_prompt() {
3067        let root = PathBuf::from("/project");
3068        let mut report = empty_report();
3069        report.runtime_coverage = Some(runtime_coverage_report_with_quality(Some(
3070            fallow_output::RuntimeCoverageCaptureQuality {
3071                window_seconds: 720, // 12 min
3072                instances_observed: 1,
3073                lazy_parse_warning: true,
3074                untracked_ratio_percent: 42.5,
3075            },
3076        )));
3077        let text = plain(&build_health_human_lines(&report, &root));
3078        assert!(
3079            text.contains(
3080                "note: short capture (12 min from 1 instance); 42.5% of functions untracked, lazy-parsed scripts may not appear."
3081            ),
3082            "warning banner missing or malformed in:\n{text}"
3083        );
3084        assert!(
3085            text.contains("extend the capture or switch to continuous monitoring"),
3086            "warning follow-up line missing in:\n{text}"
3087        );
3088        assert!(
3089            text.contains("captured 12 min from 1 instance."),
3090            "upgrade prompt header missing in:\n{text}"
3091        );
3092        assert!(
3093            text.contains("continuous monitoring over 30 days evaluates more paths"),
3094            "upgrade prompt body missing in:\n{text}"
3095        );
3096        assert!(
3097            text.contains("fallow license activate --trial --email you@company.com"),
3098            "trial CTA command missing in:\n{text}"
3099        );
3100    }
3101
3102    #[test]
3103    fn health_runtime_coverage_long_capture_shows_neither_warning_nor_prompt() {
3104        let root = PathBuf::from("/project");
3105        let mut report = empty_report();
3106        report.runtime_coverage = Some(runtime_coverage_report_with_quality(Some(
3107            fallow_output::RuntimeCoverageCaptureQuality {
3108                window_seconds: 7 * 24 * 3600, // 7 days
3109                instances_observed: 4,
3110                lazy_parse_warning: false,
3111                untracked_ratio_percent: 3.1,
3112            },
3113        )));
3114        let text = plain(&build_health_human_lines(&report, &root));
3115        assert!(
3116            !text.contains("short capture"),
3117            "long capture should not emit short-capture warning:\n{text}"
3118        );
3119        assert!(
3120            !text.contains("start a trial"),
3121            "long capture should not emit trial CTA:\n{text}"
3122        );
3123    }
3124
3125    #[test]
3126    fn format_window_labels() {
3127        assert_eq!(super::format_window(30), "30 s");
3128        assert_eq!(super::format_window(60), "1 min");
3129        assert_eq!(super::format_window(720), "12 min");
3130        assert_eq!(super::format_window(3600 * 3), "3 h");
3131        assert_eq!(super::format_window(3600 * 24 * 3), "3 d");
3132    }
3133
3134    #[test]
3135    fn health_coverage_gaps_render_section() {
3136        use fallow_output::*;
3137
3138        let root = PathBuf::from("/project");
3139        let mut report = empty_report();
3140        report.coverage_gaps = Some(CoverageGaps {
3141            summary: CoverageGapSummary {
3142                runtime_files: 1,
3143                covered_files: 0,
3144                file_coverage_pct: 0.0,
3145                untested_files: 1,
3146                untested_exports: 1,
3147            },
3148            files: vec![UntestedFileFinding::with_actions(
3149                UntestedFile {
3150                    path: root.join("src/app.ts"),
3151                    value_export_count: 2,
3152                },
3153                &root,
3154            )],
3155            exports: vec![UntestedExportFinding::with_actions(
3156                UntestedExport {
3157                    path: root.join("src/app.ts"),
3158                    export_name: "loader".into(),
3159                    line: 12,
3160                    col: 4,
3161                },
3162                &root,
3163            )],
3164        });
3165
3166        let text = plain(&build_health_human_lines(&report, &root));
3167        assert!(
3168            text.contains("Coverage gaps (1 untested file, 1 untested export, 0.0% file coverage)")
3169        );
3170        assert!(text.contains("src/app.ts"));
3171        assert!(text.contains("loader"));
3172    }
3173
3174    #[test]
3175    fn fmt_trend_val_percentage() {
3176        assert_eq!(fmt_trend_val(15.5, "%"), "15.5%");
3177        assert_eq!(fmt_trend_val(0.0, "%"), "0.0%");
3178    }
3179
3180    #[test]
3181    fn fmt_trend_val_integer_when_round() {
3182        assert_eq!(fmt_trend_val(72.0, ""), "72");
3183        assert_eq!(fmt_trend_val(5.0, "pts"), "5");
3184    }
3185
3186    #[test]
3187    fn fmt_trend_val_decimal_when_fractional() {
3188        assert_eq!(fmt_trend_val(4.7, ""), "4.7");
3189        assert_eq!(fmt_trend_val(1.3, "pts"), "1.3");
3190    }
3191
3192    #[test]
3193    fn fmt_trend_delta_percentage() {
3194        assert_eq!(fmt_trend_delta(2.5, "%"), "+2.5%");
3195        assert_eq!(fmt_trend_delta(-1.3, "%"), "-1.3%");
3196    }
3197
3198    #[test]
3199    fn fmt_trend_delta_integer_when_round() {
3200        assert_eq!(fmt_trend_delta(5.0, ""), "+5");
3201        assert_eq!(fmt_trend_delta(-3.0, "pts"), "-3");
3202    }
3203
3204    #[test]
3205    fn fmt_trend_delta_decimal_when_fractional() {
3206        assert_eq!(fmt_trend_delta(4.9, ""), "+4.9");
3207        assert_eq!(fmt_trend_delta(-0.7, "pts"), "-0.7");
3208    }
3209
3210    #[test]
3211    fn health_score_grade_a_display() {
3212        let root = PathBuf::from("/project");
3213        let mut report = empty_report();
3214        report.health_score = Some(fallow_output::HealthScore {
3215            formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
3216            score: 92.0,
3217            grade: "A",
3218            penalties: fallow_output::HealthScorePenalties {
3219                dead_files: Some(3.0),
3220                dead_exports: Some(2.0),
3221                complexity: 1.5,
3222                p90_complexity: 1.5,
3223                maintainability: Some(0.0),
3224                hotspots: Some(0.0),
3225                unused_deps: Some(0.0),
3226                circular_deps: Some(0.0),
3227                unit_size: None,
3228                coupling: None,
3229                duplication: None,
3230                prop_drilling: None,
3231            },
3232        });
3233        let lines = build_health_human_lines(&report, &root);
3234        let text = plain(&lines);
3235        assert!(text.contains("Health score:"));
3236        assert!(text.contains("92 A"));
3237        assert!(text.contains("dead files -3.0"));
3238        assert!(text.contains("dead exports -2.0"));
3239        assert!(text.contains("complexity -1.5"));
3240        assert!(text.contains("p90 -1.5"));
3241    }
3242
3243    #[test]
3244    fn health_score_grade_b_display() {
3245        let root = PathBuf::from("/project");
3246        let mut report = empty_report();
3247        report.health_score = Some(fallow_output::HealthScore {
3248            formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
3249            score: 76.0,
3250            grade: "B",
3251            penalties: fallow_output::HealthScorePenalties {
3252                dead_files: Some(5.0),
3253                dead_exports: Some(6.0),
3254                complexity: 3.0,
3255                p90_complexity: 2.0,
3256                maintainability: Some(4.0),
3257                hotspots: Some(2.0),
3258                unused_deps: Some(1.0),
3259                circular_deps: Some(1.0),
3260                unit_size: None,
3261                coupling: None,
3262                duplication: None,
3263                prop_drilling: None,
3264            },
3265        });
3266        let lines = build_health_human_lines(&report, &root);
3267        let text = plain(&lines);
3268        assert!(text.contains("76 B"));
3269        assert!(text.contains("dead exports -6.0"));
3270        assert!(text.contains("maintainability -4.0"));
3271        assert!(text.contains("hotspots -2.0"));
3272        assert!(text.contains("unused deps -1.0"));
3273        assert!(text.contains("circular deps -1.0"));
3274    }
3275
3276    #[test]
3277    fn health_score_grade_c_display() {
3278        let root = PathBuf::from("/project");
3279        let mut report = empty_report();
3280        report.health_score = Some(fallow_output::HealthScore {
3281            formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
3282            score: 60.0,
3283            grade: "C",
3284            penalties: fallow_output::HealthScorePenalties {
3285                dead_files: Some(10.0),
3286                dead_exports: Some(10.0),
3287                complexity: 10.0,
3288                p90_complexity: 5.0,
3289                maintainability: Some(5.0),
3290                hotspots: None,
3291                unused_deps: None,
3292                circular_deps: None,
3293                unit_size: None,
3294                coupling: None,
3295                duplication: None,
3296                prop_drilling: None,
3297            },
3298        });
3299        let lines = build_health_human_lines(&report, &root);
3300        let text = plain(&lines);
3301        assert!(text.contains("60 C"));
3302    }
3303
3304    #[test]
3305    fn health_score_grade_f_display() {
3306        let root = PathBuf::from("/project");
3307        let mut report = empty_report();
3308        report.health_score = Some(fallow_output::HealthScore {
3309            formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
3310            score: 30.0,
3311            grade: "F",
3312            penalties: fallow_output::HealthScorePenalties {
3313                dead_files: Some(15.0),
3314                dead_exports: Some(15.0),
3315                complexity: 20.0,
3316                p90_complexity: 10.0,
3317                maintainability: Some(10.0),
3318                hotspots: None,
3319                unused_deps: None,
3320                circular_deps: None,
3321                unit_size: None,
3322                coupling: None,
3323                duplication: None,
3324                prop_drilling: None,
3325            },
3326        });
3327        let lines = build_health_human_lines(&report, &root);
3328        let text = plain(&lines);
3329        assert!(text.contains("30 F"));
3330    }
3331
3332    #[test]
3333    fn health_score_na_components_shown() {
3334        let root = PathBuf::from("/project");
3335        let mut report = empty_report();
3336        report.health_score = Some(fallow_output::HealthScore {
3337            formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
3338            score: 90.0,
3339            grade: "A",
3340            penalties: fallow_output::HealthScorePenalties {
3341                dead_files: None,
3342                dead_exports: None,
3343                complexity: 0.0,
3344                p90_complexity: 0.0,
3345                maintainability: None,
3346                hotspots: None,
3347                unused_deps: None,
3348                circular_deps: None,
3349                unit_size: None,
3350                coupling: None,
3351                duplication: None,
3352                prop_drilling: None,
3353            },
3354        });
3355        let lines = build_health_human_lines(&report, &root);
3356        let text = plain(&lines);
3357        assert!(text.contains("N/A: dead code, maintainability, hotspots"));
3358        assert!(text.contains("enable the corresponding analysis flags"));
3359    }
3360
3361    #[test]
3362    fn health_score_no_na_when_all_present() {
3363        let root = PathBuf::from("/project");
3364        let mut report = empty_report();
3365        report.health_score = Some(fallow_output::HealthScore {
3366            formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
3367            score: 85.0,
3368            grade: "A",
3369            penalties: fallow_output::HealthScorePenalties {
3370                dead_files: Some(0.0),
3371                dead_exports: Some(0.0),
3372                complexity: 0.0,
3373                p90_complexity: 0.0,
3374                maintainability: Some(0.0),
3375                hotspots: Some(0.0),
3376                unused_deps: Some(0.0),
3377                circular_deps: Some(0.0),
3378                unit_size: None,
3379                coupling: None,
3380                duplication: None,
3381                prop_drilling: None,
3382            },
3383        });
3384        let lines = build_health_human_lines(&report, &root);
3385        let text = plain(&lines);
3386        assert!(!text.contains("N/A:"));
3387    }
3388
3389    #[test]
3390    fn health_score_zero_penalties_suppressed() {
3391        let root = PathBuf::from("/project");
3392        let mut report = empty_report();
3393        report.health_score = Some(fallow_output::HealthScore {
3394            formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
3395            score: 100.0,
3396            grade: "A",
3397            penalties: fallow_output::HealthScorePenalties {
3398                dead_files: Some(0.0),
3399                dead_exports: Some(0.0),
3400                complexity: 0.0,
3401                p90_complexity: 0.0,
3402                maintainability: Some(0.0),
3403                hotspots: Some(0.0),
3404                unused_deps: Some(0.0),
3405                circular_deps: Some(0.0),
3406                unit_size: None,
3407                coupling: None,
3408                duplication: None,
3409                prop_drilling: None,
3410            },
3411        });
3412        let lines = build_health_human_lines(&report, &root);
3413        let text = plain(&lines);
3414        assert!(!text.contains("dead files"));
3415        assert!(!text.contains("complexity -"));
3416    }
3417
3418    #[test]
3419    fn health_trend_improving_display() {
3420        let root = PathBuf::from("/project");
3421        let mut report = empty_report();
3422        report.health_trend = Some(fallow_output::HealthTrend {
3423            compared_to: fallow_output::TrendPoint {
3424                timestamp: "2026-03-25T14:30:00Z".into(),
3425                git_sha: Some("abc1234".into()),
3426                score: Some(72.0),
3427                grade: Some("B".into()),
3428                coverage_model: None,
3429                snapshot_schema_version: None,
3430            },
3431            metrics: vec![
3432                fallow_output::TrendMetric {
3433                    name: "score",
3434                    label: "Health Score",
3435                    previous: 72.0,
3436                    current: 85.0,
3437                    delta: 13.0,
3438                    direction: fallow_output::TrendDirection::Improving,
3439                    unit: "",
3440                    previous_count: None,
3441                    current_count: None,
3442                },
3443                fallow_output::TrendMetric {
3444                    name: "dead_file_pct",
3445                    label: "Dead Files",
3446                    previous: 10.0,
3447                    current: 5.0,
3448                    delta: -5.0,
3449                    direction: fallow_output::TrendDirection::Improving,
3450                    unit: "%",
3451                    previous_count: None,
3452                    current_count: None,
3453                },
3454            ],
3455            snapshots_loaded: 2,
3456            overall_direction: fallow_output::TrendDirection::Improving,
3457        });
3458        let lines = build_health_human_lines(&report, &root);
3459        let text = plain(&lines);
3460        assert!(text.contains("Trend:"));
3461        assert!(text.contains("improving"));
3462        assert!(text.contains("vs 2026-03-25"));
3463        assert!(text.contains("abc1234"));
3464        assert!(text.contains("Health Score"));
3465        assert!(text.contains("+13"));
3466        assert!(text.contains("Dead Files"));
3467        assert!(text.contains("-5.0%"));
3468    }
3469
3470    #[test]
3471    fn health_trend_declining_display() {
3472        let root = PathBuf::from("/project");
3473        let mut report = empty_report();
3474        report.health_trend = Some(fallow_output::HealthTrend {
3475            compared_to: fallow_output::TrendPoint {
3476                timestamp: "2026-03-20T10:00:00Z".into(),
3477                git_sha: None,
3478                score: None,
3479                grade: None,
3480                coverage_model: None,
3481                snapshot_schema_version: None,
3482            },
3483            metrics: vec![fallow_output::TrendMetric {
3484                name: "unused_deps",
3485                label: "Unused Deps",
3486                previous: 5.0,
3487                current: 10.0,
3488                delta: 5.0,
3489                direction: fallow_output::TrendDirection::Declining,
3490                unit: "",
3491                previous_count: None,
3492                current_count: None,
3493            }],
3494            snapshots_loaded: 1,
3495            overall_direction: fallow_output::TrendDirection::Declining,
3496        });
3497        let lines = build_health_human_lines(&report, &root);
3498        let text = plain(&lines);
3499        assert!(text.contains("declining"));
3500        assert!(text.contains("Unused Deps"));
3501    }
3502
3503    #[test]
3504    fn health_trend_all_stable_collapsed() {
3505        let root = PathBuf::from("/project");
3506        let mut report = empty_report();
3507        report.health_trend = Some(fallow_output::HealthTrend {
3508            compared_to: fallow_output::TrendPoint {
3509                timestamp: "2026-03-25T14:30:00Z".into(),
3510                git_sha: Some("def5678".into()),
3511                score: Some(80.0),
3512                grade: Some("B".into()),
3513                coverage_model: None,
3514                snapshot_schema_version: None,
3515            },
3516            metrics: vec![
3517                fallow_output::TrendMetric {
3518                    name: "score",
3519                    label: "Health Score",
3520                    previous: 80.0,
3521                    current: 80.0,
3522                    delta: 0.0,
3523                    direction: fallow_output::TrendDirection::Stable,
3524                    unit: "",
3525                    previous_count: None,
3526                    current_count: None,
3527                },
3528                fallow_output::TrendMetric {
3529                    name: "avg_cyclomatic",
3530                    label: "Avg Cyclomatic",
3531                    previous: 2.0,
3532                    current: 2.0,
3533                    delta: 0.0,
3534                    direction: fallow_output::TrendDirection::Stable,
3535                    unit: "",
3536                    previous_count: None,
3537                    current_count: None,
3538                },
3539            ],
3540            snapshots_loaded: 3,
3541            overall_direction: fallow_output::TrendDirection::Stable,
3542        });
3543        let lines = build_health_human_lines(&report, &root);
3544        let text = plain(&lines);
3545        assert!(text.contains("stable"));
3546        assert!(text.contains("All 2 metrics unchanged"));
3547        assert!(!text.contains("Health Score"));
3548    }
3549
3550    #[test]
3551    fn health_trend_without_sha() {
3552        let root = PathBuf::from("/project");
3553        let mut report = empty_report();
3554        report.health_trend = Some(fallow_output::HealthTrend {
3555            compared_to: fallow_output::TrendPoint {
3556                timestamp: "2026-03-20T10:00:00Z".into(),
3557                git_sha: None,
3558                score: None,
3559                grade: None,
3560                coverage_model: None,
3561                snapshot_schema_version: None,
3562            },
3563            metrics: vec![fallow_output::TrendMetric {
3564                name: "score",
3565                label: "Health Score",
3566                previous: 80.0,
3567                current: 82.0,
3568                delta: 2.0,
3569                direction: fallow_output::TrendDirection::Improving,
3570                unit: "",
3571                previous_count: None,
3572                current_count: None,
3573            }],
3574            snapshots_loaded: 1,
3575            overall_direction: fallow_output::TrendDirection::Improving,
3576        });
3577        let lines = build_health_human_lines(&report, &root);
3578        let text = plain(&lines);
3579        assert!(text.contains("vs 2026-03-20"));
3580        assert!(!text.contains("\u{00b7}"));
3581    }
3582
3583    #[test]
3584    fn vital_signs_shown_without_trend() {
3585        let root = PathBuf::from("/project");
3586        let mut report = empty_report();
3587        report.vital_signs = Some(fallow_output::VitalSigns {
3588            dead_file_pct: Some(3.2),
3589            dead_export_pct: Some(8.1),
3590            avg_cyclomatic: 4.7,
3591            p90_cyclomatic: 12,
3592            duplication_pct: None,
3593            hotspot_count: Some(2),
3594            maintainability_avg: Some(72.4),
3595            unused_dep_count: Some(3),
3596            circular_dep_count: Some(1),
3597            counts: None,
3598            unit_size_profile: None,
3599            unit_interfacing_profile: None,
3600            p95_fan_in: None,
3601            coupling_high_pct: None,
3602            total_loc: 42_381,
3603            ..Default::default()
3604        });
3605        report.hotspot_summary = Some(fallow_output::HotspotSummary {
3606            since: "6 months".to_string(),
3607            min_commits: 3,
3608            files_analyzed: 50,
3609            files_excluded: 20,
3610            shallow_clone: false,
3611        });
3612        let lines = build_health_human_lines(&report, &root);
3613        let text = plain(&lines);
3614        assert!(text.contains("42,381 LOC"));
3615        assert!(text.contains("dead files 3.2%"));
3616        assert!(text.contains("dead exports 8.1%"));
3617        assert!(text.contains("avg cyclomatic 4.7"));
3618        assert!(text.contains("p90 cyclomatic 12"));
3619        assert!(text.contains("maintainability 72.4"));
3620        assert!(text.contains("2 churn hotspots (since 6 months)"));
3621        assert!(text.contains("3 unused deps"));
3622        assert!(text.contains("1 circular dep"));
3623    }
3624
3625    #[test]
3626    fn vital_signs_zero_hotspots_still_show_window() {
3627        let root = PathBuf::from("/project");
3628        let mut report = empty_report();
3629        report.vital_signs = Some(fallow_output::VitalSigns {
3630            avg_cyclomatic: 2.0,
3631            p90_cyclomatic: 5,
3632            hotspot_count: Some(0),
3633            total_loc: 1_000,
3634            ..Default::default()
3635        });
3636        report.hotspot_summary = Some(fallow_output::HotspotSummary {
3637            since: "90 days".to_string(),
3638            min_commits: 3,
3639            files_analyzed: 10,
3640            files_excluded: 0,
3641            shallow_clone: false,
3642        });
3643        let lines = build_health_human_lines(&report, &root);
3644        let text = plain(&lines);
3645        assert!(text.contains("0 churn hotspots (since 90 days)"));
3646        assert!(!text.contains("Hotspots ("));
3647    }
3648
3649    #[test]
3650    fn vital_signs_hotspot_count_without_summary_omits_window() {
3651        let root = PathBuf::from("/project");
3652        let mut report = empty_report();
3653        report.vital_signs = Some(fallow_output::VitalSigns {
3654            avg_cyclomatic: 2.0,
3655            p90_cyclomatic: 5,
3656            hotspot_count: Some(1),
3657            total_loc: 1_000,
3658            ..Default::default()
3659        });
3660        report.hotspot_summary = None;
3661        let lines = build_health_human_lines(&report, &root);
3662        let text = plain(&lines);
3663        assert!(text.contains("1 churn hotspot"));
3664        assert!(!text.contains("(since"));
3665    }
3666
3667    #[test]
3668    fn vital_signs_suppressed_when_trend_active() {
3669        let root = PathBuf::from("/project");
3670        let mut report = empty_report();
3671        report.vital_signs = Some(fallow_output::VitalSigns {
3672            dead_file_pct: Some(3.2),
3673            dead_export_pct: Some(8.1),
3674            avg_cyclomatic: 4.7,
3675            p90_cyclomatic: 12,
3676            duplication_pct: None,
3677            hotspot_count: Some(2),
3678            maintainability_avg: Some(72.4),
3679            unused_dep_count: None,
3680            circular_dep_count: None,
3681            counts: None,
3682            unit_size_profile: None,
3683            unit_interfacing_profile: None,
3684            p95_fan_in: None,
3685            coupling_high_pct: None,
3686            total_loc: 0,
3687            ..Default::default()
3688        });
3689        report.health_trend = Some(fallow_output::HealthTrend {
3690            compared_to: fallow_output::TrendPoint {
3691                timestamp: "2026-03-25T14:30:00Z".into(),
3692                git_sha: None,
3693                score: None,
3694                grade: None,
3695                coverage_model: None,
3696                snapshot_schema_version: None,
3697            },
3698            metrics: vec![],
3699            snapshots_loaded: 1,
3700            overall_direction: fallow_output::TrendDirection::Stable,
3701        });
3702        let lines = build_health_human_lines(&report, &root);
3703        let text = plain(&lines);
3704        assert!(!text.contains("dead files"));
3705        assert!(!text.contains("avg cyclomatic"));
3706    }
3707
3708    #[test]
3709    fn vital_signs_optional_fields_omitted_when_none() {
3710        let root = PathBuf::from("/project");
3711        let mut report = empty_report();
3712        report.vital_signs = Some(fallow_output::VitalSigns {
3713            dead_file_pct: None,
3714            dead_export_pct: None,
3715            avg_cyclomatic: 2.0,
3716            p90_cyclomatic: 5,
3717            duplication_pct: None,
3718            hotspot_count: None,
3719            maintainability_avg: None,
3720            unused_dep_count: None,
3721            circular_dep_count: None,
3722            counts: None,
3723            unit_size_profile: None,
3724            unit_interfacing_profile: None,
3725            p95_fan_in: None,
3726            coupling_high_pct: None,
3727            total_loc: 0,
3728            ..Default::default()
3729        });
3730        let lines = build_health_human_lines(&report, &root);
3731        let text = plain(&lines);
3732        assert!(!text.contains("dead files"));
3733        assert!(!text.contains("dead exports"));
3734        assert!(!text.contains("maintainability "));
3735        assert!(!text.contains("hotspot"));
3736        assert!(text.contains("avg cyclomatic 2.0"));
3737        assert!(text.contains("p90 cyclomatic 5"));
3738    }
3739
3740    #[test]
3741    fn vital_signs_zero_counts_suppressed() {
3742        let root = PathBuf::from("/project");
3743        let mut report = empty_report();
3744        report.vital_signs = Some(fallow_output::VitalSigns {
3745            dead_file_pct: None,
3746            dead_export_pct: None,
3747            avg_cyclomatic: 1.0,
3748            p90_cyclomatic: 2,
3749            duplication_pct: None,
3750            hotspot_count: None,
3751            maintainability_avg: None,
3752            unused_dep_count: Some(0),
3753            circular_dep_count: Some(0),
3754            counts: None,
3755            unit_size_profile: None,
3756            unit_interfacing_profile: None,
3757            p95_fan_in: None,
3758            coupling_high_pct: None,
3759            total_loc: 0,
3760            ..Default::default()
3761        });
3762        let lines = build_health_human_lines(&report, &root);
3763        let text = plain(&lines);
3764        assert!(!text.contains("unused dep"));
3765        assert!(!text.contains("circular dep"));
3766    }
3767
3768    #[test]
3769    fn vital_signs_plural_vs_singular() {
3770        let root = PathBuf::from("/project");
3771        let mut report = empty_report();
3772        report.vital_signs = Some(fallow_output::VitalSigns {
3773            dead_file_pct: None,
3774            dead_export_pct: None,
3775            avg_cyclomatic: 1.0,
3776            p90_cyclomatic: 2,
3777            duplication_pct: None,
3778            hotspot_count: Some(1),
3779            maintainability_avg: None,
3780            unused_dep_count: Some(1),
3781            circular_dep_count: Some(2),
3782            counts: None,
3783            unit_size_profile: None,
3784            unit_interfacing_profile: None,
3785            p95_fan_in: None,
3786            coupling_high_pct: None,
3787            total_loc: 0,
3788            ..Default::default()
3789        });
3790        let lines = build_health_human_lines(&report, &root);
3791        let text = plain(&lines);
3792        assert!(text.contains("1 churn hotspot"));
3793        assert!(!text.contains("1 churn hotspots"));
3794        assert!(text.contains("1 unused dep"));
3795        assert!(!text.contains("1 unused deps"));
3796        assert!(text.contains("2 circular deps"));
3797    }
3798
3799    #[test]
3800    fn file_scores_single_entry() {
3801        let root = PathBuf::from("/project");
3802        let mut report = empty_report();
3803        report.file_scores = vec![fallow_output::FileHealthScore {
3804            path: root.join("src/utils.ts"),
3805            fan_in: 5,
3806            fan_out: 3,
3807            dead_code_ratio: 0.15,
3808            complexity_density: 0.42,
3809            maintainability_index: 85.3,
3810            total_cyclomatic: 12,
3811            total_cognitive: 8,
3812            function_count: 4,
3813            lines: 200,
3814            crap_max: 0.0,
3815            crap_above_threshold: 0,
3816        }];
3817        let lines = build_health_human_lines(&report, &root);
3818        let text = plain(&lines);
3819        assert!(text.contains("File health scores (1 files)"));
3820        assert!(text.contains("85.3"));
3821        assert!(text.contains("src/utils.ts"));
3822        assert!(text.contains("200 LOC"));
3823        assert!(text.contains("5 fan-in"));
3824        assert!(text.contains("3 fan-out"));
3825        assert!(text.contains("15% dead"));
3826        assert!(text.contains("0.42 density"));
3827    }
3828
3829    #[test]
3830    fn file_scores_concern_tag_marks_risk_vs_structure() {
3831        let root = PathBuf::from("/project");
3832        let mut report = empty_report();
3833        report.file_scores = vec![
3834            fallow_output::FileHealthScore {
3835                path: root.join("src/risky.ts"),
3836                fan_in: 0,
3837                fan_out: 0,
3838                dead_code_ratio: 0.0,
3839                complexity_density: 0.2,
3840                maintainability_index: 85.0,
3841                total_cyclomatic: 10,
3842                total_cognitive: 8,
3843                function_count: 1,
3844                lines: 100,
3845                crap_max: 552.0,
3846                crap_above_threshold: 1,
3847            },
3848            fallow_output::FileHealthScore {
3849                path: root.join("src/messy.ts"),
3850                fan_in: 0,
3851                fan_out: 0,
3852                dead_code_ratio: 0.0,
3853                complexity_density: 0.3,
3854                maintainability_index: 30.0,
3855                total_cyclomatic: 5,
3856                total_cognitive: 3,
3857                function_count: 1,
3858                lines: 100,
3859                crap_max: 2.0,
3860                crap_above_threshold: 0,
3861            },
3862        ];
3863        let text = plain(&build_health_human_lines(&report, &root));
3864        let risky_line = text
3865            .lines()
3866            .find(|l| l.contains("risky.ts"))
3867            .expect("risky path line");
3868        assert!(
3869            risky_line.trim_end().ends_with("risk"),
3870            "expected risk tag, got: {risky_line:?}"
3871        );
3872        let messy_line = text
3873            .lines()
3874            .find(|l| l.contains("messy.ts"))
3875            .expect("messy path line");
3876        assert!(
3877            messy_line.trim_end().ends_with("structure"),
3878            "expected structure tag, got: {messy_line:?}"
3879        );
3880    }
3881
3882    #[test]
3883    fn file_scores_mi_color_thresholds() {
3884        let root = PathBuf::from("/project");
3885        let mut report = empty_report();
3886        report.file_scores = vec![
3887            fallow_output::FileHealthScore {
3888                path: root.join("src/good.ts"),
3889                fan_in: 1,
3890                fan_out: 1,
3891                dead_code_ratio: 0.0,
3892                complexity_density: 0.1,
3893                maintainability_index: 90.0, // green: >= 80
3894                total_cyclomatic: 2,
3895                total_cognitive: 1,
3896                function_count: 1,
3897                lines: 50,
3898                crap_max: 0.0,
3899                crap_above_threshold: 0,
3900            },
3901            fallow_output::FileHealthScore {
3902                path: root.join("src/okay.ts"),
3903                fan_in: 2,
3904                fan_out: 3,
3905                dead_code_ratio: 0.1,
3906                complexity_density: 0.3,
3907                maintainability_index: 65.0, // yellow: >= 50
3908                total_cyclomatic: 8,
3909                total_cognitive: 5,
3910                function_count: 3,
3911                lines: 100,
3912                crap_max: 0.0,
3913                crap_above_threshold: 0,
3914            },
3915            fallow_output::FileHealthScore {
3916                path: root.join("src/bad.ts"),
3917                fan_in: 8,
3918                fan_out: 12,
3919                dead_code_ratio: 0.5,
3920                complexity_density: 0.9,
3921                maintainability_index: 30.0, // red: < 50
3922                total_cyclomatic: 40,
3923                total_cognitive: 30,
3924                function_count: 10,
3925                lines: 500,
3926                crap_max: 0.0,
3927                crap_above_threshold: 0,
3928            },
3929        ];
3930        let lines = build_health_human_lines(&report, &root);
3931        let text = plain(&lines);
3932        assert!(text.contains("File health scores (3 files)"));
3933        assert!(text.contains("90.0"));
3934        assert!(text.contains("65.0"));
3935        assert!(text.contains("30.0"));
3936    }
3937
3938    #[test]
3939    fn file_scores_truncation_above_max_flat_items() {
3940        let root = PathBuf::from("/project");
3941        let mut report = empty_report();
3942        for i in 0..12 {
3943            report.file_scores.push(fallow_output::FileHealthScore {
3944                path: root.join(format!("src/file{i}.ts")),
3945                fan_in: 1,
3946                fan_out: 1,
3947                dead_code_ratio: 0.0,
3948                complexity_density: 0.1,
3949                maintainability_index: 80.0,
3950                total_cyclomatic: 2,
3951                total_cognitive: 1,
3952                function_count: 1,
3953                lines: 50,
3954                crap_max: 0.0,
3955                crap_above_threshold: 0,
3956            });
3957        }
3958        let lines = build_health_human_lines(&report, &root);
3959        let text = plain(&lines);
3960        assert!(text.contains("File health scores (12 files)"));
3961        assert!(text.contains("... and 2 more files"));
3962        assert!(text.contains("file0.ts"));
3963        assert!(text.contains("file9.ts"));
3964        assert!(!text.contains("file10.ts"));
3965        assert!(!text.contains("file11.ts"));
3966    }
3967
3968    #[test]
3969    fn file_scores_docs_link() {
3970        let root = PathBuf::from("/project");
3971        let mut report = empty_report();
3972        report.file_scores = vec![fallow_output::FileHealthScore {
3973            path: root.join("src/a.ts"),
3974            fan_in: 1,
3975            fan_out: 1,
3976            dead_code_ratio: 0.0,
3977            complexity_density: 0.1,
3978            maintainability_index: 80.0,
3979            total_cyclomatic: 2,
3980            total_cognitive: 1,
3981            function_count: 1,
3982            lines: 50,
3983            crap_max: 0.0,
3984            crap_above_threshold: 0,
3985        }];
3986        let lines = build_health_human_lines(&report, &root);
3987        let text = plain(&lines);
3988        assert!(text.contains("docs.fallow.tools/explanations/health#file-health-scores"));
3989    }
3990
3991    #[test]
3992    fn hotspots_accelerating_trend() {
3993        let root = PathBuf::from("/project");
3994        let mut report = empty_report();
3995        report.hotspots = vec![
3996            fallow_output::HotspotEntry {
3997                path: root.join("src/core.ts"),
3998                score: 75.0,
3999                commits: 42,
4000                weighted_commits: 30.0,
4001                lines_added: 500,
4002                lines_deleted: 200,
4003                complexity_density: 0.85,
4004                fan_in: 10,
4005                trend: fallow_engine::churn::ChurnTrend::Accelerating,
4006                ownership: None,
4007                is_test_path: false,
4008            }
4009            .into(),
4010        ];
4011        let lines = build_health_human_lines(&report, &root);
4012        let text = plain(&lines);
4013        assert!(text.contains("Hotspots (1 files)"));
4014        assert!(text.contains("75.0"));
4015        assert!(text.contains("src/core.ts"));
4016        assert!(text.contains("42 commits"));
4017        assert!(text.contains("700 churn"));
4018        assert!(text.contains("0.85 density"));
4019        assert!(text.contains("10 fan-in"));
4020        assert!(text.contains("accelerating"));
4021    }
4022
4023    #[test]
4024    fn hotspots_cooling_trend() {
4025        let root = PathBuf::from("/project");
4026        let mut report = empty_report();
4027        report.hotspots = vec![
4028            fallow_output::HotspotEntry {
4029                path: root.join("src/old.ts"),
4030                score: 20.0,
4031                commits: 5,
4032                weighted_commits: 2.0,
4033                lines_added: 50,
4034                lines_deleted: 30,
4035                complexity_density: 0.3,
4036                fan_in: 2,
4037                trend: fallow_engine::churn::ChurnTrend::Cooling,
4038                ownership: None,
4039                is_test_path: false,
4040            }
4041            .into(),
4042        ];
4043        let lines = build_health_human_lines(&report, &root);
4044        let text = plain(&lines);
4045        assert!(text.contains("20.0"));
4046        assert!(text.contains("cooling"));
4047    }
4048
4049    #[test]
4050    fn hotspots_stable_trend() {
4051        let root = PathBuf::from("/project");
4052        let mut report = empty_report();
4053        report.hotspots = vec![
4054            fallow_output::HotspotEntry {
4055                path: root.join("src/mid.ts"),
4056                score: 45.0,
4057                commits: 15,
4058                weighted_commits: 10.0,
4059                lines_added: 200,
4060                lines_deleted: 100,
4061                complexity_density: 0.5,
4062                fan_in: 5,
4063                trend: fallow_engine::churn::ChurnTrend::Stable,
4064                ownership: None,
4065                is_test_path: false,
4066            }
4067            .into(),
4068        ];
4069        let lines = build_health_human_lines(&report, &root);
4070        let text = plain(&lines);
4071        assert!(text.contains("45.0"));
4072        assert!(text.contains("stable"));
4073    }
4074
4075    #[test]
4076    fn hotspots_with_summary_and_since() {
4077        let root = PathBuf::from("/project");
4078        let mut report = empty_report();
4079        report.hotspots = vec![
4080            fallow_output::HotspotEntry {
4081                path: root.join("src/a.ts"),
4082                score: 50.0,
4083                commits: 10,
4084                weighted_commits: 8.0,
4085                lines_added: 100,
4086                lines_deleted: 50,
4087                complexity_density: 0.4,
4088                fan_in: 3,
4089                trend: fallow_engine::churn::ChurnTrend::Stable,
4090                ownership: None,
4091                is_test_path: false,
4092            }
4093            .into(),
4094        ];
4095        report.hotspot_summary = Some(fallow_output::HotspotSummary {
4096            since: "6 months".to_string(),
4097            min_commits: 3,
4098            files_analyzed: 50,
4099            files_excluded: 20,
4100            shallow_clone: false,
4101        });
4102        let lines = build_health_human_lines(&report, &root);
4103        let text = plain(&lines);
4104        assert!(text.contains("Hotspots (1 files, since 6 months)"));
4105        assert!(text.contains("20 files excluded (< 3 commits)"));
4106    }
4107
4108    #[test]
4109    fn hotspots_summary_no_exclusions() {
4110        let root = PathBuf::from("/project");
4111        let mut report = empty_report();
4112        report.hotspots = vec![
4113            fallow_output::HotspotEntry {
4114                path: root.join("src/a.ts"),
4115                score: 50.0,
4116                commits: 10,
4117                weighted_commits: 8.0,
4118                lines_added: 100,
4119                lines_deleted: 50,
4120                complexity_density: 0.4,
4121                fan_in: 3,
4122                trend: fallow_engine::churn::ChurnTrend::Stable,
4123                ownership: None,
4124                is_test_path: false,
4125            }
4126            .into(),
4127        ];
4128        report.hotspot_summary = Some(fallow_output::HotspotSummary {
4129            since: "3 months".to_string(),
4130            min_commits: 2,
4131            files_analyzed: 50,
4132            files_excluded: 0,
4133            shallow_clone: false,
4134        });
4135        let lines = build_health_human_lines(&report, &root);
4136        let text = plain(&lines);
4137        assert!(!text.contains("files excluded"));
4138    }
4139
4140    #[test]
4141    fn hotspots_docs_link() {
4142        let root = PathBuf::from("/project");
4143        let mut report = empty_report();
4144        report.hotspots = vec![
4145            fallow_output::HotspotEntry {
4146                path: root.join("src/a.ts"),
4147                score: 50.0,
4148                commits: 10,
4149                weighted_commits: 8.0,
4150                lines_added: 100,
4151                lines_deleted: 50,
4152                complexity_density: 0.4,
4153                fan_in: 3,
4154                trend: fallow_engine::churn::ChurnTrend::Stable,
4155                ownership: None,
4156                is_test_path: false,
4157            }
4158            .into(),
4159        ];
4160        let lines = build_health_human_lines(&report, &root);
4161        let text = plain(&lines);
4162        assert!(text.contains("docs.fallow.tools/explanations/health#hotspot-metrics"));
4163    }
4164
4165    #[test]
4166    fn refactoring_targets_single_low_effort() {
4167        let root = PathBuf::from("/project");
4168        let mut report = empty_report();
4169        report.targets = vec![
4170            fallow_output::RefactoringTarget {
4171                path: root.join("src/legacy.ts"),
4172                priority: 65.0,
4173                efficiency: 65.0,
4174                recommendation: "Extract complex logic into helper functions".to_string(),
4175                category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
4176                effort: fallow_output::EffortEstimate::Low,
4177                confidence: fallow_output::Confidence::High,
4178                factors: vec![],
4179                evidence: None,
4180            }
4181            .into(),
4182        ];
4183        let lines = build_health_human_lines(&report, &root);
4184        let text = plain(&lines);
4185        assert!(text.contains("Refactoring targets (1)"));
4186        assert!(text.contains("1 low effort"));
4187        assert!(text.contains("65.0"));
4188        assert!(text.contains("pri:65.0"));
4189        assert!(text.contains("src/legacy.ts"));
4190        assert!(text.contains("complexity"));
4191        assert!(text.contains("effort:low"));
4192        assert!(text.contains("confidence:high"));
4193        assert!(text.contains("Extract complex logic into helper functions"));
4194    }
4195
4196    #[test]
4197    fn refactoring_targets_render_non_empty_relation_evidence() {
4198        let root = PathBuf::from("/project");
4199        let mut report = empty_report();
4200        report.targets = vec![
4201            fallow_output::RefactoringTarget {
4202                path: root.join("src/legacy.ts"),
4203                priority: 65.0,
4204                efficiency: 65.0,
4205                recommendation: "Extract complex logic into helper functions".to_string(),
4206                category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
4207                effort: fallow_output::EffortEstimate::Low,
4208                confidence: fallow_output::Confidence::High,
4209                factors: vec![],
4210                evidence: Some(fallow_output::TargetEvidence {
4211                    direct_callers: vec![fallow_output::DirectCallerEvidence {
4212                        path: root.join("src/consumer.ts"),
4213                        symbols: vec![
4214                            fallow_output::DirectCallerSymbolEvidence {
4215                                imported: "loadLegacy".into(),
4216                                local: "load".into(),
4217                                type_only: false,
4218                            },
4219                            fallow_output::DirectCallerSymbolEvidence {
4220                                imported: "side-effect".into(),
4221                                local: String::new(),
4222                                type_only: false,
4223                            },
4224                        ],
4225                    }],
4226                    clone_siblings: vec![fallow_output::CloneSiblingEvidence {
4227                        path: root.join("src/peer.ts"),
4228                        start_line: 12,
4229                        end_line: 20,
4230                        fingerprint: "dup:12345678".into(),
4231                    }],
4232                    ..Default::default()
4233                }),
4234            }
4235            .into(),
4236        ];
4237        let lines = build_health_human_lines(&report, &root);
4238        let text = plain(&lines);
4239        assert!(text.contains("importers: src/consumer.ts (loadLegacy as load, side effect)"));
4240        assert!(!text.contains("side-effect"));
4241        assert!(text.contains("clones: src/peer.ts:12-20 dup:12345678"));
4242    }
4243
4244    #[test]
4245    fn refactoring_targets_mixed_effort() {
4246        let root = PathBuf::from("/project");
4247        let mut report = empty_report();
4248        report.targets = vec![
4249            fallow_output::RefactoringTarget {
4250                path: root.join("src/a.ts"),
4251                priority: 80.0,
4252                efficiency: 80.0,
4253                recommendation: "Remove dead exports".to_string(),
4254                category: fallow_output::RecommendationCategory::RemoveDeadCode,
4255                effort: fallow_output::EffortEstimate::Low,
4256                confidence: fallow_output::Confidence::High,
4257                factors: vec![],
4258                evidence: None,
4259            }
4260            .into(),
4261            fallow_output::RefactoringTarget {
4262                path: root.join("src/b.ts"),
4263                priority: 60.0,
4264                efficiency: 30.0,
4265                recommendation: "Split into smaller modules".to_string(),
4266                category: fallow_output::RecommendationCategory::SplitHighImpact,
4267                effort: fallow_output::EffortEstimate::Medium,
4268                confidence: fallow_output::Confidence::Medium,
4269                factors: vec![],
4270                evidence: None,
4271            }
4272            .into(),
4273            fallow_output::RefactoringTarget {
4274                path: root.join("src/c.ts"),
4275                priority: 50.0,
4276                efficiency: 16.7,
4277                recommendation: "Break circular dependency".to_string(),
4278                category: fallow_output::RecommendationCategory::BreakCircularDependency,
4279                effort: fallow_output::EffortEstimate::High,
4280                confidence: fallow_output::Confidence::Low,
4281                factors: vec![],
4282                evidence: None,
4283            }
4284            .into(),
4285        ];
4286        let lines = build_health_human_lines(&report, &root);
4287        let text = plain(&lines);
4288        assert!(text.contains("Refactoring targets (3)"));
4289        assert!(text.contains("1 low effort"));
4290        assert!(text.contains("1 medium"));
4291        assert!(text.contains("1 high"));
4292        assert!(text.contains("effort:low"));
4293        assert!(text.contains("effort:medium"));
4294        assert!(text.contains("effort:high"));
4295        assert!(text.contains("confidence:high"));
4296        assert!(text.contains("confidence:medium"));
4297        assert!(text.contains("confidence:low"));
4298    }
4299
4300    #[test]
4301    fn refactoring_targets_truncation_above_max_flat_items() {
4302        let root = PathBuf::from("/project");
4303        let mut report = empty_report();
4304        for i in 0..12 {
4305            report.targets.push(
4306                fallow_output::RefactoringTarget {
4307                    path: root.join(format!("src/target{i}.ts")),
4308                    priority: 50.0,
4309                    efficiency: 25.0,
4310                    recommendation: format!("Fix target {i}"),
4311                    category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
4312                    effort: fallow_output::EffortEstimate::Medium,
4313                    confidence: fallow_output::Confidence::Medium,
4314                    factors: vec![],
4315                    evidence: None,
4316                }
4317                .into(),
4318            );
4319        }
4320        let lines = build_health_human_lines(&report, &root);
4321        let text = plain(&lines);
4322        assert!(text.contains("Refactoring targets (12)"));
4323        assert!(text.contains("... and 2 more targets"));
4324        assert!(text.contains("target0.ts"));
4325        assert!(text.contains("target9.ts"));
4326        assert!(!text.contains("target10.ts"));
4327    }
4328
4329    #[test]
4330    fn refactoring_targets_docs_link() {
4331        let root = PathBuf::from("/project");
4332        let mut report = empty_report();
4333        report.targets = vec![
4334            fallow_output::RefactoringTarget {
4335                path: root.join("src/a.ts"),
4336                priority: 50.0,
4337                efficiency: 50.0,
4338                recommendation: "Fix it".to_string(),
4339                category: fallow_output::RecommendationCategory::ExtractDependencies,
4340                effort: fallow_output::EffortEstimate::Low,
4341                confidence: fallow_output::Confidence::High,
4342                factors: vec![],
4343                evidence: None,
4344            }
4345            .into(),
4346        ];
4347        let lines = build_health_human_lines(&report, &root);
4348        let text = plain(&lines);
4349        assert!(text.contains("docs.fallow.tools/explanations/health#refactoring-targets"));
4350    }
4351
4352    #[test]
4353    fn refactoring_targets_all_categories() {
4354        let root = PathBuf::from("/project");
4355        let mut report = empty_report();
4356        let categories = [
4357            (
4358                fallow_output::RecommendationCategory::UrgentChurnComplexity,
4359                "churn+complexity",
4360            ),
4361            (
4362                fallow_output::RecommendationCategory::BreakCircularDependency,
4363                "circular dependency",
4364            ),
4365            (
4366                fallow_output::RecommendationCategory::SplitHighImpact,
4367                "high impact",
4368            ),
4369            (
4370                fallow_output::RecommendationCategory::RemoveDeadCode,
4371                "dead code",
4372            ),
4373            (
4374                fallow_output::RecommendationCategory::ExtractComplexFunctions,
4375                "complexity",
4376            ),
4377            (
4378                fallow_output::RecommendationCategory::ExtractDependencies,
4379                "coupling",
4380            ),
4381            (
4382                fallow_output::RecommendationCategory::AddTestCoverage,
4383                "untested risk",
4384            ),
4385        ];
4386        for (i, (cat, _label)) in categories.iter().enumerate() {
4387            report.targets.push(
4388                fallow_output::RefactoringTarget {
4389                    path: root.join(format!("src/cat{i}.ts")),
4390                    priority: 50.0,
4391                    efficiency: 50.0,
4392                    recommendation: format!("Fix cat{i}"),
4393                    category: cat.clone(),
4394                    effort: fallow_output::EffortEstimate::Low,
4395                    confidence: fallow_output::Confidence::High,
4396                    factors: vec![],
4397                    evidence: None,
4398                }
4399                .into(),
4400            );
4401        }
4402        let lines = build_health_human_lines(&report, &root);
4403        let text = plain(&lines);
4404        for (_cat, label) in &categories {
4405            assert!(
4406                text.contains(label),
4407                "Expected category label '{label}' in output"
4408            );
4409        }
4410    }
4411
4412    #[test]
4413    fn refactoring_targets_efficiency_color_thresholds() {
4414        let root = PathBuf::from("/project");
4415        let mut report = empty_report();
4416        report.targets = vec![
4417            fallow_output::RefactoringTarget {
4418                path: root.join("src/high.ts"),
4419                priority: 50.0,
4420                efficiency: 50.0, // green: >= 40
4421                recommendation: "High eff".to_string(),
4422                category: fallow_output::RecommendationCategory::RemoveDeadCode,
4423                effort: fallow_output::EffortEstimate::Low,
4424                confidence: fallow_output::Confidence::High,
4425                factors: vec![],
4426                evidence: None,
4427            }
4428            .into(),
4429            fallow_output::RefactoringTarget {
4430                path: root.join("src/mid.ts"),
4431                priority: 50.0,
4432                efficiency: 25.0, // yellow: >= 20
4433                recommendation: "Mid eff".to_string(),
4434                category: fallow_output::RecommendationCategory::RemoveDeadCode,
4435                effort: fallow_output::EffortEstimate::Medium,
4436                confidence: fallow_output::Confidence::Medium,
4437                factors: vec![],
4438                evidence: None,
4439            }
4440            .into(),
4441            fallow_output::RefactoringTarget {
4442                path: root.join("src/low.ts"),
4443                priority: 50.0,
4444                efficiency: 10.0, // dimmed: < 20
4445                recommendation: "Low eff".to_string(),
4446                category: fallow_output::RecommendationCategory::RemoveDeadCode,
4447                effort: fallow_output::EffortEstimate::High,
4448                confidence: fallow_output::Confidence::Low,
4449                factors: vec![],
4450                evidence: None,
4451            }
4452            .into(),
4453        ];
4454        let lines = build_health_human_lines(&report, &root);
4455        let text = plain(&lines);
4456        assert!(text.contains("50.0"));
4457        assert!(text.contains("25.0"));
4458        assert!(text.contains("10.0"));
4459    }
4460
4461    #[test]
4462    #[expect(
4463        clippy::too_many_lines,
4464        reason = "test fixture; linear setup/assert, length is not a maintainability concern"
4465    )]
4466    fn all_sections_combined() {
4467        let root = PathBuf::from("/project");
4468        let mut report = empty_report();
4469        report.summary.functions_above_threshold = 1;
4470        report.findings = vec![
4471            fallow_output::ComplexityViolation {
4472                path: root.join("src/complex.ts"),
4473                name: "bigFn".to_string(),
4474                line: 10,
4475                col: 0,
4476                cyclomatic: 25,
4477                cognitive: 20,
4478                line_count: 80,
4479                param_count: 0,
4480                react_hook_count: 0,
4481                react_jsx_max_depth: 0,
4482                react_prop_count: 0,
4483                react_hook_profile: None,
4484                exceeded: fallow_output::ExceededThreshold::Both,
4485                severity: fallow_output::FindingSeverity::Moderate,
4486                crap: None,
4487                coverage_pct: None,
4488                coverage_tier: None,
4489                coverage_source: None,
4490                inherited_from: None,
4491                component_rollup: None,
4492                contributions: Vec::new(),
4493                effective_thresholds: None,
4494                threshold_source: None,
4495            }
4496            .into(),
4497        ];
4498        report.health_score = Some(fallow_output::HealthScore {
4499            formula_version: fallow_output::HEALTH_SCORE_FORMULA_VERSION,
4500            score: 75.0,
4501            grade: "B",
4502            penalties: fallow_output::HealthScorePenalties {
4503                dead_files: Some(5.0),
4504                dead_exports: Some(5.0),
4505                complexity: 5.0,
4506                p90_complexity: 2.0,
4507                maintainability: Some(3.0),
4508                hotspots: Some(2.0),
4509                unused_deps: Some(2.0),
4510                circular_deps: Some(1.0),
4511                unit_size: None,
4512                coupling: None,
4513                duplication: None,
4514                prop_drilling: None,
4515            },
4516        });
4517        report.file_scores = vec![fallow_output::FileHealthScore {
4518            path: root.join("src/complex.ts"),
4519            fan_in: 5,
4520            fan_out: 3,
4521            dead_code_ratio: 0.1,
4522            complexity_density: 0.5,
4523            maintainability_index: 60.0,
4524            total_cyclomatic: 15,
4525            total_cognitive: 10,
4526            function_count: 3,
4527            lines: 200,
4528            crap_max: 0.0,
4529            crap_above_threshold: 0,
4530        }];
4531        report.hotspots = vec![
4532            fallow_output::HotspotEntry {
4533                path: root.join("src/complex.ts"),
4534                score: 65.0,
4535                commits: 20,
4536                weighted_commits: 15.0,
4537                lines_added: 300,
4538                lines_deleted: 100,
4539                complexity_density: 0.5,
4540                fan_in: 5,
4541                trend: fallow_engine::churn::ChurnTrend::Accelerating,
4542                ownership: None,
4543                is_test_path: false,
4544            }
4545            .into(),
4546        ];
4547        report.targets = vec![
4548            fallow_output::RefactoringTarget {
4549                path: root.join("src/complex.ts"),
4550                priority: 70.0,
4551                efficiency: 70.0,
4552                recommendation: "Extract complex functions".to_string(),
4553                category: fallow_output::RecommendationCategory::ExtractComplexFunctions,
4554                effort: fallow_output::EffortEstimate::Low,
4555                confidence: fallow_output::Confidence::High,
4556                factors: vec![],
4557                evidence: None,
4558            }
4559            .into(),
4560        ];
4561        let lines = build_health_human_lines(&report, &root);
4562        let text = plain(&lines);
4563        assert!(text.contains("Health score:"));
4564        assert!(text.contains("High complexity functions"));
4565        assert!(text.contains("File health scores"));
4566        assert!(text.contains("Hotspots"));
4567        assert!(text.contains("Refactoring targets"));
4568    }
4569
4570    #[test]
4571    fn completely_empty_report_produces_no_lines() {
4572        let root = PathBuf::from("/project");
4573        let report = empty_report();
4574        let lines = build_health_human_lines(&report, &root);
4575        assert!(lines.is_empty());
4576    }
4577
4578    #[test]
4579    fn finding_only_cyclomatic_exceeds() {
4580        let root = PathBuf::from("/project");
4581        let mut report = empty_report();
4582        report.summary.functions_above_threshold = 1;
4583        report.findings = vec![
4584            fallow_output::ComplexityViolation {
4585                path: root.join("src/a.ts"),
4586                name: "fn1".to_string(),
4587                line: 1,
4588                col: 0,
4589                cyclomatic: 25, // exceeds 20
4590                cognitive: 10,  // does not exceed 15
4591                line_count: 50,
4592                param_count: 0,
4593                react_hook_count: 0,
4594                react_jsx_max_depth: 0,
4595                react_prop_count: 0,
4596                react_hook_profile: None,
4597                exceeded: fallow_output::ExceededThreshold::Cyclomatic,
4598                severity: fallow_output::FindingSeverity::Moderate,
4599                crap: None,
4600                coverage_pct: None,
4601                coverage_tier: None,
4602                coverage_source: None,
4603                inherited_from: None,
4604                component_rollup: None,
4605                contributions: Vec::new(),
4606                effective_thresholds: None,
4607                threshold_source: None,
4608            }
4609            .into(),
4610        ];
4611        let lines = build_health_human_lines(&report, &root);
4612        let text = plain(&lines);
4613        assert!(text.contains("25 cyclomatic"));
4614        assert!(text.contains("10 cognitive"));
4615    }
4616
4617    #[test]
4618    fn finding_only_cognitive_exceeds() {
4619        let root = PathBuf::from("/project");
4620        let mut report = empty_report();
4621        report.summary.functions_above_threshold = 1;
4622        report.findings = vec![
4623            fallow_output::ComplexityViolation {
4624                path: root.join("src/a.ts"),
4625                name: "fn1".to_string(),
4626                line: 1,
4627                col: 0,
4628                cyclomatic: 10, // does not exceed 20
4629                cognitive: 25,  // exceeds 15
4630                line_count: 50,
4631                param_count: 0,
4632                react_hook_count: 0,
4633                react_jsx_max_depth: 0,
4634                react_prop_count: 0,
4635                react_hook_profile: None,
4636                exceeded: fallow_output::ExceededThreshold::Cognitive,
4637                severity: fallow_output::FindingSeverity::High,
4638                crap: None,
4639                coverage_pct: None,
4640                coverage_tier: None,
4641                coverage_source: None,
4642                inherited_from: None,
4643                component_rollup: None,
4644                contributions: Vec::new(),
4645                effective_thresholds: None,
4646                threshold_source: None,
4647            }
4648            .into(),
4649        ];
4650        let lines = build_health_human_lines(&report, &root);
4651        let text = plain(&lines);
4652        assert!(text.contains("10 cyclomatic"));
4653        assert!(text.contains("25 cognitive"));
4654    }
4655
4656    #[test]
4657    fn findings_across_multiple_files() {
4658        let root = PathBuf::from("/project");
4659        let mut report = empty_report();
4660        report.summary.functions_above_threshold = 2;
4661        report.findings = vec![
4662            fallow_output::ComplexityViolation {
4663                path: root.join("src/a.ts"),
4664                name: "fn1".to_string(),
4665                line: 1,
4666                col: 0,
4667                cyclomatic: 25,
4668                cognitive: 20,
4669                line_count: 50,
4670                param_count: 0,
4671                react_hook_count: 0,
4672                react_jsx_max_depth: 0,
4673                react_prop_count: 0,
4674                react_hook_profile: None,
4675                exceeded: fallow_output::ExceededThreshold::Both,
4676                severity: fallow_output::FindingSeverity::Moderate,
4677                crap: None,
4678                coverage_pct: None,
4679                coverage_tier: None,
4680                coverage_source: None,
4681                inherited_from: None,
4682                component_rollup: None,
4683                contributions: Vec::new(),
4684                effective_thresholds: None,
4685                threshold_source: None,
4686            }
4687            .into(),
4688            fallow_output::ComplexityViolation {
4689                path: root.join("src/b.ts"),
4690                name: "fn2".to_string(),
4691                line: 5,
4692                col: 0,
4693                cyclomatic: 22,
4694                cognitive: 18,
4695                line_count: 40,
4696                param_count: 0,
4697                react_hook_count: 0,
4698                react_jsx_max_depth: 0,
4699                react_prop_count: 0,
4700                react_hook_profile: None,
4701                exceeded: fallow_output::ExceededThreshold::Both,
4702                severity: fallow_output::FindingSeverity::Moderate,
4703                crap: None,
4704                coverage_pct: None,
4705                coverage_tier: None,
4706                coverage_source: None,
4707                inherited_from: None,
4708                component_rollup: None,
4709                contributions: Vec::new(),
4710                effective_thresholds: None,
4711                threshold_source: None,
4712            }
4713            .into(),
4714        ];
4715        let lines = build_health_human_lines(&report, &root);
4716        let text = plain(&lines);
4717        assert!(text.contains("src/a.ts"));
4718        assert!(text.contains("src/b.ts"));
4719    }
4720
4721    #[test]
4722    fn findings_docs_link() {
4723        let root = PathBuf::from("/project");
4724        let mut report = empty_report();
4725        report.summary.functions_above_threshold = 1;
4726        report.findings = vec![
4727            fallow_output::ComplexityViolation {
4728                path: root.join("src/a.ts"),
4729                name: "fn1".to_string(),
4730                line: 1,
4731                col: 0,
4732                cyclomatic: 25,
4733                cognitive: 20,
4734                line_count: 50,
4735                param_count: 0,
4736                react_hook_count: 0,
4737                react_jsx_max_depth: 0,
4738                react_prop_count: 0,
4739                react_hook_profile: None,
4740                exceeded: fallow_output::ExceededThreshold::Both,
4741                severity: fallow_output::FindingSeverity::Moderate,
4742                crap: None,
4743                coverage_pct: None,
4744                coverage_tier: None,
4745                coverage_source: None,
4746                inherited_from: None,
4747                component_rollup: None,
4748                contributions: Vec::new(),
4749                effective_thresholds: None,
4750                threshold_source: None,
4751            }
4752            .into(),
4753        ];
4754        let lines = build_health_human_lines(&report, &root);
4755        let text = plain(&lines);
4756        assert!(text.contains("docs.fallow.tools/explanations/health#complexity-metrics"));
4757    }
4758
4759    #[test]
4760    fn hotspot_score_high_medium_low() {
4761        let root = PathBuf::from("/project");
4762        let mut report = empty_report();
4763        report.hotspots = vec![
4764            fallow_output::HotspotEntry {
4765                path: root.join("src/high.ts"),
4766                score: 80.0, // red: >= 70
4767                commits: 30,
4768                weighted_commits: 25.0,
4769                lines_added: 400,
4770                lines_deleted: 200,
4771                complexity_density: 0.9,
4772                fan_in: 8,
4773                trend: fallow_engine::churn::ChurnTrend::Accelerating,
4774                ownership: None,
4775                is_test_path: false,
4776            }
4777            .into(),
4778            fallow_output::HotspotEntry {
4779                path: root.join("src/medium.ts"),
4780                score: 45.0, // yellow: >= 30
4781                commits: 15,
4782                weighted_commits: 10.0,
4783                lines_added: 200,
4784                lines_deleted: 100,
4785                complexity_density: 0.5,
4786                fan_in: 4,
4787                trend: fallow_engine::churn::ChurnTrend::Stable,
4788                ownership: None,
4789                is_test_path: false,
4790            }
4791            .into(),
4792            fallow_output::HotspotEntry {
4793                path: root.join("src/low.ts"),
4794                score: 15.0, // green: < 30
4795                commits: 5,
4796                weighted_commits: 3.0,
4797                lines_added: 50,
4798                lines_deleted: 20,
4799                complexity_density: 0.2,
4800                fan_in: 1,
4801                trend: fallow_engine::churn::ChurnTrend::Cooling,
4802                ownership: None,
4803                is_test_path: false,
4804            }
4805            .into(),
4806        ];
4807        let lines = build_health_human_lines(&report, &root);
4808        let text = plain(&lines);
4809        assert!(text.contains("80.0"));
4810        assert!(text.contains("45.0"));
4811        assert!(text.contains("15.0"));
4812        assert!(text.contains("Hotspots (3 files)"));
4813    }
4814
4815    #[test]
4816    fn rollup_breakdown_renders_workspace_relative_template_path() {
4817        let root = PathBuf::from("/project");
4818        let template =
4819            root.join("apps/admin/src/app/payments/payment-list/payment-list.component.html");
4820        let finding = fallow_output::ComplexityViolation {
4821            path: root.join("apps/admin/src/app/payments/payment-list/payment-list.component.ts"),
4822            name: "<component>".to_string(),
4823            line: 1,
4824            col: 0,
4825            cyclomatic: 25,
4826            cognitive: 28,
4827            line_count: 0,
4828            param_count: 0,
4829            react_hook_count: 0,
4830            react_jsx_max_depth: 0,
4831            react_prop_count: 0,
4832            react_hook_profile: None,
4833            exceeded: fallow_output::ExceededThreshold::Both,
4834            severity: fallow_output::FindingSeverity::High,
4835            crap: None,
4836            coverage_pct: None,
4837            coverage_tier: None,
4838            coverage_source: None,
4839            inherited_from: None,
4840            component_rollup: Some(fallow_output::ComponentRollup {
4841                component: "PaymentListComponent".to_string(),
4842                class_worst_function: "ngOnInit".to_string(),
4843                class_cyclomatic: 12,
4844                class_cognitive: 16,
4845                template_path: template,
4846                template_cyclomatic: 13,
4847                template_cognitive: 12,
4848            }),
4849            contributions: Vec::new(),
4850            effective_thresholds: None,
4851            threshold_source: None,
4852        };
4853        let line = render_component_rollup_breakdown(&finding, &root)
4854            .expect("rollup payload should render a breakdown line");
4855        assert!(
4856            line.contains("apps/admin/src/app/payments/payment-list/payment-list.component.html"),
4857            "breakdown must include workspace-relative template path: {line}"
4858        );
4859        assert!(
4860            !line.contains(" payment-list.component.html"),
4861            "bare basename token must not be the rendered template: {line}"
4862        );
4863    }
4864
4865    #[test]
4866    fn inherited_from_renders_workspace_relative_owner_path() {
4867        let root = PathBuf::from("/project");
4868        let owner = root.join("apps/admin/src/app/auth/permissions/permissions.component.ts");
4869        let template_path =
4870            root.join("apps/admin/src/app/auth/permissions/permissions.component.html");
4871        let report = fallow_output::HealthReport {
4872            findings: vec![
4873                fallow_output::ComplexityViolation {
4874                    path: template_path,
4875                    name: "<template>".to_string(),
4876                    line: 1,
4877                    col: 0,
4878                    cyclomatic: 12,
4879                    cognitive: 14,
4880                    line_count: 0,
4881                    param_count: 0,
4882                    react_hook_count: 0,
4883                    react_jsx_max_depth: 0,
4884                    react_prop_count: 0,
4885                    react_hook_profile: None,
4886                    exceeded: fallow_output::ExceededThreshold::Both,
4887                    severity: fallow_output::FindingSeverity::High,
4888                    crap: Some(45.0),
4889                    coverage_pct: None,
4890                    coverage_tier: Some(fallow_output::CoverageTier::Partial),
4891                    coverage_source: Some(
4892                        fallow_output::CoverageSource::EstimatedComponentInherited,
4893                    ),
4894                    inherited_from: Some(owner),
4895                    component_rollup: None,
4896                    contributions: Vec::new(),
4897                    effective_thresholds: None,
4898                    threshold_source: None,
4899                }
4900                .into(),
4901            ],
4902            summary: fallow_output::HealthSummary {
4903                files_analyzed: 1,
4904                functions_analyzed: 1,
4905                functions_above_threshold: 1,
4906                ..Default::default()
4907            },
4908            ..Default::default()
4909        };
4910        let lines = build_health_human_lines(&report, &root);
4911        let text = plain(&lines);
4912        assert!(
4913            text.contains(
4914                "(inherited from apps/admin/src/app/auth/permissions/permissions.component.ts)"
4915            ),
4916            "inherited-from suffix must use workspace-relative path: {text}"
4917        );
4918        assert!(
4919            !text.contains("(inherited from permissions.component.ts)"),
4920            "bare basename suffix must not be rendered: {text}"
4921        );
4922    }
4923
4924    fn react_finding(
4925        react_hook_count: u16,
4926        react_prop_count: u16,
4927        react_jsx_max_depth: u16,
4928        profile: Option<fallow_output::ReactHookProfile>,
4929    ) -> fallow_output::ComplexityViolation {
4930        fallow_output::ComplexityViolation {
4931            path: PathBuf::from("src/Dashboard.tsx"),
4932            name: "Dashboard".to_string(),
4933            line: 1,
4934            col: 0,
4935            cyclomatic: 40,
4936            cognitive: 30,
4937            line_count: 90,
4938            param_count: 1,
4939            react_hook_count,
4940            react_jsx_max_depth,
4941            react_prop_count,
4942            react_hook_profile: profile,
4943            exceeded: fallow_output::ExceededThreshold::Both,
4944            severity: fallow_output::FindingSeverity::High,
4945            crap: None,
4946            coverage_pct: None,
4947            coverage_tier: None,
4948            coverage_source: None,
4949            inherited_from: None,
4950            component_rollup: None,
4951            contributions: Vec::new(),
4952            effective_thresholds: None,
4953            threshold_source: None,
4954        }
4955    }
4956
4957    #[test]
4958    fn react_context_renders_hook_breakdown_and_max_effect_deps() {
4959        let finding = react_finding(
4960            9,
4961            14,
4962            7,
4963            Some(fallow_output::ReactHookProfile {
4964                state: 3,
4965                effect: 4,
4966                memo: 2,
4967                callback: 0,
4968                custom: 0,
4969                max_effect_dep_arity: Some(5),
4970            }),
4971        );
4972        let line = render_react_context(&finding).expect("react context line");
4973        let plain_line = strip_ansi(&line);
4974        assert!(
4975            plain_line
4976                .contains("react: 14 props, 9 hooks (3 state, 4 effect, 2 memo), max effect deps 5, JSX depth 7"),
4977            "breakdown line: {plain_line}"
4978        );
4979    }
4980
4981    #[test]
4982    fn react_context_without_profile_keeps_bare_hook_count() {
4983        let finding = react_finding(5, 0, 0, None);
4984        let line = render_react_context(&finding).expect("react context line");
4985        let plain_line = strip_ansi(&line);
4986        assert!(plain_line.contains("react: 5 hooks"), "{plain_line}");
4987        assert!(
4988            !plain_line.contains('('),
4989            "no breakdown parenthetical without a profile: {plain_line}"
4990        );
4991        assert!(
4992            !plain_line.contains("max effect deps"),
4993            "no effect-deps segment without a profile: {plain_line}"
4994        );
4995    }
4996
4997    #[test]
4998    fn react_context_omits_max_effect_deps_when_arity_absent() {
4999        let finding = react_finding(
5000            2,
5001            0,
5002            0,
5003            Some(fallow_output::ReactHookProfile {
5004                state: 1,
5005                effect: 1,
5006                memo: 0,
5007                callback: 0,
5008                custom: 0,
5009                max_effect_dep_arity: None,
5010            }),
5011        );
5012        let line = render_react_context(&finding).expect("react context line");
5013        let plain_line = strip_ansi(&line);
5014        assert!(
5015            plain_line.contains("2 hooks (1 state, 1 effect)"),
5016            "{plain_line}"
5017        );
5018        assert!(
5019            !plain_line.contains("max effect deps"),
5020            "absent arity must omit the effect-deps segment: {plain_line}"
5021        );
5022    }
5023
5024    #[test]
5025    fn react_context_none_for_non_react_finding() {
5026        let finding = react_finding(0, 0, 0, None);
5027        assert!(render_react_context(&finding).is_none());
5028    }
5029}