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