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}