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