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