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