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