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