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