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