Skip to main content

fallow_cli/report/
markdown.rs

1use std::fmt::Write;
2use std::path::Path;
3
4use fallow_core::duplicates::DuplicationReport;
5use fallow_core::results::{AnalysisResults, PrivateTypeLeak, UnusedExport, UnusedMember};
6
7use super::grouping::ResultGroup;
8use super::{normalize_uri, plural, relative_path};
9
10/// Escape backticks in user-controlled strings to prevent breaking markdown code spans.
11fn escape_backticks(s: &str) -> String {
12    s.replace('`', "\\`")
13}
14
15pub(super) fn print_markdown(results: &AnalysisResults, root: &Path) {
16    println!("{}", build_markdown(results, root));
17}
18
19/// Build markdown output for analysis results.
20#[expect(
21    clippy::too_many_lines,
22    reason = "one section per issue type; splitting would fragment the output builder"
23)]
24pub fn build_markdown(results: &AnalysisResults, root: &Path) -> String {
25    let rel = |p: &Path| {
26        escape_backticks(&normalize_uri(
27            &relative_path(p, root).display().to_string(),
28        ))
29    };
30
31    let total = results.total_issues();
32    let mut out = String::new();
33
34    if total == 0 {
35        out.push_str("## Fallow: no issues found\n");
36        return out;
37    }
38
39    let _ = write!(out, "## Fallow: {total} issue{} found\n\n", plural(total));
40
41    // ── Unused files ──
42    markdown_section(&mut out, &results.unused_files, "Unused files", |file| {
43        vec![format!("- `{}`", rel(&file.path))]
44    });
45
46    // ── Unused exports ──
47    markdown_grouped_section(
48        &mut out,
49        &results.unused_exports,
50        "Unused exports",
51        root,
52        |e| e.path.as_path(),
53        format_export,
54    );
55
56    // ── Unused types ──
57    markdown_grouped_section(
58        &mut out,
59        &results.unused_types,
60        "Unused type exports",
61        root,
62        |e| e.path.as_path(),
63        format_export,
64    );
65
66    markdown_grouped_section(
67        &mut out,
68        &results.private_type_leaks,
69        "Private type leaks",
70        root,
71        |e| e.path.as_path(),
72        format_private_type_leak,
73    );
74
75    // ── Unused dependencies ──
76    markdown_section(
77        &mut out,
78        &results.unused_dependencies,
79        "Unused dependencies",
80        |dep| format_dependency(&dep.package_name, &dep.path, &dep.used_in_workspaces, root),
81    );
82
83    // ── Unused devDependencies ──
84    markdown_section(
85        &mut out,
86        &results.unused_dev_dependencies,
87        "Unused devDependencies",
88        |dep| format_dependency(&dep.package_name, &dep.path, &dep.used_in_workspaces, root),
89    );
90
91    // ── Unused optionalDependencies ──
92    markdown_section(
93        &mut out,
94        &results.unused_optional_dependencies,
95        "Unused optionalDependencies",
96        |dep| format_dependency(&dep.package_name, &dep.path, &dep.used_in_workspaces, root),
97    );
98
99    // ── Unused enum members ──
100    markdown_grouped_section(
101        &mut out,
102        &results.unused_enum_members,
103        "Unused enum members",
104        root,
105        |m| m.path.as_path(),
106        format_member,
107    );
108
109    // ── Unused class members ──
110    markdown_grouped_section(
111        &mut out,
112        &results.unused_class_members,
113        "Unused class members",
114        root,
115        |m| m.path.as_path(),
116        format_member,
117    );
118
119    // ── Unresolved imports ──
120    markdown_grouped_section(
121        &mut out,
122        &results.unresolved_imports,
123        "Unresolved imports",
124        root,
125        |i| i.path.as_path(),
126        |i| format!(":{} `{}`", i.line, escape_backticks(&i.specifier)),
127    );
128
129    // ── Unlisted dependencies ──
130    markdown_section(
131        &mut out,
132        &results.unlisted_dependencies,
133        "Unlisted dependencies",
134        |dep| vec![format!("- `{}`", escape_backticks(&dep.package_name))],
135    );
136
137    // ── Duplicate exports ──
138    markdown_section(
139        &mut out,
140        &results.duplicate_exports,
141        "Duplicate exports",
142        |dup| {
143            let locations: Vec<String> = dup
144                .locations
145                .iter()
146                .map(|loc| format!("`{}`", rel(&loc.path)))
147                .collect();
148            vec![format!(
149                "- `{}` in {}",
150                escape_backticks(&dup.export_name),
151                locations.join(", ")
152            )]
153        },
154    );
155
156    // ── Type-only dependencies ──
157    markdown_section(
158        &mut out,
159        &results.type_only_dependencies,
160        "Type-only dependencies (consider moving to devDependencies)",
161        |dep| format_dependency(&dep.package_name, &dep.path, &[], root),
162    );
163
164    // ── Test-only dependencies ──
165    markdown_section(
166        &mut out,
167        &results.test_only_dependencies,
168        "Test-only production dependencies (consider moving to devDependencies)",
169        |dep| format_dependency(&dep.package_name, &dep.path, &[], root),
170    );
171
172    // ── Circular dependencies ──
173    markdown_section(
174        &mut out,
175        &results.circular_dependencies,
176        "Circular dependencies",
177        |cycle| {
178            let chain: Vec<String> = cycle.files.iter().map(|p| rel(p)).collect();
179            let mut display_chain = chain.clone();
180            if let Some(first) = chain.first() {
181                display_chain.push(first.clone());
182            }
183            let cross_pkg_tag = if cycle.is_cross_package {
184                " *(cross-package)*"
185            } else {
186                ""
187            };
188            vec![format!(
189                "- {}{}",
190                display_chain
191                    .iter()
192                    .map(|s| format!("`{s}`"))
193                    .collect::<Vec<_>>()
194                    .join(" \u{2192} "),
195                cross_pkg_tag
196            )]
197        },
198    );
199
200    // ── Boundary violations ──
201    markdown_section(
202        &mut out,
203        &results.boundary_violations,
204        "Boundary violations",
205        |v| {
206            vec![format!(
207                "- `{}`:{}  \u{2192} `{}` ({} \u{2192} {})",
208                rel(&v.from_path),
209                v.line,
210                rel(&v.to_path),
211                v.from_zone,
212                v.to_zone,
213            )]
214        },
215    );
216
217    // ── Stale suppressions ──
218    markdown_section(
219        &mut out,
220        &results.stale_suppressions,
221        "Stale suppressions",
222        |s| {
223            vec![format!(
224                "- `{}`:{} `{}` ({})",
225                rel(&s.path),
226                s.line,
227                escape_backticks(&s.description()),
228                escape_backticks(&s.explanation()),
229            )]
230        },
231    );
232    markdown_section(
233        &mut out,
234        &results.unused_catalog_entries,
235        "Unused catalog entries",
236        |entry| {
237            let mut row = format!(
238                "- `{}` (`{}`) `{}`:{}",
239                escape_backticks(&entry.entry_name),
240                escape_backticks(&entry.catalog_name),
241                rel(&entry.path),
242                entry.line,
243            );
244            if !entry.hardcoded_consumers.is_empty() {
245                use std::fmt::Write as _;
246                let consumers = entry
247                    .hardcoded_consumers
248                    .iter()
249                    .map(|p| format!("`{}`", rel(p)))
250                    .collect::<Vec<_>>()
251                    .join(", ");
252                let _ = write!(row, " (hardcoded in {consumers})");
253            }
254            vec![row]
255        },
256    );
257    markdown_section(
258        &mut out,
259        &results.unresolved_catalog_references,
260        "Unresolved catalog references",
261        |finding| {
262            let mut row = format!(
263                "- `{}` (`{}`) `{}`:{}",
264                escape_backticks(&finding.entry_name),
265                escape_backticks(&finding.catalog_name),
266                rel(&finding.path),
267                finding.line,
268            );
269            if !finding.available_in_catalogs.is_empty() {
270                use std::fmt::Write as _;
271                let alts = finding
272                    .available_in_catalogs
273                    .iter()
274                    .map(|c| format!("`{}`", escape_backticks(c)))
275                    .collect::<Vec<_>>()
276                    .join(", ");
277                let _ = write!(row, " (available in: {alts})");
278            }
279            vec![row]
280        },
281    );
282    markdown_section(
283        &mut out,
284        &results.unused_dependency_overrides,
285        "Unused dependency overrides",
286        |finding| {
287            use std::fmt::Write as _;
288            let mut row = format!(
289                "- `{}` -> `{}` (`{}`) `{}`:{}",
290                escape_backticks(&finding.raw_key),
291                escape_backticks(&finding.version_range),
292                finding.source.as_label(),
293                rel(&finding.path),
294                finding.line,
295            );
296            if let Some(hint) = &finding.hint {
297                let _ = write!(row, " (hint: {})", escape_backticks(hint));
298            }
299            vec![row]
300        },
301    );
302    markdown_section(
303        &mut out,
304        &results.misconfigured_dependency_overrides,
305        "Misconfigured dependency overrides",
306        |finding| {
307            vec![format!(
308                "- `{}` -> `{}` (`{}`) `{}`:{} ({})",
309                escape_backticks(&finding.raw_key),
310                escape_backticks(&finding.raw_value),
311                finding.source.as_label(),
312                rel(&finding.path),
313                finding.line,
314                finding.reason.describe(),
315            )]
316        },
317    );
318
319    out
320}
321
322/// Print grouped markdown output: each group gets an `## owner (N issues)` heading.
323pub(super) fn print_grouped_markdown(groups: &[ResultGroup], root: &Path) {
324    let total: usize = groups.iter().map(|g| g.results.total_issues()).sum();
325
326    if total == 0 {
327        println!("## Fallow: no issues found");
328        return;
329    }
330
331    println!(
332        "## Fallow: {total} issue{} found (grouped)\n",
333        plural(total)
334    );
335
336    for group in groups {
337        let count = group.results.total_issues();
338        if count == 0 {
339            continue;
340        }
341        println!(
342            "## {} ({count} issue{})\n",
343            escape_backticks(&group.key),
344            plural(count)
345        );
346        // Section-mode: surface the section's default owners under the heading
347        // so PR comment dashboards can see who approves without re-opening
348        // CODEOWNERS. `owners` is `None` outside of `--group-by section` and
349        // empty for the `(no section)` / `(unowned)` buckets.
350        if let Some(ref owners) = group.owners
351            && !owners.is_empty()
352        {
353            let joined = owners
354                .iter()
355                .map(|o| escape_backticks(o))
356                .collect::<Vec<_>>()
357                .join(" ");
358            println!("Owners: {joined}\n");
359        }
360        // build_markdown already emits its own `## Fallow: N issues found` header;
361        // we re-use the section-level rendering by extracting just the section body.
362        let body = build_markdown(&group.results, root);
363        // Skip the first `## Fallow: ...` line from build_markdown and print the rest.
364        let sections = body
365            .strip_prefix("## Fallow: no issues found\n")
366            .or_else(|| body.find("\n\n").map(|pos| &body[pos + 2..]))
367            .unwrap_or(&body);
368        print!("{sections}");
369    }
370}
371
372fn format_export(e: &UnusedExport) -> String {
373    let re = if e.is_re_export { " (re-export)" } else { "" };
374    format!(":{} `{}`{re}", e.line, escape_backticks(&e.export_name))
375}
376
377fn format_private_type_leak(e: &PrivateTypeLeak) -> String {
378    format!(
379        ":{} `{}` references private type `{}`",
380        e.line,
381        escape_backticks(&e.export_name),
382        escape_backticks(&e.type_name)
383    )
384}
385
386fn format_member(m: &UnusedMember) -> String {
387    format!(
388        ":{} `{}.{}`",
389        m.line,
390        escape_backticks(&m.parent_name),
391        escape_backticks(&m.member_name)
392    )
393}
394
395fn format_dependency(
396    dep_name: &str,
397    pkg_path: &Path,
398    used_in_workspaces: &[std::path::PathBuf],
399    root: &Path,
400) -> Vec<String> {
401    let name = escape_backticks(dep_name);
402    let pkg_label = relative_path(pkg_path, root).display().to_string();
403    let workspace_context = if used_in_workspaces.is_empty() {
404        String::new()
405    } else {
406        let workspaces = used_in_workspaces
407            .iter()
408            .map(|path| escape_backticks(&relative_path(path, root).display().to_string()))
409            .collect::<Vec<_>>()
410            .join(", ");
411        format!("; imported in {workspaces}")
412    };
413    if pkg_label == "package.json" && workspace_context.is_empty() {
414        vec![format!("- `{name}`")]
415    } else {
416        let label = if pkg_label == "package.json" {
417            workspace_context.trim_start_matches("; ").to_string()
418        } else {
419            format!("{}{workspace_context}", escape_backticks(&pkg_label))
420        };
421        vec![format!("- `{name}` ({label})")]
422    }
423}
424
425/// Emit a markdown section with a header and per-item lines. Skipped if empty.
426fn markdown_section<T>(
427    out: &mut String,
428    items: &[T],
429    title: &str,
430    format_lines: impl Fn(&T) -> Vec<String>,
431) {
432    if items.is_empty() {
433        return;
434    }
435    let _ = write!(out, "### {title} ({})\n\n", items.len());
436    for item in items {
437        for line in format_lines(item) {
438            out.push_str(&line);
439            out.push('\n');
440        }
441    }
442    out.push('\n');
443}
444
445/// Emit a markdown section whose items are grouped by file path.
446fn markdown_grouped_section<'a, T>(
447    out: &mut String,
448    items: &'a [T],
449    title: &str,
450    root: &Path,
451    get_path: impl Fn(&'a T) -> &'a Path,
452    format_detail: impl Fn(&T) -> String,
453) {
454    if items.is_empty() {
455        return;
456    }
457    let _ = write!(out, "### {title} ({})\n\n", items.len());
458
459    let mut indices: Vec<usize> = (0..items.len()).collect();
460    indices.sort_by(|&a, &b| get_path(&items[a]).cmp(get_path(&items[b])));
461
462    let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
463    let mut last_file = String::new();
464    for &i in &indices {
465        let item = &items[i];
466        let file_str = rel(get_path(item));
467        if file_str != last_file {
468            let _ = writeln!(out, "- `{file_str}`");
469            last_file = file_str;
470        }
471        let _ = writeln!(out, "  - {}", format_detail(item));
472    }
473    out.push('\n');
474}
475
476// ── Duplication markdown output ──────────────────────────────────
477
478pub(super) fn print_duplication_markdown(report: &DuplicationReport, root: &Path) {
479    println!("{}", build_duplication_markdown(report, root));
480}
481
482/// Build markdown output for duplication results.
483#[must_use]
484pub fn build_duplication_markdown(report: &DuplicationReport, root: &Path) -> String {
485    let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
486
487    let mut out = String::new();
488
489    if report.clone_groups.is_empty() {
490        out.push_str("## Fallow: no code duplication found\n");
491        return out;
492    }
493
494    let stats = &report.stats;
495    let _ = write!(
496        out,
497        "## Fallow: {} clone group{} found ({:.1}% duplication)\n\n",
498        stats.clone_groups,
499        plural(stats.clone_groups),
500        stats.duplication_percentage,
501    );
502
503    out.push_str("### Duplicates\n\n");
504    for (i, group) in report.clone_groups.iter().enumerate() {
505        let instance_count = group.instances.len();
506        let _ = write!(
507            out,
508            "**Clone group {}** ({} lines, {instance_count} instance{})\n\n",
509            i + 1,
510            group.line_count,
511            plural(instance_count)
512        );
513        for instance in &group.instances {
514            let relative = rel(&instance.file);
515            let _ = writeln!(
516                out,
517                "- `{relative}:{}-{}`",
518                instance.start_line, instance.end_line
519            );
520        }
521        out.push('\n');
522    }
523
524    // Clone families
525    if !report.clone_families.is_empty() {
526        out.push_str("### Clone Families\n\n");
527        for (i, family) in report.clone_families.iter().enumerate() {
528            let file_names: Vec<_> = family.files.iter().map(|f| rel(f)).collect();
529            let _ = write!(
530                out,
531                "**Family {}** ({} group{}, {} lines across {})\n\n",
532                i + 1,
533                family.groups.len(),
534                plural(family.groups.len()),
535                family.total_duplicated_lines,
536                file_names
537                    .iter()
538                    .map(|s| format!("`{s}`"))
539                    .collect::<Vec<_>>()
540                    .join(", "),
541            );
542            for suggestion in &family.suggestions {
543                let savings = if suggestion.estimated_savings > 0 {
544                    format!(" (~{} lines saved)", suggestion.estimated_savings)
545                } else {
546                    String::new()
547                };
548                let _ = writeln!(out, "- {}{savings}", suggestion.description);
549            }
550            out.push('\n');
551        }
552    }
553
554    // Summary line
555    let _ = writeln!(
556        out,
557        "**Summary:** {} duplicated lines ({:.1}%) across {} file{}",
558        stats.duplicated_lines,
559        stats.duplication_percentage,
560        stats.files_with_clones,
561        plural(stats.files_with_clones),
562    );
563
564    out
565}
566
567// ── Health markdown output ──────────────────────────────────────────
568
569pub(super) fn print_health_markdown(report: &crate::health_types::HealthReport, root: &Path) {
570    println!("{}", build_health_markdown(report, root));
571}
572
573/// Build markdown output for health (complexity) results.
574#[must_use]
575pub fn build_health_markdown(report: &crate::health_types::HealthReport, root: &Path) -> String {
576    let mut out = String::new();
577
578    if let Some(ref hs) = report.health_score {
579        let _ = writeln!(out, "## Health Score: {:.0} ({})\n", hs.score, hs.grade);
580    }
581
582    write_trend_section(&mut out, report);
583    write_vital_signs_section(&mut out, report);
584
585    if report.findings.is_empty()
586        && report.file_scores.is_empty()
587        && report.coverage_gaps.is_none()
588        && report.hotspots.is_empty()
589        && report.targets.is_empty()
590        && report.runtime_coverage.is_none()
591    {
592        if report.vital_signs.is_none() {
593            let _ = write!(
594                out,
595                "## Fallow: no functions exceed complexity thresholds\n\n\
596                 **{}** functions analyzed (max cyclomatic: {}, max cognitive: {}, max CRAP: {:.1})\n",
597                report.summary.functions_analyzed,
598                report.summary.max_cyclomatic_threshold,
599                report.summary.max_cognitive_threshold,
600                report.summary.max_crap_threshold,
601            );
602        }
603        return out;
604    }
605
606    write_findings_section(&mut out, report, root);
607    write_runtime_coverage_section(&mut out, report, root);
608    write_coverage_gaps_section(&mut out, report, root);
609    write_file_scores_section(&mut out, report, root);
610    write_hotspots_section(&mut out, report, root);
611    write_targets_section(&mut out, report, root);
612    write_metric_legend(&mut out, report);
613
614    out
615}
616
617fn write_runtime_coverage_section(
618    out: &mut String,
619    report: &crate::health_types::HealthReport,
620    root: &Path,
621) {
622    let Some(ref production) = report.runtime_coverage else {
623        return;
624    };
625    // Prepend a blank line so the heading is not concatenated to the previous
626    // section (GFM requires a blank line before headings to avoid the heading
627    // being parsed as a paragraph continuation).
628    if !out.is_empty() && !out.ends_with("\n\n") {
629        out.push('\n');
630    }
631    let _ = writeln!(
632        out,
633        "## Runtime Coverage\n\n- Verdict: {}\n- Functions tracked: {}\n- Hit: {}\n- Unhit: {}\n- Untracked: {}\n- Coverage: {:.1}%\n- Traces observed: {}\n- Period: {} day(s), {} deployment(s)\n",
634        production.verdict,
635        production.summary.functions_tracked,
636        production.summary.functions_hit,
637        production.summary.functions_unhit,
638        production.summary.functions_untracked,
639        production.summary.coverage_percent,
640        production.summary.trace_count,
641        production.summary.period_days,
642        production.summary.deployments_seen,
643    );
644    if let Some(watermark) = production.watermark {
645        let _ = writeln!(out, "- Watermark: {watermark}\n");
646    }
647    if let Some(ref quality) = production.summary.capture_quality
648        && quality.lazy_parse_warning
649    {
650        let window = super::human::health::format_window(quality.window_seconds);
651        let _ = writeln!(
652            out,
653            "- Capture quality: short window ({} from {} instance(s), {:.1}% of functions untracked); lazy-parsed scripts may not appear.\n",
654            window, quality.instances_observed, quality.untracked_ratio_percent,
655        );
656    }
657    let rel = |p: &Path| {
658        escape_backticks(&normalize_uri(
659            &relative_path(p, root).display().to_string(),
660        ))
661    };
662    if !production.findings.is_empty() {
663        out.push_str("| ID | Path | Function | Verdict | Invocations | Confidence |\n");
664        out.push_str("|:---|:-----|:---------|:--------|------------:|:-----------|\n");
665        for finding in &production.findings {
666            let invocations = finding
667                .invocations
668                .map_or_else(|| "-".to_owned(), |hits| hits.to_string());
669            let _ = writeln!(
670                out,
671                "| `{}` | `{}`:{} | `{}` | {} | {} | {} |",
672                escape_backticks(&finding.id),
673                rel(&finding.path),
674                finding.line,
675                escape_backticks(&finding.function),
676                finding.verdict,
677                invocations,
678                finding.confidence,
679            );
680        }
681        out.push('\n');
682    }
683    if !production.hot_paths.is_empty() {
684        out.push_str("| ID | Hot path | Function | Invocations | Percentile |\n");
685        out.push_str("|:---|:---------|:---------|------------:|-----------:|\n");
686        for entry in &production.hot_paths {
687            let _ = writeln!(
688                out,
689                "| `{}` | `{}`:{} | `{}` | {} | {} |",
690                escape_backticks(&entry.id),
691                rel(&entry.path),
692                entry.line,
693                escape_backticks(&entry.function),
694                entry.invocations,
695                entry.percentile,
696            );
697        }
698        out.push('\n');
699    }
700}
701
702/// Write the trend comparison table to the output.
703fn write_trend_section(out: &mut String, report: &crate::health_types::HealthReport) {
704    let Some(ref trend) = report.health_trend else {
705        return;
706    };
707    let sha_str = trend
708        .compared_to
709        .git_sha
710        .as_deref()
711        .map_or(String::new(), |sha| format!(" ({sha})"));
712    let _ = writeln!(
713        out,
714        "## Trend (vs {}{})\n",
715        trend
716            .compared_to
717            .timestamp
718            .get(..10)
719            .unwrap_or(&trend.compared_to.timestamp),
720        sha_str,
721    );
722    out.push_str("| Metric | Previous | Current | Delta | Direction |\n");
723    out.push_str("|:-------|:---------|:--------|:------|:----------|\n");
724    for m in &trend.metrics {
725        let fmt_val = |v: f64| -> String {
726            if m.unit == "%" {
727                format!("{v:.1}%")
728            } else if (v - v.round()).abs() < 0.05 {
729                format!("{v:.0}")
730            } else {
731                format!("{v:.1}")
732            }
733        };
734        let prev = fmt_val(m.previous);
735        let cur = fmt_val(m.current);
736        let delta = if m.unit == "%" {
737            format!("{:+.1}%", m.delta)
738        } else if (m.delta - m.delta.round()).abs() < 0.05 {
739            format!("{:+.0}", m.delta)
740        } else {
741            format!("{:+.1}", m.delta)
742        };
743        let _ = writeln!(
744            out,
745            "| {} | {} | {} | {} | {} {} |",
746            m.label,
747            prev,
748            cur,
749            delta,
750            m.direction.arrow(),
751            m.direction.label(),
752        );
753    }
754    let md_sha = trend
755        .compared_to
756        .git_sha
757        .as_deref()
758        .map_or(String::new(), |sha| format!(" ({sha})"));
759    let _ = writeln!(
760        out,
761        "\n*vs {}{} · {} {} available*\n",
762        trend
763            .compared_to
764            .timestamp
765            .get(..10)
766            .unwrap_or(&trend.compared_to.timestamp),
767        md_sha,
768        trend.snapshots_loaded,
769        if trend.snapshots_loaded == 1 {
770            "snapshot"
771        } else {
772            "snapshots"
773        },
774    );
775}
776
777/// Write the vital signs summary table to the output.
778fn write_vital_signs_section(out: &mut String, report: &crate::health_types::HealthReport) {
779    let Some(ref vs) = report.vital_signs else {
780        return;
781    };
782    out.push_str("## Vital Signs\n\n");
783    out.push_str("| Metric | Value |\n");
784    out.push_str("|:-------|------:|\n");
785    if vs.total_loc > 0 {
786        let _ = writeln!(out, "| Total LOC | {} |", vs.total_loc);
787    }
788    let _ = writeln!(out, "| Avg Cyclomatic | {:.1} |", vs.avg_cyclomatic);
789    let _ = writeln!(out, "| P90 Cyclomatic | {} |", vs.p90_cyclomatic);
790    if let Some(v) = vs.dead_file_pct {
791        let _ = writeln!(out, "| Dead Files | {v:.1}% |");
792    }
793    if let Some(v) = vs.dead_export_pct {
794        let _ = writeln!(out, "| Dead Exports | {v:.1}% |");
795    }
796    if let Some(v) = vs.maintainability_avg {
797        let _ = writeln!(out, "| Maintainability (avg) | {v:.1} |");
798    }
799    if let Some(v) = vs.hotspot_count {
800        let _ = writeln!(out, "| Hotspots | {v} |");
801    }
802    if let Some(v) = vs.circular_dep_count {
803        let _ = writeln!(out, "| Circular Deps | {v} |");
804    }
805    if let Some(v) = vs.unused_dep_count {
806        let _ = writeln!(out, "| Unused Deps | {v} |");
807    }
808    out.push('\n');
809}
810
811/// Write the complexity findings table to the output.
812fn write_findings_section(
813    out: &mut String,
814    report: &crate::health_types::HealthReport,
815    root: &Path,
816) {
817    if report.findings.is_empty() {
818        return;
819    }
820
821    let rel = |p: &Path| {
822        escape_backticks(&normalize_uri(
823            &relative_path(p, root).display().to_string(),
824        ))
825    };
826
827    let count = report.summary.functions_above_threshold;
828    let shown = report.findings.len();
829    if shown < count {
830        let _ = write!(
831            out,
832            "## Fallow: {count} high complexity function{} ({shown} shown)\n\n",
833            plural(count),
834        );
835    } else {
836        let _ = write!(
837            out,
838            "## Fallow: {count} high complexity function{}\n\n",
839            plural(count),
840        );
841    }
842
843    out.push_str("| File | Function | Severity | Cyclomatic | Cognitive | CRAP | Lines |\n");
844    out.push_str("|:-----|:---------|:---------|:-----------|:----------|:-----|:------|\n");
845
846    for finding in &report.findings {
847        let file_str = rel(&finding.path);
848        let cyc_marker = if finding.cyclomatic > report.summary.max_cyclomatic_threshold {
849            " **!**"
850        } else {
851            ""
852        };
853        let cog_marker = if finding.cognitive > report.summary.max_cognitive_threshold {
854            " **!**"
855        } else {
856            ""
857        };
858        let severity_label = match finding.severity {
859            crate::health_types::FindingSeverity::Critical => "critical",
860            crate::health_types::FindingSeverity::High => "high",
861            crate::health_types::FindingSeverity::Moderate => "moderate",
862        };
863        let crap_cell = match finding.crap {
864            Some(crap) => {
865                let marker = if crap >= report.summary.max_crap_threshold {
866                    " **!**"
867                } else {
868                    ""
869                };
870                format!("{crap:.1}{marker}")
871            }
872            None => "-".to_string(),
873        };
874        let _ = writeln!(
875            out,
876            "| `{file_str}:{line}` | `{name}` | {severity_label} | {cyc}{cyc_marker} | {cog}{cog_marker} | {crap_cell} | {lines} |",
877            line = finding.line,
878            name = escape_backticks(&finding.name),
879            cyc = finding.cyclomatic,
880            cog = finding.cognitive,
881            lines = finding.line_count,
882        );
883    }
884
885    let s = &report.summary;
886    let _ = write!(
887        out,
888        "\n**{files}** files, **{funcs}** functions analyzed \
889         (thresholds: cyclomatic > {cyc}, cognitive > {cog}, CRAP >= {crap:.1})\n",
890        files = s.files_analyzed,
891        funcs = s.functions_analyzed,
892        cyc = s.max_cyclomatic_threshold,
893        cog = s.max_cognitive_threshold,
894        crap = s.max_crap_threshold,
895    );
896}
897
898/// Write the file health scores table to the output.
899fn write_file_scores_section(
900    out: &mut String,
901    report: &crate::health_types::HealthReport,
902    root: &Path,
903) {
904    if report.file_scores.is_empty() {
905        return;
906    }
907
908    let rel = |p: &Path| {
909        escape_backticks(&normalize_uri(
910            &relative_path(p, root).display().to_string(),
911        ))
912    };
913
914    out.push('\n');
915    let _ = writeln!(
916        out,
917        "### File Health Scores ({} files)\n",
918        report.file_scores.len(),
919    );
920    out.push_str("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density | Risk |\n");
921    out.push_str("|:-----|:---------------|:-------|:--------|:----------|:--------|:-----|\n");
922
923    for score in &report.file_scores {
924        let file_str = rel(&score.path);
925        let _ = writeln!(
926            out,
927            "| `{file_str}` | {mi:.1} | {fi} | {fan_out} | {dead:.0}% | {density:.2} | {crap:.1} |",
928            mi = score.maintainability_index,
929            fi = score.fan_in,
930            fan_out = score.fan_out,
931            dead = score.dead_code_ratio * 100.0,
932            density = score.complexity_density,
933            crap = score.crap_max,
934        );
935    }
936
937    if let Some(avg) = report.summary.average_maintainability {
938        let _ = write!(out, "\n**Average maintainability index:** {avg:.1}/100\n");
939    }
940}
941
942fn write_coverage_gaps_section(
943    out: &mut String,
944    report: &crate::health_types::HealthReport,
945    root: &Path,
946) {
947    let Some(ref gaps) = report.coverage_gaps else {
948        return;
949    };
950
951    out.push('\n');
952    let _ = writeln!(out, "### Coverage Gaps\n");
953    let _ = writeln!(
954        out,
955        "*{} untested files · {} untested exports · {:.1}% file coverage*\n",
956        gaps.summary.untested_files, gaps.summary.untested_exports, gaps.summary.file_coverage_pct,
957    );
958
959    if gaps.files.is_empty() && gaps.exports.is_empty() {
960        out.push_str("_No coverage gaps found in scope._\n");
961        return;
962    }
963
964    if !gaps.files.is_empty() {
965        out.push_str("#### Files\n");
966        for item in &gaps.files {
967            let file_str = escape_backticks(&normalize_uri(
968                &relative_path(&item.path, root).display().to_string(),
969            ));
970            let _ = writeln!(
971                out,
972                "- `{file_str}` ({count} value export{})",
973                if item.value_export_count == 1 {
974                    ""
975                } else {
976                    "s"
977                },
978                count = item.value_export_count,
979            );
980        }
981        out.push('\n');
982    }
983
984    if !gaps.exports.is_empty() {
985        out.push_str("#### Exports\n");
986        for item in &gaps.exports {
987            let file_str = escape_backticks(&normalize_uri(
988                &relative_path(&item.path, root).display().to_string(),
989            ));
990            let _ = writeln!(out, "- `{file_str}`:{} `{}`", item.line, item.export_name);
991        }
992    }
993}
994
995/// Write the hotspots table to the output.
996/// Render the four ownership table cells (bus, top contributor, declared
997/// owner, notes) for the markdown hotspots table. Cells fall back to an
998/// en-dash (U+2013) when ownership data is missing for an entry.
999fn ownership_md_cells(
1000    ownership: Option<&crate::health_types::OwnershipMetrics>,
1001) -> (String, String, String, String) {
1002    let Some(o) = ownership else {
1003        let dash = "\u{2013}".to_string();
1004        return (dash.clone(), dash.clone(), dash.clone(), dash);
1005    };
1006    let bus = o.bus_factor.to_string();
1007    let top = format!(
1008        "`{}` ({:.0}%)",
1009        o.top_contributor.identifier,
1010        o.top_contributor.share * 100.0,
1011    );
1012    let owner = o
1013        .declared_owner
1014        .as_deref()
1015        .map_or_else(|| "\u{2013}".to_string(), str::to_string);
1016    let mut notes: Vec<&str> = Vec::new();
1017    if o.unowned == Some(true) {
1018        notes.push("**unowned**");
1019    }
1020    if o.drift {
1021        notes.push("drift");
1022    }
1023    let notes_str = if notes.is_empty() {
1024        "\u{2013}".to_string()
1025    } else {
1026        notes.join(", ")
1027    };
1028    (bus, top, owner, notes_str)
1029}
1030
1031fn write_hotspots_section(
1032    out: &mut String,
1033    report: &crate::health_types::HealthReport,
1034    root: &Path,
1035) {
1036    if report.hotspots.is_empty() {
1037        return;
1038    }
1039
1040    let rel = |p: &Path| {
1041        escape_backticks(&normalize_uri(
1042            &relative_path(p, root).display().to_string(),
1043        ))
1044    };
1045
1046    out.push('\n');
1047    let header = report.hotspot_summary.as_ref().map_or_else(
1048        || format!("### Hotspots ({} files)\n", report.hotspots.len()),
1049        |summary| {
1050            format!(
1051                "### Hotspots ({} files, since {})\n",
1052                report.hotspots.len(),
1053                summary.since,
1054            )
1055        },
1056    );
1057    let _ = writeln!(out, "{header}");
1058    // Add ownership columns when at least one entry has ownership data.
1059    let any_ownership = report.hotspots.iter().any(|e| e.ownership.is_some());
1060    if any_ownership {
1061        out.push_str(
1062            "| File | Score | Commits | Churn | Density | Fan-in | Trend | Bus | Top | Owner | Notes |\n"
1063        );
1064        out.push_str(
1065            "|:-----|:------|:--------|:------|:--------|:-------|:------|:----|:----|:------|:------|\n"
1066        );
1067    } else {
1068        out.push_str("| File | Score | Commits | Churn | Density | Fan-in | Trend |\n");
1069        out.push_str("|:-----|:------|:--------|:------|:--------|:-------|:------|\n");
1070    }
1071
1072    for entry in &report.hotspots {
1073        let file_str = rel(&entry.path);
1074        if any_ownership {
1075            let (bus, top, owner, notes) = ownership_md_cells(entry.ownership.as_ref());
1076            let _ = writeln!(
1077                out,
1078                "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} | {bus} | {top} | {owner} | {notes} |",
1079                score = entry.score,
1080                commits = entry.commits,
1081                churn = entry.lines_added + entry.lines_deleted,
1082                density = entry.complexity_density,
1083                fi = entry.fan_in,
1084                trend = entry.trend,
1085            );
1086        } else {
1087            let _ = writeln!(
1088                out,
1089                "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} |",
1090                score = entry.score,
1091                commits = entry.commits,
1092                churn = entry.lines_added + entry.lines_deleted,
1093                density = entry.complexity_density,
1094                fi = entry.fan_in,
1095                trend = entry.trend,
1096            );
1097        }
1098    }
1099
1100    if let Some(ref summary) = report.hotspot_summary
1101        && summary.files_excluded > 0
1102    {
1103        let _ = write!(
1104            out,
1105            "\n*{} file{} excluded (< {} commits)*\n",
1106            summary.files_excluded,
1107            plural(summary.files_excluded),
1108            summary.min_commits,
1109        );
1110    }
1111}
1112
1113/// Write the refactoring targets table to the output.
1114fn write_targets_section(
1115    out: &mut String,
1116    report: &crate::health_types::HealthReport,
1117    root: &Path,
1118) {
1119    if report.targets.is_empty() {
1120        return;
1121    }
1122    let _ = write!(
1123        out,
1124        "\n### Refactoring Targets ({})\n\n",
1125        report.targets.len()
1126    );
1127    out.push_str("| Efficiency | Category | Effort / Confidence | File | Recommendation |\n");
1128    out.push_str("|:-----------|:---------|:--------------------|:-----|:---------------|\n");
1129    for target in &report.targets {
1130        let file_str = normalize_uri(&relative_path(&target.path, root).display().to_string());
1131        let category = target.category.label();
1132        let effort = target.effort.label();
1133        let confidence = target.confidence.label();
1134        let _ = writeln!(
1135            out,
1136            "| {:.1} | {category} | {effort} / {confidence} | `{file_str}` | {} |",
1137            target.efficiency, target.recommendation,
1138        );
1139    }
1140}
1141
1142/// Write the metric legend collapsible section to the output.
1143fn write_metric_legend(out: &mut String, report: &crate::health_types::HealthReport) {
1144    let has_scores = !report.file_scores.is_empty();
1145    let has_coverage = report.coverage_gaps.is_some();
1146    let has_hotspots = !report.hotspots.is_empty();
1147    let has_targets = !report.targets.is_empty();
1148    if !has_scores && !has_coverage && !has_hotspots && !has_targets {
1149        return;
1150    }
1151    out.push_str("\n---\n\n<details><summary>Metric definitions</summary>\n\n");
1152    if has_scores {
1153        out.push_str("- **MI**: Maintainability Index (0\u{2013}100, higher is better)\n");
1154        out.push_str("- **Fan-in**: files that import this file (blast radius)\n");
1155        out.push_str("- **Fan-out**: files this file imports (coupling)\n");
1156        out.push_str("- **Dead Code**: % of value exports with zero references\n");
1157        out.push_str("- **Density**: cyclomatic complexity / lines of code\n");
1158    }
1159    if has_coverage {
1160        out.push_str(
1161            "- **File coverage**: runtime files also reachable from a discovered test root\n",
1162        );
1163        out.push_str("- **Untested export**: export with no reference chain from any test-reachable module\n");
1164    }
1165    if has_hotspots {
1166        out.push_str("- **Score**: churn \u{00d7} complexity (0\u{2013}100, higher = riskier)\n");
1167        out.push_str("- **Commits**: commits in the analysis window\n");
1168        out.push_str("- **Churn**: total lines added + deleted\n");
1169        out.push_str("- **Trend**: accelerating / stable / cooling\n");
1170    }
1171    if has_targets {
1172        out.push_str(
1173            "- **Efficiency**: priority / effort (higher = better quick-win value, default sort)\n",
1174        );
1175        out.push_str("- **Category**: recommendation type (churn+complexity, high impact, dead code, complexity, coupling, circular dep)\n");
1176        out.push_str("- **Effort**: estimated effort (low / medium / high) based on file size, function count, and fan-in\n");
1177        out.push_str("- **Confidence**: recommendation reliability (high = deterministic analysis, medium = heuristic, low = git-dependent)\n");
1178    }
1179    out.push_str(
1180        "\n[Full metric reference](https://docs.fallow.tools/explanations/metrics)\n\n</details>\n",
1181    );
1182}
1183
1184#[cfg(test)]
1185mod tests {
1186    use super::*;
1187    use crate::report::test_helpers::sample_results;
1188    use fallow_core::duplicates::{
1189        CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
1190        RefactoringKind, RefactoringSuggestion,
1191    };
1192    use fallow_core::results::*;
1193    use std::path::PathBuf;
1194
1195    #[test]
1196    fn markdown_empty_results_no_issues() {
1197        let root = PathBuf::from("/project");
1198        let results = AnalysisResults::default();
1199        let md = build_markdown(&results, &root);
1200        assert_eq!(md, "## Fallow: no issues found\n");
1201    }
1202
1203    #[test]
1204    fn markdown_contains_header_with_count() {
1205        let root = PathBuf::from("/project");
1206        let results = sample_results(&root);
1207        let md = build_markdown(&results, &root);
1208        assert!(md.starts_with(&format!(
1209            "## Fallow: {} issues found\n",
1210            results.total_issues()
1211        )));
1212    }
1213
1214    #[test]
1215    fn markdown_contains_all_sections() {
1216        let root = PathBuf::from("/project");
1217        let results = sample_results(&root);
1218        let md = build_markdown(&results, &root);
1219
1220        assert!(md.contains("### Unused files (1)"));
1221        assert!(md.contains("### Unused exports (1)"));
1222        assert!(md.contains("### Unused type exports (1)"));
1223        assert!(md.contains("### Unused dependencies (1)"));
1224        assert!(md.contains("### Unused devDependencies (1)"));
1225        assert!(md.contains("### Unused enum members (1)"));
1226        assert!(md.contains("### Unused class members (1)"));
1227        assert!(md.contains("### Unresolved imports (1)"));
1228        assert!(md.contains("### Unlisted dependencies (1)"));
1229        assert!(md.contains("### Duplicate exports (1)"));
1230        assert!(md.contains("### Type-only dependencies"));
1231        assert!(md.contains("### Test-only production dependencies"));
1232        assert!(md.contains("### Circular dependencies (1)"));
1233    }
1234
1235    #[test]
1236    fn markdown_unused_file_format() {
1237        let root = PathBuf::from("/project");
1238        let mut results = AnalysisResults::default();
1239        results.unused_files.push(UnusedFile {
1240            path: root.join("src/dead.ts"),
1241        });
1242        let md = build_markdown(&results, &root);
1243        assert!(md.contains("- `src/dead.ts`"));
1244    }
1245
1246    #[test]
1247    fn markdown_unused_export_grouped_by_file() {
1248        let root = PathBuf::from("/project");
1249        let mut results = AnalysisResults::default();
1250        results.unused_exports.push(UnusedExport {
1251            path: root.join("src/utils.ts"),
1252            export_name: "helperFn".to_string(),
1253            is_type_only: false,
1254            line: 10,
1255            col: 4,
1256            span_start: 120,
1257            is_re_export: false,
1258        });
1259        let md = build_markdown(&results, &root);
1260        assert!(md.contains("- `src/utils.ts`"));
1261        assert!(md.contains(":10 `helperFn`"));
1262    }
1263
1264    #[test]
1265    fn markdown_re_export_tagged() {
1266        let root = PathBuf::from("/project");
1267        let mut results = AnalysisResults::default();
1268        results.unused_exports.push(UnusedExport {
1269            path: root.join("src/index.ts"),
1270            export_name: "reExported".to_string(),
1271            is_type_only: false,
1272            line: 1,
1273            col: 0,
1274            span_start: 0,
1275            is_re_export: true,
1276        });
1277        let md = build_markdown(&results, &root);
1278        assert!(md.contains("(re-export)"));
1279    }
1280
1281    #[test]
1282    fn markdown_unused_dep_format() {
1283        let root = PathBuf::from("/project");
1284        let mut results = AnalysisResults::default();
1285        results.unused_dependencies.push(UnusedDependency {
1286            package_name: "lodash".to_string(),
1287            location: DependencyLocation::Dependencies,
1288            path: root.join("package.json"),
1289            line: 5,
1290            used_in_workspaces: Vec::new(),
1291        });
1292        let md = build_markdown(&results, &root);
1293        assert!(md.contains("- `lodash`"));
1294    }
1295
1296    #[test]
1297    fn markdown_circular_dep_format() {
1298        let root = PathBuf::from("/project");
1299        let mut results = AnalysisResults::default();
1300        results.circular_dependencies.push(CircularDependency {
1301            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1302            length: 2,
1303            line: 3,
1304            col: 0,
1305            is_cross_package: false,
1306        });
1307        let md = build_markdown(&results, &root);
1308        assert!(md.contains("`src/a.ts`"));
1309        assert!(md.contains("`src/b.ts`"));
1310        assert!(md.contains("\u{2192}"));
1311    }
1312
1313    #[test]
1314    fn markdown_strips_root_prefix() {
1315        let root = PathBuf::from("/project");
1316        let mut results = AnalysisResults::default();
1317        results.unused_files.push(UnusedFile {
1318            path: PathBuf::from("/project/src/deep/nested/file.ts"),
1319        });
1320        let md = build_markdown(&results, &root);
1321        assert!(md.contains("`src/deep/nested/file.ts`"));
1322        assert!(!md.contains("/project/"));
1323    }
1324
1325    #[test]
1326    fn markdown_single_issue_no_plural() {
1327        let root = PathBuf::from("/project");
1328        let mut results = AnalysisResults::default();
1329        results.unused_files.push(UnusedFile {
1330            path: root.join("src/dead.ts"),
1331        });
1332        let md = build_markdown(&results, &root);
1333        assert!(md.starts_with("## Fallow: 1 issue found\n"));
1334    }
1335
1336    #[test]
1337    fn markdown_type_only_dep_format() {
1338        let root = PathBuf::from("/project");
1339        let mut results = AnalysisResults::default();
1340        results.type_only_dependencies.push(TypeOnlyDependency {
1341            package_name: "zod".to_string(),
1342            path: root.join("package.json"),
1343            line: 8,
1344        });
1345        let md = build_markdown(&results, &root);
1346        assert!(md.contains("### Type-only dependencies"));
1347        assert!(md.contains("- `zod`"));
1348    }
1349
1350    #[test]
1351    fn markdown_escapes_backticks_in_export_names() {
1352        let root = PathBuf::from("/project");
1353        let mut results = AnalysisResults::default();
1354        results.unused_exports.push(UnusedExport {
1355            path: root.join("src/utils.ts"),
1356            export_name: "foo`bar".to_string(),
1357            is_type_only: false,
1358            line: 1,
1359            col: 0,
1360            span_start: 0,
1361            is_re_export: false,
1362        });
1363        let md = build_markdown(&results, &root);
1364        assert!(md.contains("foo\\`bar"));
1365        assert!(!md.contains("foo`bar`"));
1366    }
1367
1368    #[test]
1369    fn markdown_escapes_backticks_in_package_names() {
1370        let root = PathBuf::from("/project");
1371        let mut results = AnalysisResults::default();
1372        results.unused_dependencies.push(UnusedDependency {
1373            package_name: "pkg`name".to_string(),
1374            location: DependencyLocation::Dependencies,
1375            path: root.join("package.json"),
1376            line: 5,
1377            used_in_workspaces: Vec::new(),
1378        });
1379        let md = build_markdown(&results, &root);
1380        assert!(md.contains("pkg\\`name"));
1381    }
1382
1383    // ── Duplication markdown ──
1384
1385    #[test]
1386    fn duplication_markdown_empty() {
1387        let report = DuplicationReport::default();
1388        let root = PathBuf::from("/project");
1389        let md = build_duplication_markdown(&report, &root);
1390        assert_eq!(md, "## Fallow: no code duplication found\n");
1391    }
1392
1393    #[test]
1394    fn duplication_markdown_contains_groups() {
1395        let root = PathBuf::from("/project");
1396        let report = DuplicationReport {
1397            clone_groups: vec![CloneGroup {
1398                instances: vec![
1399                    CloneInstance {
1400                        file: root.join("src/a.ts"),
1401                        start_line: 1,
1402                        end_line: 10,
1403                        start_col: 0,
1404                        end_col: 0,
1405                        fragment: String::new(),
1406                    },
1407                    CloneInstance {
1408                        file: root.join("src/b.ts"),
1409                        start_line: 5,
1410                        end_line: 14,
1411                        start_col: 0,
1412                        end_col: 0,
1413                        fragment: String::new(),
1414                    },
1415                ],
1416                token_count: 50,
1417                line_count: 10,
1418            }],
1419            clone_families: vec![],
1420            mirrored_directories: vec![],
1421            stats: DuplicationStats {
1422                total_files: 10,
1423                files_with_clones: 2,
1424                total_lines: 500,
1425                duplicated_lines: 20,
1426                total_tokens: 2500,
1427                duplicated_tokens: 100,
1428                clone_groups: 1,
1429                clone_instances: 2,
1430                duplication_percentage: 4.0,
1431                clone_groups_below_min_occurrences: 0,
1432            },
1433        };
1434        let md = build_duplication_markdown(&report, &root);
1435        assert!(md.contains("**Clone group 1**"));
1436        assert!(md.contains("`src/a.ts:1-10`"));
1437        assert!(md.contains("`src/b.ts:5-14`"));
1438        assert!(md.contains("4.0% duplication"));
1439    }
1440
1441    #[test]
1442    fn duplication_markdown_contains_families() {
1443        let root = PathBuf::from("/project");
1444        let report = DuplicationReport {
1445            clone_groups: vec![CloneGroup {
1446                instances: vec![CloneInstance {
1447                    file: root.join("src/a.ts"),
1448                    start_line: 1,
1449                    end_line: 5,
1450                    start_col: 0,
1451                    end_col: 0,
1452                    fragment: String::new(),
1453                }],
1454                token_count: 30,
1455                line_count: 5,
1456            }],
1457            clone_families: vec![CloneFamily {
1458                files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1459                groups: vec![],
1460                total_duplicated_lines: 20,
1461                total_duplicated_tokens: 100,
1462                suggestions: vec![RefactoringSuggestion {
1463                    kind: RefactoringKind::ExtractFunction,
1464                    description: "Extract shared utility function".to_string(),
1465                    estimated_savings: 15,
1466                }],
1467            }],
1468            mirrored_directories: vec![],
1469            stats: DuplicationStats {
1470                clone_groups: 1,
1471                clone_instances: 1,
1472                duplication_percentage: 2.0,
1473                ..Default::default()
1474            },
1475        };
1476        let md = build_duplication_markdown(&report, &root);
1477        assert!(md.contains("### Clone Families"));
1478        assert!(md.contains("**Family 1**"));
1479        assert!(md.contains("Extract shared utility function"));
1480        assert!(md.contains("~15 lines saved"));
1481    }
1482
1483    // ── Health markdown ──
1484
1485    #[test]
1486    fn health_markdown_empty_no_findings() {
1487        let root = PathBuf::from("/project");
1488        let report = crate::health_types::HealthReport {
1489            summary: crate::health_types::HealthSummary {
1490                files_analyzed: 10,
1491                functions_analyzed: 50,
1492                ..Default::default()
1493            },
1494            ..Default::default()
1495        };
1496        let md = build_health_markdown(&report, &root);
1497        assert!(md.contains("no functions exceed complexity thresholds"));
1498        assert!(md.contains("**50** functions analyzed"));
1499    }
1500
1501    #[test]
1502    fn health_markdown_table_format() {
1503        let root = PathBuf::from("/project");
1504        let report = crate::health_types::HealthReport {
1505            findings: vec![crate::health_types::HealthFinding {
1506                path: root.join("src/utils.ts"),
1507                name: "parseExpression".to_string(),
1508                line: 42,
1509                col: 0,
1510                cyclomatic: 25,
1511                cognitive: 30,
1512                line_count: 80,
1513                param_count: 0,
1514                exceeded: crate::health_types::ExceededThreshold::Both,
1515                severity: crate::health_types::FindingSeverity::High,
1516                crap: None,
1517                coverage_pct: None,
1518                coverage_tier: None,
1519            }],
1520            summary: crate::health_types::HealthSummary {
1521                files_analyzed: 10,
1522                functions_analyzed: 50,
1523                functions_above_threshold: 1,
1524                ..Default::default()
1525            },
1526            ..Default::default()
1527        };
1528        let md = build_health_markdown(&report, &root);
1529        assert!(md.contains("## Fallow: 1 high complexity function\n"));
1530        assert!(md.contains("| File | Function |"));
1531        assert!(md.contains("`src/utils.ts:42`"));
1532        assert!(md.contains("`parseExpression`"));
1533        assert!(md.contains("25 **!**"));
1534        assert!(md.contains("30 **!**"));
1535        assert!(md.contains("| 80 |"));
1536        // CRAP column renders `-` when the finding didn't trigger on CRAP.
1537        assert!(md.contains("| - |"));
1538    }
1539
1540    #[test]
1541    fn health_markdown_crap_column_shows_score_and_marker() {
1542        let root = PathBuf::from("/project");
1543        let report = crate::health_types::HealthReport {
1544            findings: vec![crate::health_types::HealthFinding {
1545                path: root.join("src/risky.ts"),
1546                name: "branchy".to_string(),
1547                line: 1,
1548                col: 0,
1549                cyclomatic: 67,
1550                cognitive: 10,
1551                line_count: 80,
1552                param_count: 1,
1553                exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
1554                severity: crate::health_types::FindingSeverity::Critical,
1555                crap: Some(182.0),
1556                coverage_pct: None,
1557                coverage_tier: None,
1558            }],
1559            summary: crate::health_types::HealthSummary {
1560                files_analyzed: 1,
1561                functions_analyzed: 1,
1562                functions_above_threshold: 1,
1563                ..Default::default()
1564            },
1565            ..Default::default()
1566        };
1567        let md = build_health_markdown(&report, &root);
1568        assert!(
1569            md.contains("| CRAP |"),
1570            "markdown table should have CRAP column header: {md}"
1571        );
1572        assert!(
1573            md.contains("182.0 **!**"),
1574            "CRAP value should be rendered with a threshold marker: {md}"
1575        );
1576        assert!(
1577            md.contains("CRAP >="),
1578            "trailing summary line should reference the CRAP threshold: {md}"
1579        );
1580    }
1581
1582    #[test]
1583    fn health_markdown_no_marker_when_below_threshold() {
1584        let root = PathBuf::from("/project");
1585        let report = crate::health_types::HealthReport {
1586            findings: vec![crate::health_types::HealthFinding {
1587                path: root.join("src/utils.ts"),
1588                name: "helper".to_string(),
1589                line: 10,
1590                col: 0,
1591                cyclomatic: 15,
1592                cognitive: 20,
1593                line_count: 30,
1594                param_count: 0,
1595                exceeded: crate::health_types::ExceededThreshold::Cognitive,
1596                severity: crate::health_types::FindingSeverity::High,
1597                crap: None,
1598                coverage_pct: None,
1599                coverage_tier: None,
1600            }],
1601            summary: crate::health_types::HealthSummary {
1602                files_analyzed: 5,
1603                functions_analyzed: 20,
1604                functions_above_threshold: 1,
1605                ..Default::default()
1606            },
1607            ..Default::default()
1608        };
1609        let md = build_health_markdown(&report, &root);
1610        // Cyclomatic 15 is below threshold 20, no marker
1611        assert!(md.contains("| 15 |"));
1612        // Cognitive 20 exceeds threshold 15, has marker
1613        assert!(md.contains("20 **!**"));
1614    }
1615
1616    #[test]
1617    fn health_markdown_with_targets() {
1618        use crate::health_types::*;
1619
1620        let root = PathBuf::from("/project");
1621        let report = HealthReport {
1622            summary: HealthSummary {
1623                files_analyzed: 10,
1624                functions_analyzed: 50,
1625                ..Default::default()
1626            },
1627            targets: vec![
1628                RefactoringTarget {
1629                    path: PathBuf::from("/project/src/complex.ts"),
1630                    priority: 82.5,
1631                    efficiency: 27.5,
1632                    recommendation: "Split high-impact file".into(),
1633                    category: RecommendationCategory::SplitHighImpact,
1634                    effort: crate::health_types::EffortEstimate::High,
1635                    confidence: crate::health_types::Confidence::Medium,
1636                    factors: vec![ContributingFactor {
1637                        metric: "fan_in",
1638                        value: 25.0,
1639                        threshold: 10.0,
1640                        detail: "25 files depend on this".into(),
1641                    }],
1642                    evidence: None,
1643                },
1644                RefactoringTarget {
1645                    path: PathBuf::from("/project/src/legacy.ts"),
1646                    priority: 45.0,
1647                    efficiency: 45.0,
1648                    recommendation: "Remove 5 unused exports".into(),
1649                    category: RecommendationCategory::RemoveDeadCode,
1650                    effort: crate::health_types::EffortEstimate::Low,
1651                    confidence: crate::health_types::Confidence::High,
1652                    factors: vec![],
1653                    evidence: None,
1654                },
1655            ],
1656            ..Default::default()
1657        };
1658        let md = build_health_markdown(&report, &root);
1659
1660        // Should have refactoring targets section
1661        assert!(
1662            md.contains("Refactoring Targets"),
1663            "should contain targets heading"
1664        );
1665        assert!(
1666            md.contains("src/complex.ts"),
1667            "should contain target file path"
1668        );
1669        assert!(md.contains("27.5"), "should contain efficiency score");
1670        assert!(
1671            md.contains("Split high-impact file"),
1672            "should contain recommendation"
1673        );
1674        assert!(md.contains("src/legacy.ts"), "should contain second target");
1675    }
1676
1677    #[test]
1678    fn health_markdown_with_coverage_gaps() {
1679        use crate::health_types::*;
1680
1681        let root = PathBuf::from("/project");
1682        let report = HealthReport {
1683            summary: HealthSummary {
1684                files_analyzed: 10,
1685                functions_analyzed: 50,
1686                ..Default::default()
1687            },
1688            coverage_gaps: Some(CoverageGaps {
1689                summary: CoverageGapSummary {
1690                    runtime_files: 2,
1691                    covered_files: 0,
1692                    file_coverage_pct: 0.0,
1693                    untested_files: 1,
1694                    untested_exports: 1,
1695                },
1696                files: vec![UntestedFile {
1697                    path: root.join("src/app.ts"),
1698                    value_export_count: 2,
1699                }],
1700                exports: vec![UntestedExport {
1701                    path: root.join("src/app.ts"),
1702                    export_name: "loader".into(),
1703                    line: 12,
1704                    col: 4,
1705                }],
1706            }),
1707            ..Default::default()
1708        };
1709
1710        let md = build_health_markdown(&report, &root);
1711        assert!(md.contains("### Coverage Gaps"));
1712        assert!(md.contains("*1 untested files"));
1713        assert!(md.contains("`src/app.ts` (2 value exports)"));
1714        assert!(md.contains("`src/app.ts`:12 `loader`"));
1715    }
1716
1717    // ── Dependency in workspace package ──
1718
1719    #[test]
1720    fn markdown_dep_in_workspace_shows_package_label() {
1721        let root = PathBuf::from("/project");
1722        let mut results = AnalysisResults::default();
1723        results.unused_dependencies.push(UnusedDependency {
1724            package_name: "lodash".to_string(),
1725            location: DependencyLocation::Dependencies,
1726            path: root.join("packages/core/package.json"),
1727            line: 5,
1728            used_in_workspaces: Vec::new(),
1729        });
1730        let md = build_markdown(&results, &root);
1731        // Non-root package.json should show the label
1732        assert!(md.contains("(packages/core/package.json)"));
1733    }
1734
1735    #[test]
1736    fn markdown_dep_at_root_no_extra_label() {
1737        let root = PathBuf::from("/project");
1738        let mut results = AnalysisResults::default();
1739        results.unused_dependencies.push(UnusedDependency {
1740            package_name: "lodash".to_string(),
1741            location: DependencyLocation::Dependencies,
1742            path: root.join("package.json"),
1743            line: 5,
1744            used_in_workspaces: Vec::new(),
1745        });
1746        let md = build_markdown(&results, &root);
1747        assert!(md.contains("- `lodash`"));
1748        assert!(!md.contains("(package.json)"));
1749    }
1750
1751    #[test]
1752    fn markdown_root_dep_with_cross_workspace_context_uses_context_label() {
1753        let root = PathBuf::from("/project");
1754        let mut results = AnalysisResults::default();
1755        results.unused_dependencies.push(UnusedDependency {
1756            package_name: "lodash-es".to_string(),
1757            location: DependencyLocation::Dependencies,
1758            path: root.join("package.json"),
1759            line: 5,
1760            used_in_workspaces: vec![root.join("packages/consumer")],
1761        });
1762        let md = build_markdown(&results, &root);
1763        assert!(md.contains("- `lodash-es` (imported in packages/consumer)"));
1764        assert!(!md.contains("(package.json; imported in packages/consumer)"));
1765    }
1766
1767    // ── Multiple exports same file grouped ──
1768
1769    #[test]
1770    fn markdown_exports_grouped_by_file() {
1771        let root = PathBuf::from("/project");
1772        let mut results = AnalysisResults::default();
1773        results.unused_exports.push(UnusedExport {
1774            path: root.join("src/utils.ts"),
1775            export_name: "alpha".to_string(),
1776            is_type_only: false,
1777            line: 5,
1778            col: 0,
1779            span_start: 0,
1780            is_re_export: false,
1781        });
1782        results.unused_exports.push(UnusedExport {
1783            path: root.join("src/utils.ts"),
1784            export_name: "beta".to_string(),
1785            is_type_only: false,
1786            line: 10,
1787            col: 0,
1788            span_start: 0,
1789            is_re_export: false,
1790        });
1791        results.unused_exports.push(UnusedExport {
1792            path: root.join("src/other.ts"),
1793            export_name: "gamma".to_string(),
1794            is_type_only: false,
1795            line: 1,
1796            col: 0,
1797            span_start: 0,
1798            is_re_export: false,
1799        });
1800        let md = build_markdown(&results, &root);
1801        // File header should appear only once for utils.ts
1802        let utils_count = md.matches("- `src/utils.ts`").count();
1803        assert_eq!(utils_count, 1, "file header should appear once per file");
1804        // Both exports should be under it as sub-items
1805        assert!(md.contains(":5 `alpha`"));
1806        assert!(md.contains(":10 `beta`"));
1807    }
1808
1809    // ── Multiple issues plural header ──
1810
1811    #[test]
1812    fn markdown_multiple_issues_plural() {
1813        let root = PathBuf::from("/project");
1814        let mut results = AnalysisResults::default();
1815        results.unused_files.push(UnusedFile {
1816            path: root.join("src/a.ts"),
1817        });
1818        results.unused_files.push(UnusedFile {
1819            path: root.join("src/b.ts"),
1820        });
1821        let md = build_markdown(&results, &root);
1822        assert!(md.starts_with("## Fallow: 2 issues found\n"));
1823    }
1824
1825    // ── Duplication markdown with zero estimated savings ──
1826
1827    #[test]
1828    fn duplication_markdown_zero_savings_no_suffix() {
1829        let root = PathBuf::from("/project");
1830        let report = DuplicationReport {
1831            clone_groups: vec![CloneGroup {
1832                instances: vec![CloneInstance {
1833                    file: root.join("src/a.ts"),
1834                    start_line: 1,
1835                    end_line: 5,
1836                    start_col: 0,
1837                    end_col: 0,
1838                    fragment: String::new(),
1839                }],
1840                token_count: 30,
1841                line_count: 5,
1842            }],
1843            clone_families: vec![CloneFamily {
1844                files: vec![root.join("src/a.ts")],
1845                groups: vec![],
1846                total_duplicated_lines: 5,
1847                total_duplicated_tokens: 30,
1848                suggestions: vec![RefactoringSuggestion {
1849                    kind: RefactoringKind::ExtractFunction,
1850                    description: "Extract function".to_string(),
1851                    estimated_savings: 0,
1852                }],
1853            }],
1854            mirrored_directories: vec![],
1855            stats: DuplicationStats {
1856                clone_groups: 1,
1857                clone_instances: 1,
1858                duplication_percentage: 1.0,
1859                ..Default::default()
1860            },
1861        };
1862        let md = build_duplication_markdown(&report, &root);
1863        assert!(md.contains("Extract function"));
1864        assert!(!md.contains("lines saved"));
1865    }
1866
1867    // ── Health markdown vital signs ──
1868
1869    #[test]
1870    fn health_markdown_vital_signs_table() {
1871        let root = PathBuf::from("/project");
1872        let report = crate::health_types::HealthReport {
1873            summary: crate::health_types::HealthSummary {
1874                files_analyzed: 10,
1875                functions_analyzed: 50,
1876                ..Default::default()
1877            },
1878            vital_signs: Some(crate::health_types::VitalSigns {
1879                avg_cyclomatic: 3.5,
1880                p90_cyclomatic: 12,
1881                dead_file_pct: Some(5.0),
1882                dead_export_pct: Some(10.2),
1883                duplication_pct: None,
1884                maintainability_avg: Some(72.3),
1885                hotspot_count: Some(3),
1886                circular_dep_count: Some(1),
1887                unused_dep_count: Some(2),
1888                counts: None,
1889                unit_size_profile: None,
1890                unit_interfacing_profile: None,
1891                p95_fan_in: None,
1892                coupling_high_pct: None,
1893                total_loc: 15_200,
1894                ..Default::default()
1895            }),
1896            ..Default::default()
1897        };
1898        let md = build_health_markdown(&report, &root);
1899        assert!(md.contains("## Vital Signs"));
1900        assert!(md.contains("| Metric | Value |"));
1901        assert!(md.contains("| Total LOC | 15200 |"));
1902        assert!(md.contains("| Avg Cyclomatic | 3.5 |"));
1903        assert!(md.contains("| P90 Cyclomatic | 12 |"));
1904        assert!(md.contains("| Dead Files | 5.0% |"));
1905        assert!(md.contains("| Dead Exports | 10.2% |"));
1906        assert!(md.contains("| Maintainability (avg) | 72.3 |"));
1907        assert!(md.contains("| Hotspots | 3 |"));
1908        assert!(md.contains("| Circular Deps | 1 |"));
1909        assert!(md.contains("| Unused Deps | 2 |"));
1910    }
1911
1912    // ── Health markdown file scores ──
1913
1914    #[test]
1915    fn health_markdown_file_scores_table() {
1916        let root = PathBuf::from("/project");
1917        let report = crate::health_types::HealthReport {
1918            findings: vec![crate::health_types::HealthFinding {
1919                path: root.join("src/dummy.ts"),
1920                name: "fn".to_string(),
1921                line: 1,
1922                col: 0,
1923                cyclomatic: 25,
1924                cognitive: 20,
1925                line_count: 50,
1926                param_count: 0,
1927                exceeded: crate::health_types::ExceededThreshold::Both,
1928                severity: crate::health_types::FindingSeverity::High,
1929                crap: None,
1930                coverage_pct: None,
1931                coverage_tier: None,
1932            }],
1933            summary: crate::health_types::HealthSummary {
1934                files_analyzed: 5,
1935                functions_analyzed: 10,
1936                functions_above_threshold: 1,
1937                files_scored: Some(1),
1938                average_maintainability: Some(65.0),
1939                ..Default::default()
1940            },
1941            file_scores: vec![crate::health_types::FileHealthScore {
1942                path: root.join("src/utils.ts"),
1943                fan_in: 5,
1944                fan_out: 3,
1945                dead_code_ratio: 0.25,
1946                complexity_density: 0.8,
1947                maintainability_index: 72.5,
1948                total_cyclomatic: 40,
1949                total_cognitive: 30,
1950                function_count: 10,
1951                lines: 200,
1952                crap_max: 0.0,
1953                crap_above_threshold: 0,
1954            }],
1955            ..Default::default()
1956        };
1957        let md = build_health_markdown(&report, &root);
1958        assert!(md.contains("### File Health Scores (1 files)"));
1959        assert!(md.contains("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density |"));
1960        assert!(md.contains("| `src/utils.ts` | 72.5 | 5 | 3 | 25% | 0.80 |"));
1961        assert!(md.contains("**Average maintainability index:** 65.0/100"));
1962    }
1963
1964    // ── Health markdown hotspots ──
1965
1966    #[test]
1967    fn health_markdown_hotspots_table() {
1968        let root = PathBuf::from("/project");
1969        let report = crate::health_types::HealthReport {
1970            findings: vec![crate::health_types::HealthFinding {
1971                path: root.join("src/dummy.ts"),
1972                name: "fn".to_string(),
1973                line: 1,
1974                col: 0,
1975                cyclomatic: 25,
1976                cognitive: 20,
1977                line_count: 50,
1978                param_count: 0,
1979                exceeded: crate::health_types::ExceededThreshold::Both,
1980                severity: crate::health_types::FindingSeverity::High,
1981                crap: None,
1982                coverage_pct: None,
1983                coverage_tier: None,
1984            }],
1985            summary: crate::health_types::HealthSummary {
1986                files_analyzed: 5,
1987                functions_analyzed: 10,
1988                functions_above_threshold: 1,
1989                ..Default::default()
1990            },
1991            hotspots: vec![crate::health_types::HotspotEntry {
1992                path: root.join("src/hot.ts"),
1993                score: 85.0,
1994                commits: 42,
1995                weighted_commits: 35.0,
1996                lines_added: 500,
1997                lines_deleted: 200,
1998                complexity_density: 1.2,
1999                fan_in: 10,
2000                trend: fallow_core::churn::ChurnTrend::Accelerating,
2001                ownership: None,
2002                is_test_path: false,
2003            }],
2004            hotspot_summary: Some(crate::health_types::HotspotSummary {
2005                since: "6 months".to_string(),
2006                min_commits: 3,
2007                files_analyzed: 50,
2008                files_excluded: 5,
2009                shallow_clone: false,
2010            }),
2011            ..Default::default()
2012        };
2013        let md = build_health_markdown(&report, &root);
2014        assert!(md.contains("### Hotspots (1 files, since 6 months)"));
2015        assert!(md.contains("| `src/hot.ts` | 85.0 | 42 | 700 | 1.20 | 10 | accelerating |"));
2016        assert!(md.contains("*5 files excluded (< 3 commits)*"));
2017    }
2018
2019    // ── Health markdown metric legend ──
2020
2021    #[test]
2022    fn health_markdown_metric_legend_with_scores() {
2023        let root = PathBuf::from("/project");
2024        let report = crate::health_types::HealthReport {
2025            findings: vec![crate::health_types::HealthFinding {
2026                path: root.join("src/x.ts"),
2027                name: "f".to_string(),
2028                line: 1,
2029                col: 0,
2030                cyclomatic: 25,
2031                cognitive: 20,
2032                line_count: 10,
2033                param_count: 0,
2034                exceeded: crate::health_types::ExceededThreshold::Both,
2035                severity: crate::health_types::FindingSeverity::High,
2036                crap: None,
2037                coverage_pct: None,
2038                coverage_tier: None,
2039            }],
2040            summary: crate::health_types::HealthSummary {
2041                files_analyzed: 1,
2042                functions_analyzed: 1,
2043                functions_above_threshold: 1,
2044                files_scored: Some(1),
2045                average_maintainability: Some(70.0),
2046                ..Default::default()
2047            },
2048            file_scores: vec![crate::health_types::FileHealthScore {
2049                path: root.join("src/x.ts"),
2050                fan_in: 1,
2051                fan_out: 1,
2052                dead_code_ratio: 0.0,
2053                complexity_density: 0.5,
2054                maintainability_index: 80.0,
2055                total_cyclomatic: 10,
2056                total_cognitive: 8,
2057                function_count: 2,
2058                lines: 50,
2059                crap_max: 0.0,
2060                crap_above_threshold: 0,
2061            }],
2062            ..Default::default()
2063        };
2064        let md = build_health_markdown(&report, &root);
2065        assert!(md.contains("<details><summary>Metric definitions</summary>"));
2066        assert!(md.contains("**MI**: Maintainability Index"));
2067        assert!(md.contains("**Fan-in**"));
2068        assert!(md.contains("Full metric reference"));
2069    }
2070
2071    // ── Health markdown truncated findings ──
2072
2073    #[test]
2074    fn health_markdown_truncated_findings_shown_count() {
2075        let root = PathBuf::from("/project");
2076        let report = crate::health_types::HealthReport {
2077            findings: vec![crate::health_types::HealthFinding {
2078                path: root.join("src/x.ts"),
2079                name: "f".to_string(),
2080                line: 1,
2081                col: 0,
2082                cyclomatic: 25,
2083                cognitive: 20,
2084                line_count: 10,
2085                param_count: 0,
2086                exceeded: crate::health_types::ExceededThreshold::Both,
2087                severity: crate::health_types::FindingSeverity::High,
2088                crap: None,
2089                coverage_pct: None,
2090                coverage_tier: None,
2091            }],
2092            summary: crate::health_types::HealthSummary {
2093                files_analyzed: 10,
2094                functions_analyzed: 50,
2095                functions_above_threshold: 5, // 5 total but only 1 shown
2096                ..Default::default()
2097            },
2098            ..Default::default()
2099        };
2100        let md = build_health_markdown(&report, &root);
2101        assert!(md.contains("5 high complexity functions (1 shown)"));
2102    }
2103
2104    // ── escape_backticks ──
2105
2106    #[test]
2107    fn escape_backticks_handles_multiple() {
2108        assert_eq!(escape_backticks("a`b`c"), "a\\`b\\`c");
2109    }
2110
2111    #[test]
2112    fn escape_backticks_no_backticks_unchanged() {
2113        assert_eq!(escape_backticks("hello"), "hello");
2114    }
2115
2116    // ── Unresolved import in markdown ──
2117
2118    #[test]
2119    fn markdown_unresolved_import_grouped_by_file() {
2120        let root = PathBuf::from("/project");
2121        let mut results = AnalysisResults::default();
2122        results.unresolved_imports.push(UnresolvedImport {
2123            path: root.join("src/app.ts"),
2124            specifier: "./missing".to_string(),
2125            line: 3,
2126            col: 0,
2127            specifier_col: 0,
2128        });
2129        let md = build_markdown(&results, &root);
2130        assert!(md.contains("### Unresolved imports (1)"));
2131        assert!(md.contains("- `src/app.ts`"));
2132        assert!(md.contains(":3 `./missing`"));
2133    }
2134
2135    // ── Markdown optional dep ──
2136
2137    #[test]
2138    fn markdown_unused_optional_dep() {
2139        let root = PathBuf::from("/project");
2140        let mut results = AnalysisResults::default();
2141        results.unused_optional_dependencies.push(UnusedDependency {
2142            package_name: "fsevents".to_string(),
2143            location: DependencyLocation::OptionalDependencies,
2144            path: root.join("package.json"),
2145            line: 12,
2146            used_in_workspaces: Vec::new(),
2147        });
2148        let md = build_markdown(&results, &root);
2149        assert!(md.contains("### Unused optionalDependencies (1)"));
2150        assert!(md.contains("- `fsevents`"));
2151    }
2152
2153    // ── Health markdown no hotspot exclusion message when 0 excluded ──
2154
2155    #[test]
2156    fn health_markdown_hotspots_no_excluded_message() {
2157        let root = PathBuf::from("/project");
2158        let report = crate::health_types::HealthReport {
2159            findings: vec![crate::health_types::HealthFinding {
2160                path: root.join("src/x.ts"),
2161                name: "f".to_string(),
2162                line: 1,
2163                col: 0,
2164                cyclomatic: 25,
2165                cognitive: 20,
2166                line_count: 10,
2167                param_count: 0,
2168                exceeded: crate::health_types::ExceededThreshold::Both,
2169                severity: crate::health_types::FindingSeverity::High,
2170                crap: None,
2171                coverage_pct: None,
2172                coverage_tier: None,
2173            }],
2174            summary: crate::health_types::HealthSummary {
2175                files_analyzed: 5,
2176                functions_analyzed: 10,
2177                functions_above_threshold: 1,
2178                ..Default::default()
2179            },
2180            hotspots: vec![crate::health_types::HotspotEntry {
2181                path: root.join("src/hot.ts"),
2182                score: 50.0,
2183                commits: 10,
2184                weighted_commits: 8.0,
2185                lines_added: 100,
2186                lines_deleted: 50,
2187                complexity_density: 0.5,
2188                fan_in: 3,
2189                trend: fallow_core::churn::ChurnTrend::Stable,
2190                ownership: None,
2191                is_test_path: false,
2192            }],
2193            hotspot_summary: Some(crate::health_types::HotspotSummary {
2194                since: "6 months".to_string(),
2195                min_commits: 3,
2196                files_analyzed: 50,
2197                files_excluded: 0,
2198                shallow_clone: false,
2199            }),
2200            ..Default::default()
2201        };
2202        let md = build_health_markdown(&report, &root);
2203        assert!(!md.contains("files excluded"));
2204    }
2205
2206    // ── Duplication markdown plural ──
2207
2208    #[test]
2209    fn duplication_markdown_single_group_no_plural() {
2210        let root = PathBuf::from("/project");
2211        let report = DuplicationReport {
2212            clone_groups: vec![CloneGroup {
2213                instances: vec![CloneInstance {
2214                    file: root.join("src/a.ts"),
2215                    start_line: 1,
2216                    end_line: 5,
2217                    start_col: 0,
2218                    end_col: 0,
2219                    fragment: String::new(),
2220                }],
2221                token_count: 30,
2222                line_count: 5,
2223            }],
2224            clone_families: vec![],
2225            mirrored_directories: vec![],
2226            stats: DuplicationStats {
2227                clone_groups: 1,
2228                clone_instances: 1,
2229                duplication_percentage: 2.0,
2230                ..Default::default()
2231            },
2232        };
2233        let md = build_duplication_markdown(&report, &root);
2234        assert!(md.contains("1 clone group found"));
2235        assert!(!md.contains("1 clone groups found"));
2236    }
2237}