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