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.hotspots.is_empty()
431        && report.targets.is_empty()
432    {
433        if report.vital_signs.is_none() {
434            let _ = write!(
435                out,
436                "## Fallow: no functions exceed complexity thresholds\n\n\
437                 **{}** functions analyzed (max cyclomatic: {}, max cognitive: {})\n",
438                report.summary.functions_analyzed,
439                report.summary.max_cyclomatic_threshold,
440                report.summary.max_cognitive_threshold,
441            );
442        }
443        return out;
444    }
445
446    write_findings_section(&mut out, report, root);
447    write_file_scores_section(&mut out, report, root);
448    write_hotspots_section(&mut out, report, root);
449    write_targets_section(&mut out, report, root);
450    write_metric_legend(&mut out, report);
451
452    out
453}
454
455/// Write the trend comparison table to the output.
456fn write_trend_section(out: &mut String, report: &crate::health_types::HealthReport) {
457    let Some(ref trend) = report.health_trend else {
458        return;
459    };
460    let sha_str = trend
461        .compared_to
462        .git_sha
463        .as_deref()
464        .map_or(String::new(), |sha| format!(" ({sha})"));
465    let _ = writeln!(
466        out,
467        "## Trend (vs {}{})\n",
468        trend
469            .compared_to
470            .timestamp
471            .get(..10)
472            .unwrap_or(&trend.compared_to.timestamp),
473        sha_str,
474    );
475    out.push_str("| Metric | Previous | Current | Delta | Direction |\n");
476    out.push_str("|:-------|:---------|:--------|:------|:----------|\n");
477    for m in &trend.metrics {
478        let fmt_val = |v: f64| -> String {
479            if m.unit == "%" {
480                format!("{v:.1}%")
481            } else if (v - v.round()).abs() < 0.05 {
482                format!("{v:.0}")
483            } else {
484                format!("{v:.1}")
485            }
486        };
487        let prev = fmt_val(m.previous);
488        let cur = fmt_val(m.current);
489        let delta = if m.unit == "%" {
490            format!("{:+.1}%", m.delta)
491        } else if (m.delta - m.delta.round()).abs() < 0.05 {
492            format!("{:+.0}", m.delta)
493        } else {
494            format!("{:+.1}", m.delta)
495        };
496        let _ = writeln!(
497            out,
498            "| {} | {} | {} | {} | {} {} |",
499            m.label,
500            prev,
501            cur,
502            delta,
503            m.direction.arrow(),
504            m.direction.label(),
505        );
506    }
507    let md_sha = trend
508        .compared_to
509        .git_sha
510        .as_deref()
511        .map_or(String::new(), |sha| format!(" ({sha})"));
512    let _ = writeln!(
513        out,
514        "\n*vs {}{} · {} {} available*\n",
515        trend
516            .compared_to
517            .timestamp
518            .get(..10)
519            .unwrap_or(&trend.compared_to.timestamp),
520        md_sha,
521        trend.snapshots_loaded,
522        if trend.snapshots_loaded == 1 {
523            "snapshot"
524        } else {
525            "snapshots"
526        },
527    );
528}
529
530/// Write the vital signs summary table to the output.
531fn write_vital_signs_section(out: &mut String, report: &crate::health_types::HealthReport) {
532    let Some(ref vs) = report.vital_signs else {
533        return;
534    };
535    out.push_str("## Vital Signs\n\n");
536    out.push_str("| Metric | Value |\n");
537    out.push_str("|:-------|------:|\n");
538    let _ = writeln!(out, "| Avg Cyclomatic | {:.1} |", vs.avg_cyclomatic);
539    let _ = writeln!(out, "| P90 Cyclomatic | {} |", vs.p90_cyclomatic);
540    if let Some(v) = vs.dead_file_pct {
541        let _ = writeln!(out, "| Dead Files | {v:.1}% |");
542    }
543    if let Some(v) = vs.dead_export_pct {
544        let _ = writeln!(out, "| Dead Exports | {v:.1}% |");
545    }
546    if let Some(v) = vs.maintainability_avg {
547        let _ = writeln!(out, "| Maintainability (avg) | {v:.1} |");
548    }
549    if let Some(v) = vs.hotspot_count {
550        let _ = writeln!(out, "| Hotspots | {v} |");
551    }
552    if let Some(v) = vs.circular_dep_count {
553        let _ = writeln!(out, "| Circular Deps | {v} |");
554    }
555    if let Some(v) = vs.unused_dep_count {
556        let _ = writeln!(out, "| Unused Deps | {v} |");
557    }
558    out.push('\n');
559}
560
561/// Write the complexity findings table to the output.
562fn write_findings_section(
563    out: &mut String,
564    report: &crate::health_types::HealthReport,
565    root: &Path,
566) {
567    if report.findings.is_empty() {
568        return;
569    }
570
571    let rel = |p: &Path| {
572        escape_backticks(&normalize_uri(
573            &relative_path(p, root).display().to_string(),
574        ))
575    };
576
577    let count = report.summary.functions_above_threshold;
578    let shown = report.findings.len();
579    if shown < count {
580        let _ = write!(
581            out,
582            "## Fallow: {count} high complexity function{} ({shown} shown)\n\n",
583            plural(count),
584        );
585    } else {
586        let _ = write!(
587            out,
588            "## Fallow: {count} high complexity function{}\n\n",
589            plural(count),
590        );
591    }
592
593    out.push_str("| File | Function | Cyclomatic | Cognitive | Lines |\n");
594    out.push_str("|:-----|:---------|:-----------|:----------|:------|\n");
595
596    for finding in &report.findings {
597        let file_str = rel(&finding.path);
598        let cyc_marker = if finding.cyclomatic > report.summary.max_cyclomatic_threshold {
599            " **!**"
600        } else {
601            ""
602        };
603        let cog_marker = if finding.cognitive > report.summary.max_cognitive_threshold {
604            " **!**"
605        } else {
606            ""
607        };
608        let _ = writeln!(
609            out,
610            "| `{file_str}:{line}` | `{name}` | {cyc}{cyc_marker} | {cog}{cog_marker} | {lines} |",
611            line = finding.line,
612            name = escape_backticks(&finding.name),
613            cyc = finding.cyclomatic,
614            cog = finding.cognitive,
615            lines = finding.line_count,
616        );
617    }
618
619    let s = &report.summary;
620    let _ = write!(
621        out,
622        "\n**{files}** files, **{funcs}** functions analyzed \
623         (thresholds: cyclomatic > {cyc}, cognitive > {cog})\n",
624        files = s.files_analyzed,
625        funcs = s.functions_analyzed,
626        cyc = s.max_cyclomatic_threshold,
627        cog = s.max_cognitive_threshold,
628    );
629}
630
631/// Write the file health scores table to the output.
632fn write_file_scores_section(
633    out: &mut String,
634    report: &crate::health_types::HealthReport,
635    root: &Path,
636) {
637    if report.file_scores.is_empty() {
638        return;
639    }
640
641    let rel = |p: &Path| {
642        escape_backticks(&normalize_uri(
643            &relative_path(p, root).display().to_string(),
644        ))
645    };
646
647    out.push('\n');
648    let _ = writeln!(
649        out,
650        "### File Health Scores ({} files)\n",
651        report.file_scores.len(),
652    );
653    out.push_str("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density |\n");
654    out.push_str("|:-----|:---------------|:-------|:--------|:----------|:--------|\n");
655
656    for score in &report.file_scores {
657        let file_str = rel(&score.path);
658        let _ = writeln!(
659            out,
660            "| `{file_str}` | {mi:.1} | {fi} | {fan_out} | {dead:.0}% | {density:.2} |",
661            mi = score.maintainability_index,
662            fi = score.fan_in,
663            fan_out = score.fan_out,
664            dead = score.dead_code_ratio * 100.0,
665            density = score.complexity_density,
666        );
667    }
668
669    if let Some(avg) = report.summary.average_maintainability {
670        let _ = write!(out, "\n**Average maintainability index:** {avg:.1}/100\n");
671    }
672}
673
674/// Write the hotspots table to the output.
675fn write_hotspots_section(
676    out: &mut String,
677    report: &crate::health_types::HealthReport,
678    root: &Path,
679) {
680    if report.hotspots.is_empty() {
681        return;
682    }
683
684    let rel = |p: &Path| {
685        escape_backticks(&normalize_uri(
686            &relative_path(p, root).display().to_string(),
687        ))
688    };
689
690    out.push('\n');
691    let header = report.hotspot_summary.as_ref().map_or_else(
692        || format!("### Hotspots ({} files)\n", report.hotspots.len()),
693        |summary| {
694            format!(
695                "### Hotspots ({} files, since {})\n",
696                report.hotspots.len(),
697                summary.since,
698            )
699        },
700    );
701    let _ = writeln!(out, "{header}");
702    out.push_str("| File | Score | Commits | Churn | Density | Fan-in | Trend |\n");
703    out.push_str("|:-----|:------|:--------|:------|:--------|:-------|:------|\n");
704
705    for entry in &report.hotspots {
706        let file_str = rel(&entry.path);
707        let _ = writeln!(
708            out,
709            "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} |",
710            score = entry.score,
711            commits = entry.commits,
712            churn = entry.lines_added + entry.lines_deleted,
713            density = entry.complexity_density,
714            fi = entry.fan_in,
715            trend = entry.trend,
716        );
717    }
718
719    if let Some(ref summary) = report.hotspot_summary
720        && summary.files_excluded > 0
721    {
722        let _ = write!(
723            out,
724            "\n*{} file{} excluded (< {} commits)*\n",
725            summary.files_excluded,
726            plural(summary.files_excluded),
727            summary.min_commits,
728        );
729    }
730}
731
732/// Write the refactoring targets table to the output.
733fn write_targets_section(
734    out: &mut String,
735    report: &crate::health_types::HealthReport,
736    root: &Path,
737) {
738    if report.targets.is_empty() {
739        return;
740    }
741    let _ = write!(
742        out,
743        "\n### Refactoring Targets ({})\n\n",
744        report.targets.len()
745    );
746    out.push_str("| Efficiency | Category | Effort / Confidence | File | Recommendation |\n");
747    out.push_str("|:-----------|:---------|:--------------------|:-----|:---------------|\n");
748    for target in &report.targets {
749        let file_str = normalize_uri(&relative_path(&target.path, root).display().to_string());
750        let category = target.category.label();
751        let effort = target.effort.label();
752        let confidence = target.confidence.label();
753        let _ = writeln!(
754            out,
755            "| {:.1} | {category} | {effort} / {confidence} | `{file_str}` | {} |",
756            target.efficiency, target.recommendation,
757        );
758    }
759}
760
761/// Write the metric legend collapsible section to the output.
762fn write_metric_legend(out: &mut String, report: &crate::health_types::HealthReport) {
763    let has_scores = !report.file_scores.is_empty();
764    let has_hotspots = !report.hotspots.is_empty();
765    let has_targets = !report.targets.is_empty();
766    if !has_scores && !has_hotspots && !has_targets {
767        return;
768    }
769    out.push_str("\n---\n\n<details><summary>Metric definitions</summary>\n\n");
770    if has_scores {
771        out.push_str("- **MI** — Maintainability Index (0\u{2013}100, higher is better)\n");
772        out.push_str("- **Fan-in** — files that import this file (blast radius)\n");
773        out.push_str("- **Fan-out** — files this file imports (coupling)\n");
774        out.push_str("- **Dead Code** — % of value exports with zero references\n");
775        out.push_str("- **Density** — cyclomatic complexity / lines of code\n");
776    }
777    if has_hotspots {
778        out.push_str("- **Score** — churn \u{00d7} complexity (0\u{2013}100, higher = riskier)\n");
779        out.push_str("- **Commits** — commits in the analysis window\n");
780        out.push_str("- **Churn** — total lines added + deleted\n");
781        out.push_str("- **Trend** — accelerating / stable / cooling\n");
782    }
783    if has_targets {
784        out.push_str("- **Efficiency** — priority / effort (higher = better quick-win value, default sort)\n");
785        out.push_str("- **Category** — recommendation type (churn+complexity, high impact, dead code, complexity, coupling, circular dep)\n");
786        out.push_str("- **Effort** — estimated effort (low / medium / high) based on file size, function count, and fan-in\n");
787        out.push_str("- **Confidence** — recommendation reliability (high = deterministic analysis, medium = heuristic, low = git-dependent)\n");
788    }
789    out.push_str(
790        "\n[Full metric reference](https://docs.fallow.tools/explanations/metrics)\n\n</details>\n",
791    );
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797    use crate::report::test_helpers::sample_results;
798    use fallow_core::duplicates::{
799        CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
800        RefactoringKind, RefactoringSuggestion,
801    };
802    use fallow_core::results::*;
803    use std::path::PathBuf;
804
805    #[test]
806    fn markdown_empty_results_no_issues() {
807        let root = PathBuf::from("/project");
808        let results = AnalysisResults::default();
809        let md = build_markdown(&results, &root);
810        assert_eq!(md, "## Fallow: no issues found\n");
811    }
812
813    #[test]
814    fn markdown_contains_header_with_count() {
815        let root = PathBuf::from("/project");
816        let results = sample_results(&root);
817        let md = build_markdown(&results, &root);
818        assert!(md.starts_with(&format!(
819            "## Fallow: {} issues found\n",
820            results.total_issues()
821        )));
822    }
823
824    #[test]
825    fn markdown_contains_all_sections() {
826        let root = PathBuf::from("/project");
827        let results = sample_results(&root);
828        let md = build_markdown(&results, &root);
829
830        assert!(md.contains("### Unused files (1)"));
831        assert!(md.contains("### Unused exports (1)"));
832        assert!(md.contains("### Unused type exports (1)"));
833        assert!(md.contains("### Unused dependencies (1)"));
834        assert!(md.contains("### Unused devDependencies (1)"));
835        assert!(md.contains("### Unused enum members (1)"));
836        assert!(md.contains("### Unused class members (1)"));
837        assert!(md.contains("### Unresolved imports (1)"));
838        assert!(md.contains("### Unlisted dependencies (1)"));
839        assert!(md.contains("### Duplicate exports (1)"));
840        assert!(md.contains("### Type-only dependencies"));
841        assert!(md.contains("### Test-only production dependencies"));
842        assert!(md.contains("### Circular dependencies (1)"));
843    }
844
845    #[test]
846    fn markdown_unused_file_format() {
847        let root = PathBuf::from("/project");
848        let mut results = AnalysisResults::default();
849        results.unused_files.push(UnusedFile {
850            path: root.join("src/dead.ts"),
851        });
852        let md = build_markdown(&results, &root);
853        assert!(md.contains("- `src/dead.ts`"));
854    }
855
856    #[test]
857    fn markdown_unused_export_grouped_by_file() {
858        let root = PathBuf::from("/project");
859        let mut results = AnalysisResults::default();
860        results.unused_exports.push(UnusedExport {
861            path: root.join("src/utils.ts"),
862            export_name: "helperFn".to_string(),
863            is_type_only: false,
864            line: 10,
865            col: 4,
866            span_start: 120,
867            is_re_export: false,
868        });
869        let md = build_markdown(&results, &root);
870        assert!(md.contains("- `src/utils.ts`"));
871        assert!(md.contains(":10 `helperFn`"));
872    }
873
874    #[test]
875    fn markdown_re_export_tagged() {
876        let root = PathBuf::from("/project");
877        let mut results = AnalysisResults::default();
878        results.unused_exports.push(UnusedExport {
879            path: root.join("src/index.ts"),
880            export_name: "reExported".to_string(),
881            is_type_only: false,
882            line: 1,
883            col: 0,
884            span_start: 0,
885            is_re_export: true,
886        });
887        let md = build_markdown(&results, &root);
888        assert!(md.contains("(re-export)"));
889    }
890
891    #[test]
892    fn markdown_unused_dep_format() {
893        let root = PathBuf::from("/project");
894        let mut results = AnalysisResults::default();
895        results.unused_dependencies.push(UnusedDependency {
896            package_name: "lodash".to_string(),
897            location: DependencyLocation::Dependencies,
898            path: root.join("package.json"),
899            line: 5,
900        });
901        let md = build_markdown(&results, &root);
902        assert!(md.contains("- `lodash`"));
903    }
904
905    #[test]
906    fn markdown_circular_dep_format() {
907        let root = PathBuf::from("/project");
908        let mut results = AnalysisResults::default();
909        results.circular_dependencies.push(CircularDependency {
910            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
911            length: 2,
912            line: 3,
913            col: 0,
914            is_cross_package: false,
915        });
916        let md = build_markdown(&results, &root);
917        assert!(md.contains("`src/a.ts`"));
918        assert!(md.contains("`src/b.ts`"));
919        assert!(md.contains("\u{2192}"));
920    }
921
922    #[test]
923    fn markdown_strips_root_prefix() {
924        let root = PathBuf::from("/project");
925        let mut results = AnalysisResults::default();
926        results.unused_files.push(UnusedFile {
927            path: PathBuf::from("/project/src/deep/nested/file.ts"),
928        });
929        let md = build_markdown(&results, &root);
930        assert!(md.contains("`src/deep/nested/file.ts`"));
931        assert!(!md.contains("/project/"));
932    }
933
934    #[test]
935    fn markdown_single_issue_no_plural() {
936        let root = PathBuf::from("/project");
937        let mut results = AnalysisResults::default();
938        results.unused_files.push(UnusedFile {
939            path: root.join("src/dead.ts"),
940        });
941        let md = build_markdown(&results, &root);
942        assert!(md.starts_with("## Fallow: 1 issue found\n"));
943    }
944
945    #[test]
946    fn markdown_type_only_dep_format() {
947        let root = PathBuf::from("/project");
948        let mut results = AnalysisResults::default();
949        results.type_only_dependencies.push(TypeOnlyDependency {
950            package_name: "zod".to_string(),
951            path: root.join("package.json"),
952            line: 8,
953        });
954        let md = build_markdown(&results, &root);
955        assert!(md.contains("### Type-only dependencies"));
956        assert!(md.contains("- `zod`"));
957    }
958
959    #[test]
960    fn markdown_escapes_backticks_in_export_names() {
961        let root = PathBuf::from("/project");
962        let mut results = AnalysisResults::default();
963        results.unused_exports.push(UnusedExport {
964            path: root.join("src/utils.ts"),
965            export_name: "foo`bar".to_string(),
966            is_type_only: false,
967            line: 1,
968            col: 0,
969            span_start: 0,
970            is_re_export: false,
971        });
972        let md = build_markdown(&results, &root);
973        assert!(md.contains("foo\\`bar"));
974        assert!(!md.contains("foo`bar`"));
975    }
976
977    #[test]
978    fn markdown_escapes_backticks_in_package_names() {
979        let root = PathBuf::from("/project");
980        let mut results = AnalysisResults::default();
981        results.unused_dependencies.push(UnusedDependency {
982            package_name: "pkg`name".to_string(),
983            location: DependencyLocation::Dependencies,
984            path: root.join("package.json"),
985            line: 5,
986        });
987        let md = build_markdown(&results, &root);
988        assert!(md.contains("pkg\\`name"));
989    }
990
991    // ── Duplication markdown ──
992
993    #[test]
994    fn duplication_markdown_empty() {
995        let report = DuplicationReport::default();
996        let root = PathBuf::from("/project");
997        let md = build_duplication_markdown(&report, &root);
998        assert_eq!(md, "## Fallow: no code duplication found\n");
999    }
1000
1001    #[test]
1002    fn duplication_markdown_contains_groups() {
1003        let root = PathBuf::from("/project");
1004        let report = DuplicationReport {
1005            clone_groups: vec![CloneGroup {
1006                instances: vec![
1007                    CloneInstance {
1008                        file: root.join("src/a.ts"),
1009                        start_line: 1,
1010                        end_line: 10,
1011                        start_col: 0,
1012                        end_col: 0,
1013                        fragment: String::new(),
1014                    },
1015                    CloneInstance {
1016                        file: root.join("src/b.ts"),
1017                        start_line: 5,
1018                        end_line: 14,
1019                        start_col: 0,
1020                        end_col: 0,
1021                        fragment: String::new(),
1022                    },
1023                ],
1024                token_count: 50,
1025                line_count: 10,
1026            }],
1027            clone_families: vec![],
1028            mirrored_directories: vec![],
1029            stats: DuplicationStats {
1030                total_files: 10,
1031                files_with_clones: 2,
1032                total_lines: 500,
1033                duplicated_lines: 20,
1034                total_tokens: 2500,
1035                duplicated_tokens: 100,
1036                clone_groups: 1,
1037                clone_instances: 2,
1038                duplication_percentage: 4.0,
1039            },
1040        };
1041        let md = build_duplication_markdown(&report, &root);
1042        assert!(md.contains("**Clone group 1**"));
1043        assert!(md.contains("`src/a.ts:1-10`"));
1044        assert!(md.contains("`src/b.ts:5-14`"));
1045        assert!(md.contains("4.0% duplication"));
1046    }
1047
1048    #[test]
1049    fn duplication_markdown_contains_families() {
1050        let root = PathBuf::from("/project");
1051        let report = DuplicationReport {
1052            clone_groups: vec![CloneGroup {
1053                instances: vec![CloneInstance {
1054                    file: root.join("src/a.ts"),
1055                    start_line: 1,
1056                    end_line: 5,
1057                    start_col: 0,
1058                    end_col: 0,
1059                    fragment: String::new(),
1060                }],
1061                token_count: 30,
1062                line_count: 5,
1063            }],
1064            clone_families: vec![CloneFamily {
1065                files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1066                groups: vec![],
1067                total_duplicated_lines: 20,
1068                total_duplicated_tokens: 100,
1069                suggestions: vec![RefactoringSuggestion {
1070                    kind: RefactoringKind::ExtractFunction,
1071                    description: "Extract shared utility function".to_string(),
1072                    estimated_savings: 15,
1073                }],
1074            }],
1075            mirrored_directories: vec![],
1076            stats: DuplicationStats {
1077                clone_groups: 1,
1078                clone_instances: 1,
1079                duplication_percentage: 2.0,
1080                ..Default::default()
1081            },
1082        };
1083        let md = build_duplication_markdown(&report, &root);
1084        assert!(md.contains("### Clone Families"));
1085        assert!(md.contains("**Family 1**"));
1086        assert!(md.contains("Extract shared utility function"));
1087        assert!(md.contains("~15 lines saved"));
1088    }
1089
1090    // ── Health markdown ──
1091
1092    #[test]
1093    fn health_markdown_empty_no_findings() {
1094        let root = PathBuf::from("/project");
1095        let report = crate::health_types::HealthReport {
1096            findings: vec![],
1097            summary: crate::health_types::HealthSummary {
1098                files_analyzed: 10,
1099                functions_analyzed: 50,
1100                functions_above_threshold: 0,
1101                max_cyclomatic_threshold: 20,
1102                max_cognitive_threshold: 15,
1103                files_scored: None,
1104                average_maintainability: None,
1105            },
1106            vital_signs: None,
1107            health_score: None,
1108            file_scores: vec![],
1109            hotspots: vec![],
1110            hotspot_summary: None,
1111            targets: vec![],
1112            target_thresholds: None,
1113            health_trend: None,
1114        };
1115        let md = build_health_markdown(&report, &root);
1116        assert!(md.contains("no functions exceed complexity thresholds"));
1117        assert!(md.contains("**50** functions analyzed"));
1118    }
1119
1120    #[test]
1121    fn health_markdown_table_format() {
1122        let root = PathBuf::from("/project");
1123        let report = crate::health_types::HealthReport {
1124            findings: vec![crate::health_types::HealthFinding {
1125                path: root.join("src/utils.ts"),
1126                name: "parseExpression".to_string(),
1127                line: 42,
1128                col: 0,
1129                cyclomatic: 25,
1130                cognitive: 30,
1131                line_count: 80,
1132                exceeded: crate::health_types::ExceededThreshold::Both,
1133            }],
1134            summary: crate::health_types::HealthSummary {
1135                files_analyzed: 10,
1136                functions_analyzed: 50,
1137                functions_above_threshold: 1,
1138                max_cyclomatic_threshold: 20,
1139                max_cognitive_threshold: 15,
1140                files_scored: None,
1141                average_maintainability: None,
1142            },
1143            vital_signs: None,
1144            health_score: None,
1145            file_scores: vec![],
1146            hotspots: vec![],
1147            hotspot_summary: None,
1148            targets: vec![],
1149            target_thresholds: None,
1150            health_trend: None,
1151        };
1152        let md = build_health_markdown(&report, &root);
1153        assert!(md.contains("## Fallow: 1 high complexity function\n"));
1154        assert!(md.contains("| File | Function |"));
1155        assert!(md.contains("`src/utils.ts:42`"));
1156        assert!(md.contains("`parseExpression`"));
1157        assert!(md.contains("25 **!**"));
1158        assert!(md.contains("30 **!**"));
1159        assert!(md.contains("| 80 |"));
1160    }
1161
1162    #[test]
1163    fn health_markdown_no_marker_when_below_threshold() {
1164        let root = PathBuf::from("/project");
1165        let report = crate::health_types::HealthReport {
1166            findings: vec![crate::health_types::HealthFinding {
1167                path: root.join("src/utils.ts"),
1168                name: "helper".to_string(),
1169                line: 10,
1170                col: 0,
1171                cyclomatic: 15,
1172                cognitive: 20,
1173                line_count: 30,
1174                exceeded: crate::health_types::ExceededThreshold::Cognitive,
1175            }],
1176            summary: crate::health_types::HealthSummary {
1177                files_analyzed: 5,
1178                functions_analyzed: 20,
1179                functions_above_threshold: 1,
1180                max_cyclomatic_threshold: 20,
1181                max_cognitive_threshold: 15,
1182                files_scored: None,
1183                average_maintainability: None,
1184            },
1185            vital_signs: None,
1186            health_score: None,
1187            file_scores: vec![],
1188            hotspots: vec![],
1189            hotspot_summary: None,
1190            targets: vec![],
1191            target_thresholds: None,
1192            health_trend: None,
1193        };
1194        let md = build_health_markdown(&report, &root);
1195        // Cyclomatic 15 is below threshold 20, no marker
1196        assert!(md.contains("| 15 |"));
1197        // Cognitive 20 exceeds threshold 15, has marker
1198        assert!(md.contains("20 **!**"));
1199    }
1200
1201    #[test]
1202    fn health_markdown_with_targets() {
1203        use crate::health_types::*;
1204
1205        let root = PathBuf::from("/project");
1206        let report = HealthReport {
1207            findings: vec![],
1208            summary: HealthSummary {
1209                files_analyzed: 10,
1210                functions_analyzed: 50,
1211                functions_above_threshold: 0,
1212                max_cyclomatic_threshold: 20,
1213                max_cognitive_threshold: 15,
1214                files_scored: None,
1215                average_maintainability: None,
1216            },
1217            vital_signs: None,
1218            health_score: None,
1219            file_scores: vec![],
1220            hotspots: vec![],
1221            hotspot_summary: None,
1222            targets: vec![
1223                RefactoringTarget {
1224                    path: PathBuf::from("/project/src/complex.ts"),
1225                    priority: 82.5,
1226                    efficiency: 27.5,
1227                    recommendation: "Split high-impact file".into(),
1228                    category: RecommendationCategory::SplitHighImpact,
1229                    effort: crate::health_types::EffortEstimate::High,
1230                    confidence: crate::health_types::Confidence::Medium,
1231                    factors: vec![ContributingFactor {
1232                        metric: "fan_in",
1233                        value: 25.0,
1234                        threshold: 10.0,
1235                        detail: "25 files depend on this".into(),
1236                    }],
1237                    evidence: None,
1238                },
1239                RefactoringTarget {
1240                    path: PathBuf::from("/project/src/legacy.ts"),
1241                    priority: 45.0,
1242                    efficiency: 45.0,
1243                    recommendation: "Remove 5 unused exports".into(),
1244                    category: RecommendationCategory::RemoveDeadCode,
1245                    effort: crate::health_types::EffortEstimate::Low,
1246                    confidence: crate::health_types::Confidence::High,
1247                    factors: vec![],
1248                    evidence: None,
1249                },
1250            ],
1251            target_thresholds: None,
1252            health_trend: None,
1253        };
1254        let md = build_health_markdown(&report, &root);
1255
1256        // Should have refactoring targets section
1257        assert!(
1258            md.contains("Refactoring Targets"),
1259            "should contain targets heading"
1260        );
1261        assert!(
1262            md.contains("src/complex.ts"),
1263            "should contain target file path"
1264        );
1265        assert!(md.contains("27.5"), "should contain efficiency score");
1266        assert!(
1267            md.contains("Split high-impact file"),
1268            "should contain recommendation"
1269        );
1270        assert!(md.contains("src/legacy.ts"), "should contain second target");
1271    }
1272
1273    // ── Dependency in workspace package ──
1274
1275    #[test]
1276    fn markdown_dep_in_workspace_shows_package_label() {
1277        let root = PathBuf::from("/project");
1278        let mut results = AnalysisResults::default();
1279        results.unused_dependencies.push(UnusedDependency {
1280            package_name: "lodash".to_string(),
1281            location: DependencyLocation::Dependencies,
1282            path: root.join("packages/core/package.json"),
1283            line: 5,
1284        });
1285        let md = build_markdown(&results, &root);
1286        // Non-root package.json should show the label
1287        assert!(md.contains("(packages/core/package.json)"));
1288    }
1289
1290    #[test]
1291    fn markdown_dep_at_root_no_extra_label() {
1292        let root = PathBuf::from("/project");
1293        let mut results = AnalysisResults::default();
1294        results.unused_dependencies.push(UnusedDependency {
1295            package_name: "lodash".to_string(),
1296            location: DependencyLocation::Dependencies,
1297            path: root.join("package.json"),
1298            line: 5,
1299        });
1300        let md = build_markdown(&results, &root);
1301        assert!(md.contains("- `lodash`"));
1302        assert!(!md.contains("(package.json)"));
1303    }
1304
1305    // ── Multiple exports same file grouped ──
1306
1307    #[test]
1308    fn markdown_exports_grouped_by_file() {
1309        let root = PathBuf::from("/project");
1310        let mut results = AnalysisResults::default();
1311        results.unused_exports.push(UnusedExport {
1312            path: root.join("src/utils.ts"),
1313            export_name: "alpha".to_string(),
1314            is_type_only: false,
1315            line: 5,
1316            col: 0,
1317            span_start: 0,
1318            is_re_export: false,
1319        });
1320        results.unused_exports.push(UnusedExport {
1321            path: root.join("src/utils.ts"),
1322            export_name: "beta".to_string(),
1323            is_type_only: false,
1324            line: 10,
1325            col: 0,
1326            span_start: 0,
1327            is_re_export: false,
1328        });
1329        results.unused_exports.push(UnusedExport {
1330            path: root.join("src/other.ts"),
1331            export_name: "gamma".to_string(),
1332            is_type_only: false,
1333            line: 1,
1334            col: 0,
1335            span_start: 0,
1336            is_re_export: false,
1337        });
1338        let md = build_markdown(&results, &root);
1339        // File header should appear only once for utils.ts
1340        let utils_count = md.matches("- `src/utils.ts`").count();
1341        assert_eq!(utils_count, 1, "file header should appear once per file");
1342        // Both exports should be under it as sub-items
1343        assert!(md.contains(":5 `alpha`"));
1344        assert!(md.contains(":10 `beta`"));
1345    }
1346
1347    // ── Multiple issues plural header ──
1348
1349    #[test]
1350    fn markdown_multiple_issues_plural() {
1351        let root = PathBuf::from("/project");
1352        let mut results = AnalysisResults::default();
1353        results.unused_files.push(UnusedFile {
1354            path: root.join("src/a.ts"),
1355        });
1356        results.unused_files.push(UnusedFile {
1357            path: root.join("src/b.ts"),
1358        });
1359        let md = build_markdown(&results, &root);
1360        assert!(md.starts_with("## Fallow: 2 issues found\n"));
1361    }
1362
1363    // ── Duplication markdown with zero estimated savings ──
1364
1365    #[test]
1366    fn duplication_markdown_zero_savings_no_suffix() {
1367        let root = PathBuf::from("/project");
1368        let report = DuplicationReport {
1369            clone_groups: vec![CloneGroup {
1370                instances: vec![CloneInstance {
1371                    file: root.join("src/a.ts"),
1372                    start_line: 1,
1373                    end_line: 5,
1374                    start_col: 0,
1375                    end_col: 0,
1376                    fragment: String::new(),
1377                }],
1378                token_count: 30,
1379                line_count: 5,
1380            }],
1381            clone_families: vec![CloneFamily {
1382                files: vec![root.join("src/a.ts")],
1383                groups: vec![],
1384                total_duplicated_lines: 5,
1385                total_duplicated_tokens: 30,
1386                suggestions: vec![RefactoringSuggestion {
1387                    kind: RefactoringKind::ExtractFunction,
1388                    description: "Extract function".to_string(),
1389                    estimated_savings: 0,
1390                }],
1391            }],
1392            mirrored_directories: vec![],
1393            stats: DuplicationStats {
1394                clone_groups: 1,
1395                clone_instances: 1,
1396                duplication_percentage: 1.0,
1397                ..Default::default()
1398            },
1399        };
1400        let md = build_duplication_markdown(&report, &root);
1401        assert!(md.contains("Extract function"));
1402        assert!(!md.contains("lines saved"));
1403    }
1404
1405    // ── Health markdown vital signs ──
1406
1407    #[test]
1408    fn health_markdown_vital_signs_table() {
1409        let root = PathBuf::from("/project");
1410        let report = crate::health_types::HealthReport {
1411            findings: vec![],
1412            summary: crate::health_types::HealthSummary {
1413                files_analyzed: 10,
1414                functions_analyzed: 50,
1415                functions_above_threshold: 0,
1416                max_cyclomatic_threshold: 20,
1417                max_cognitive_threshold: 15,
1418                files_scored: None,
1419                average_maintainability: None,
1420            },
1421            vital_signs: Some(crate::health_types::VitalSigns {
1422                avg_cyclomatic: 3.5,
1423                p90_cyclomatic: 12,
1424                dead_file_pct: Some(5.0),
1425                dead_export_pct: Some(10.2),
1426                duplication_pct: None,
1427                maintainability_avg: Some(72.3),
1428                hotspot_count: Some(3),
1429                circular_dep_count: Some(1),
1430                unused_dep_count: Some(2),
1431                counts: None,
1432            }),
1433            health_score: None,
1434            file_scores: vec![],
1435            hotspots: vec![],
1436            hotspot_summary: None,
1437            targets: vec![],
1438            target_thresholds: None,
1439            health_trend: None,
1440        };
1441        let md = build_health_markdown(&report, &root);
1442        assert!(md.contains("## Vital Signs"));
1443        assert!(md.contains("| Metric | Value |"));
1444        assert!(md.contains("| Avg Cyclomatic | 3.5 |"));
1445        assert!(md.contains("| P90 Cyclomatic | 12 |"));
1446        assert!(md.contains("| Dead Files | 5.0% |"));
1447        assert!(md.contains("| Dead Exports | 10.2% |"));
1448        assert!(md.contains("| Maintainability (avg) | 72.3 |"));
1449        assert!(md.contains("| Hotspots | 3 |"));
1450        assert!(md.contains("| Circular Deps | 1 |"));
1451        assert!(md.contains("| Unused Deps | 2 |"));
1452    }
1453
1454    // ── Health markdown file scores ──
1455
1456    #[test]
1457    fn health_markdown_file_scores_table() {
1458        let root = PathBuf::from("/project");
1459        let report = crate::health_types::HealthReport {
1460            findings: vec![crate::health_types::HealthFinding {
1461                path: root.join("src/dummy.ts"),
1462                name: "fn".to_string(),
1463                line: 1,
1464                col: 0,
1465                cyclomatic: 25,
1466                cognitive: 20,
1467                line_count: 50,
1468                exceeded: crate::health_types::ExceededThreshold::Both,
1469            }],
1470            summary: crate::health_types::HealthSummary {
1471                files_analyzed: 5,
1472                functions_analyzed: 10,
1473                functions_above_threshold: 1,
1474                max_cyclomatic_threshold: 20,
1475                max_cognitive_threshold: 15,
1476                files_scored: Some(1),
1477                average_maintainability: Some(65.0),
1478            },
1479            vital_signs: None,
1480            health_score: None,
1481            file_scores: vec![crate::health_types::FileHealthScore {
1482                path: root.join("src/utils.ts"),
1483                fan_in: 5,
1484                fan_out: 3,
1485                dead_code_ratio: 0.25,
1486                complexity_density: 0.8,
1487                maintainability_index: 72.5,
1488                total_cyclomatic: 40,
1489                total_cognitive: 30,
1490                function_count: 10,
1491                lines: 200,
1492            }],
1493            hotspots: vec![],
1494            hotspot_summary: None,
1495            targets: vec![],
1496            target_thresholds: None,
1497            health_trend: None,
1498        };
1499        let md = build_health_markdown(&report, &root);
1500        assert!(md.contains("### File Health Scores (1 files)"));
1501        assert!(md.contains("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density |"));
1502        assert!(md.contains("| `src/utils.ts` | 72.5 | 5 | 3 | 25% | 0.80 |"));
1503        assert!(md.contains("**Average maintainability index:** 65.0/100"));
1504    }
1505
1506    // ── Health markdown hotspots ──
1507
1508    #[test]
1509    fn health_markdown_hotspots_table() {
1510        let root = PathBuf::from("/project");
1511        let report = crate::health_types::HealthReport {
1512            findings: vec![crate::health_types::HealthFinding {
1513                path: root.join("src/dummy.ts"),
1514                name: "fn".to_string(),
1515                line: 1,
1516                col: 0,
1517                cyclomatic: 25,
1518                cognitive: 20,
1519                line_count: 50,
1520                exceeded: crate::health_types::ExceededThreshold::Both,
1521            }],
1522            summary: crate::health_types::HealthSummary {
1523                files_analyzed: 5,
1524                functions_analyzed: 10,
1525                functions_above_threshold: 1,
1526                max_cyclomatic_threshold: 20,
1527                max_cognitive_threshold: 15,
1528                files_scored: None,
1529                average_maintainability: None,
1530            },
1531            vital_signs: None,
1532            health_score: None,
1533            file_scores: vec![],
1534            hotspots: vec![crate::health_types::HotspotEntry {
1535                path: root.join("src/hot.ts"),
1536                score: 85.0,
1537                commits: 42,
1538                weighted_commits: 35.0,
1539                lines_added: 500,
1540                lines_deleted: 200,
1541                complexity_density: 1.2,
1542                fan_in: 10,
1543                trend: fallow_core::churn::ChurnTrend::Accelerating,
1544            }],
1545            hotspot_summary: Some(crate::health_types::HotspotSummary {
1546                since: "6 months".to_string(),
1547                min_commits: 3,
1548                files_analyzed: 50,
1549                files_excluded: 5,
1550                shallow_clone: false,
1551            }),
1552            targets: vec![],
1553            target_thresholds: None,
1554            health_trend: None,
1555        };
1556        let md = build_health_markdown(&report, &root);
1557        assert!(md.contains("### Hotspots (1 files, since 6 months)"));
1558        assert!(md.contains("| `src/hot.ts` | 85.0 | 42 | 700 | 1.20 | 10 | accelerating |"));
1559        assert!(md.contains("*5 files excluded (< 3 commits)*"));
1560    }
1561
1562    // ── Health markdown metric legend ──
1563
1564    #[test]
1565    fn health_markdown_metric_legend_with_scores() {
1566        let root = PathBuf::from("/project");
1567        let report = crate::health_types::HealthReport {
1568            findings: vec![crate::health_types::HealthFinding {
1569                path: root.join("src/x.ts"),
1570                name: "f".to_string(),
1571                line: 1,
1572                col: 0,
1573                cyclomatic: 25,
1574                cognitive: 20,
1575                line_count: 10,
1576                exceeded: crate::health_types::ExceededThreshold::Both,
1577            }],
1578            summary: crate::health_types::HealthSummary {
1579                files_analyzed: 1,
1580                functions_analyzed: 1,
1581                functions_above_threshold: 1,
1582                max_cyclomatic_threshold: 20,
1583                max_cognitive_threshold: 15,
1584                files_scored: Some(1),
1585                average_maintainability: Some(70.0),
1586            },
1587            vital_signs: None,
1588            health_score: None,
1589            file_scores: vec![crate::health_types::FileHealthScore {
1590                path: root.join("src/x.ts"),
1591                fan_in: 1,
1592                fan_out: 1,
1593                dead_code_ratio: 0.0,
1594                complexity_density: 0.5,
1595                maintainability_index: 80.0,
1596                total_cyclomatic: 10,
1597                total_cognitive: 8,
1598                function_count: 2,
1599                lines: 50,
1600            }],
1601            hotspots: vec![],
1602            hotspot_summary: None,
1603            targets: vec![],
1604            target_thresholds: None,
1605            health_trend: None,
1606        };
1607        let md = build_health_markdown(&report, &root);
1608        assert!(md.contains("<details><summary>Metric definitions</summary>"));
1609        assert!(md.contains("**MI** \u{2014} Maintainability Index"));
1610        assert!(md.contains("**Fan-in**"));
1611        assert!(md.contains("Full metric reference"));
1612    }
1613
1614    // ── Health markdown truncated findings ──
1615
1616    #[test]
1617    fn health_markdown_truncated_findings_shown_count() {
1618        let root = PathBuf::from("/project");
1619        let report = crate::health_types::HealthReport {
1620            findings: vec![crate::health_types::HealthFinding {
1621                path: root.join("src/x.ts"),
1622                name: "f".to_string(),
1623                line: 1,
1624                col: 0,
1625                cyclomatic: 25,
1626                cognitive: 20,
1627                line_count: 10,
1628                exceeded: crate::health_types::ExceededThreshold::Both,
1629            }],
1630            summary: crate::health_types::HealthSummary {
1631                files_analyzed: 10,
1632                functions_analyzed: 50,
1633                functions_above_threshold: 5, // 5 total but only 1 shown
1634                max_cyclomatic_threshold: 20,
1635                max_cognitive_threshold: 15,
1636                files_scored: None,
1637                average_maintainability: None,
1638            },
1639            vital_signs: None,
1640            health_score: None,
1641            file_scores: vec![],
1642            hotspots: vec![],
1643            hotspot_summary: None,
1644            targets: vec![],
1645            target_thresholds: None,
1646            health_trend: None,
1647        };
1648        let md = build_health_markdown(&report, &root);
1649        assert!(md.contains("5 high complexity functions (1 shown)"));
1650    }
1651
1652    // ── escape_backticks ──
1653
1654    #[test]
1655    fn escape_backticks_handles_multiple() {
1656        assert_eq!(escape_backticks("a`b`c"), "a\\`b\\`c");
1657    }
1658
1659    #[test]
1660    fn escape_backticks_no_backticks_unchanged() {
1661        assert_eq!(escape_backticks("hello"), "hello");
1662    }
1663
1664    // ── Unresolved import in markdown ──
1665
1666    #[test]
1667    fn markdown_unresolved_import_grouped_by_file() {
1668        let root = PathBuf::from("/project");
1669        let mut results = AnalysisResults::default();
1670        results.unresolved_imports.push(UnresolvedImport {
1671            path: root.join("src/app.ts"),
1672            specifier: "./missing".to_string(),
1673            line: 3,
1674            col: 0,
1675            specifier_col: 0,
1676        });
1677        let md = build_markdown(&results, &root);
1678        assert!(md.contains("### Unresolved imports (1)"));
1679        assert!(md.contains("- `src/app.ts`"));
1680        assert!(md.contains(":3 `./missing`"));
1681    }
1682
1683    // ── Markdown optional dep ──
1684
1685    #[test]
1686    fn markdown_unused_optional_dep() {
1687        let root = PathBuf::from("/project");
1688        let mut results = AnalysisResults::default();
1689        results.unused_optional_dependencies.push(UnusedDependency {
1690            package_name: "fsevents".to_string(),
1691            location: DependencyLocation::OptionalDependencies,
1692            path: root.join("package.json"),
1693            line: 12,
1694        });
1695        let md = build_markdown(&results, &root);
1696        assert!(md.contains("### Unused optionalDependencies (1)"));
1697        assert!(md.contains("- `fsevents`"));
1698    }
1699
1700    // ── Health markdown no hotspot exclusion message when 0 excluded ──
1701
1702    #[test]
1703    fn health_markdown_hotspots_no_excluded_message() {
1704        let root = PathBuf::from("/project");
1705        let report = crate::health_types::HealthReport {
1706            findings: vec![crate::health_types::HealthFinding {
1707                path: root.join("src/x.ts"),
1708                name: "f".to_string(),
1709                line: 1,
1710                col: 0,
1711                cyclomatic: 25,
1712                cognitive: 20,
1713                line_count: 10,
1714                exceeded: crate::health_types::ExceededThreshold::Both,
1715            }],
1716            summary: crate::health_types::HealthSummary {
1717                files_analyzed: 5,
1718                functions_analyzed: 10,
1719                functions_above_threshold: 1,
1720                max_cyclomatic_threshold: 20,
1721                max_cognitive_threshold: 15,
1722                files_scored: None,
1723                average_maintainability: None,
1724            },
1725            vital_signs: None,
1726            health_score: None,
1727            file_scores: vec![],
1728            hotspots: vec![crate::health_types::HotspotEntry {
1729                path: root.join("src/hot.ts"),
1730                score: 50.0,
1731                commits: 10,
1732                weighted_commits: 8.0,
1733                lines_added: 100,
1734                lines_deleted: 50,
1735                complexity_density: 0.5,
1736                fan_in: 3,
1737                trend: fallow_core::churn::ChurnTrend::Stable,
1738            }],
1739            hotspot_summary: Some(crate::health_types::HotspotSummary {
1740                since: "6 months".to_string(),
1741                min_commits: 3,
1742                files_analyzed: 50,
1743                files_excluded: 0,
1744                shallow_clone: false,
1745            }),
1746            targets: vec![],
1747            target_thresholds: None,
1748            health_trend: None,
1749        };
1750        let md = build_health_markdown(&report, &root);
1751        assert!(!md.contains("files excluded"));
1752    }
1753
1754    // ── Duplication markdown plural ──
1755
1756    #[test]
1757    fn duplication_markdown_single_group_no_plural() {
1758        let root = PathBuf::from("/project");
1759        let report = DuplicationReport {
1760            clone_groups: vec![CloneGroup {
1761                instances: vec![CloneInstance {
1762                    file: root.join("src/a.ts"),
1763                    start_line: 1,
1764                    end_line: 5,
1765                    start_col: 0,
1766                    end_col: 0,
1767                    fragment: String::new(),
1768                }],
1769                token_count: 30,
1770                line_count: 5,
1771            }],
1772            clone_families: vec![],
1773            mirrored_directories: vec![],
1774            stats: DuplicationStats {
1775                clone_groups: 1,
1776                clone_instances: 1,
1777                duplication_percentage: 2.0,
1778                ..Default::default()
1779            },
1780        };
1781        let md = build_duplication_markdown(&report, &root);
1782        assert!(md.contains("1 clone group found"));
1783        assert!(!md.contains("1 clone groups found"));
1784    }
1785}