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