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