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, 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!(
35        out,
36        "## Fallow: {total} issue{} found\n\n",
37        if total == 1 { "" } else { "s" }
38    );
39
40    // ── Unused files ──
41    markdown_section(&mut out, &results.unused_files, "Unused files", |file| {
42        vec![format!("- `{}`", rel(&file.path))]
43    });
44
45    // ── Unused exports ──
46    markdown_grouped_section(
47        &mut out,
48        &results.unused_exports,
49        "Unused exports",
50        root,
51        |e| e.path.as_path(),
52        format_export,
53    );
54
55    // ── Unused types ──
56    markdown_grouped_section(
57        &mut out,
58        &results.unused_types,
59        "Unused type exports",
60        root,
61        |e| e.path.as_path(),
62        format_export,
63    );
64
65    // ── Unused dependencies ──
66    markdown_section(
67        &mut out,
68        &results.unused_dependencies,
69        "Unused dependencies",
70        |dep| format_dependency(&dep.package_name, &dep.path, root),
71    );
72
73    // ── Unused devDependencies ──
74    markdown_section(
75        &mut out,
76        &results.unused_dev_dependencies,
77        "Unused devDependencies",
78        |dep| format_dependency(&dep.package_name, &dep.path, root),
79    );
80
81    // ── Unused optionalDependencies ──
82    markdown_section(
83        &mut out,
84        &results.unused_optional_dependencies,
85        "Unused optionalDependencies",
86        |dep| format_dependency(&dep.package_name, &dep.path, root),
87    );
88
89    // ── Unused enum members ──
90    markdown_grouped_section(
91        &mut out,
92        &results.unused_enum_members,
93        "Unused enum members",
94        root,
95        |m| m.path.as_path(),
96        format_member,
97    );
98
99    // ── Unused class members ──
100    markdown_grouped_section(
101        &mut out,
102        &results.unused_class_members,
103        "Unused class members",
104        root,
105        |m| m.path.as_path(),
106        format_member,
107    );
108
109    // ── Unresolved imports ──
110    markdown_grouped_section(
111        &mut out,
112        &results.unresolved_imports,
113        "Unresolved imports",
114        root,
115        |i| i.path.as_path(),
116        |i| format!(":{} `{}`", i.line, escape_backticks(&i.specifier)),
117    );
118
119    // ── Unlisted dependencies ──
120    markdown_section(
121        &mut out,
122        &results.unlisted_dependencies,
123        "Unlisted dependencies",
124        |dep| vec![format!("- `{}`", escape_backticks(&dep.package_name))],
125    );
126
127    // ── Duplicate exports ──
128    markdown_section(
129        &mut out,
130        &results.duplicate_exports,
131        "Duplicate exports",
132        |dup| {
133            let locations: Vec<String> = dup
134                .locations
135                .iter()
136                .map(|loc| format!("`{}`", rel(&loc.path)))
137                .collect();
138            vec![format!(
139                "- `{}` in {}",
140                escape_backticks(&dup.export_name),
141                locations.join(", ")
142            )]
143        },
144    );
145
146    // ── Type-only dependencies ──
147    markdown_section(
148        &mut out,
149        &results.type_only_dependencies,
150        "Type-only dependencies (consider moving to devDependencies)",
151        |dep| format_dependency(&dep.package_name, &dep.path, root),
152    );
153
154    // ── Circular dependencies ──
155    markdown_section(
156        &mut out,
157        &results.circular_dependencies,
158        "Circular dependencies",
159        |cycle| {
160            let chain: Vec<String> = cycle.files.iter().map(|p| rel(p)).collect();
161            let mut display_chain = chain.clone();
162            if let Some(first) = chain.first() {
163                display_chain.push(first.clone());
164            }
165            vec![format!(
166                "- {}",
167                display_chain
168                    .iter()
169                    .map(|s| format!("`{s}`"))
170                    .collect::<Vec<_>>()
171                    .join(" \u{2192} ")
172            )]
173        },
174    );
175
176    out
177}
178
179fn format_export(e: &UnusedExport) -> String {
180    let re = if e.is_re_export { " (re-export)" } else { "" };
181    format!(":{} `{}`{re}", e.line, escape_backticks(&e.export_name))
182}
183
184fn format_member(m: &UnusedMember) -> String {
185    format!(
186        ":{} `{}.{}`",
187        m.line,
188        escape_backticks(&m.parent_name),
189        escape_backticks(&m.member_name)
190    )
191}
192
193fn format_dependency(dep_name: &str, pkg_path: &Path, root: &Path) -> Vec<String> {
194    let name = escape_backticks(dep_name);
195    let pkg_label = relative_path(pkg_path, root).display().to_string();
196    if pkg_label == "package.json" {
197        vec![format!("- `{name}`")]
198    } else {
199        let label = escape_backticks(&pkg_label);
200        vec![format!("- `{name}` ({label})")]
201    }
202}
203
204/// Emit a markdown section with a header and per-item lines. Skipped if empty.
205fn markdown_section<T>(
206    out: &mut String,
207    items: &[T],
208    title: &str,
209    format_lines: impl Fn(&T) -> Vec<String>,
210) {
211    if items.is_empty() {
212        return;
213    }
214    let _ = write!(out, "### {title} ({})\n\n", items.len());
215    for item in items {
216        for line in format_lines(item) {
217            out.push_str(&line);
218            out.push('\n');
219        }
220    }
221    out.push('\n');
222}
223
224/// Emit a markdown section whose items are grouped by file path.
225fn markdown_grouped_section<'a, T>(
226    out: &mut String,
227    items: &'a [T],
228    title: &str,
229    root: &Path,
230    get_path: impl Fn(&'a T) -> &'a Path,
231    format_detail: impl Fn(&T) -> String,
232) {
233    if items.is_empty() {
234        return;
235    }
236    let _ = write!(out, "### {title} ({})\n\n", items.len());
237
238    let mut indices: Vec<usize> = (0..items.len()).collect();
239    indices.sort_by(|&a, &b| get_path(&items[a]).cmp(get_path(&items[b])));
240
241    let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
242    let mut last_file = String::new();
243    for &i in &indices {
244        let item = &items[i];
245        let file_str = rel(get_path(item));
246        if file_str != last_file {
247            let _ = writeln!(out, "- `{file_str}`");
248            last_file = file_str;
249        }
250        let _ = writeln!(out, "  - {}", format_detail(item));
251    }
252    out.push('\n');
253}
254
255// ── Duplication markdown output ──────────────────────────────────
256
257pub(super) fn print_duplication_markdown(report: &DuplicationReport, root: &Path) {
258    println!("{}", build_duplication_markdown(report, root));
259}
260
261/// Build markdown output for duplication results.
262pub fn build_duplication_markdown(report: &DuplicationReport, root: &Path) -> String {
263    let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
264
265    let mut out = String::new();
266
267    if report.clone_groups.is_empty() {
268        out.push_str("## Fallow: no code duplication found\n");
269        return out;
270    }
271
272    let stats = &report.stats;
273    let _ = write!(
274        out,
275        "## Fallow: {} clone group{} found ({:.1}% duplication)\n\n",
276        stats.clone_groups,
277        if stats.clone_groups == 1 { "" } else { "s" },
278        stats.duplication_percentage,
279    );
280
281    out.push_str("### Duplicates\n\n");
282    for (i, group) in report.clone_groups.iter().enumerate() {
283        let instance_count = group.instances.len();
284        let _ = write!(
285            out,
286            "**Clone group {}** ({} lines, {instance_count} instance{})\n\n",
287            i + 1,
288            group.line_count,
289            if instance_count == 1 { "" } else { "s" }
290        );
291        for instance in &group.instances {
292            let relative = rel(&instance.file);
293            let _ = writeln!(
294                out,
295                "- `{relative}:{}-{}`",
296                instance.start_line, instance.end_line
297            );
298        }
299        out.push('\n');
300    }
301
302    // Clone families
303    if !report.clone_families.is_empty() {
304        out.push_str("### Clone Families\n\n");
305        for (i, family) in report.clone_families.iter().enumerate() {
306            let file_names: Vec<_> = family.files.iter().map(|f| rel(f)).collect();
307            let _ = write!(
308                out,
309                "**Family {}** ({} group{}, {} lines across {})\n\n",
310                i + 1,
311                family.groups.len(),
312                if family.groups.len() == 1 { "" } else { "s" },
313                family.total_duplicated_lines,
314                file_names
315                    .iter()
316                    .map(|s| format!("`{s}`"))
317                    .collect::<Vec<_>>()
318                    .join(", "),
319            );
320            for suggestion in &family.suggestions {
321                let savings = if suggestion.estimated_savings > 0 {
322                    format!(" (~{} lines saved)", suggestion.estimated_savings)
323                } else {
324                    String::new()
325                };
326                let _ = writeln!(out, "- {}{savings}", suggestion.description);
327            }
328            out.push('\n');
329        }
330    }
331
332    // Summary line
333    let _ = writeln!(
334        out,
335        "**Summary:** {} duplicated lines ({:.1}%) across {} file{}",
336        stats.duplicated_lines,
337        stats.duplication_percentage,
338        stats.files_with_clones,
339        if stats.files_with_clones == 1 {
340            ""
341        } else {
342            "s"
343        },
344    );
345
346    out
347}
348
349// ── Health markdown output ──────────────────────────────────────────
350
351pub(super) fn print_health_markdown(report: &crate::health_types::HealthReport, root: &Path) {
352    println!("{}", build_health_markdown(report, root));
353}
354
355/// Build markdown output for health (complexity) results.
356pub fn build_health_markdown(report: &crate::health_types::HealthReport, root: &Path) -> String {
357    let rel = |p: &Path| {
358        escape_backticks(&normalize_uri(
359            &relative_path(p, root).display().to_string(),
360        ))
361    };
362
363    let mut out = String::new();
364
365    // Vital signs summary table
366    if let Some(ref vs) = report.vital_signs {
367        out.push_str("## Vital Signs\n\n");
368        out.push_str("| Metric | Value |\n");
369        out.push_str("|:-------|------:|\n");
370        let _ = writeln!(out, "| Avg Cyclomatic | {:.1} |", vs.avg_cyclomatic);
371        let _ = writeln!(out, "| P90 Cyclomatic | {} |", vs.p90_cyclomatic);
372        if let Some(v) = vs.dead_file_pct {
373            let _ = writeln!(out, "| Dead Files | {v:.1}% |");
374        }
375        if let Some(v) = vs.dead_export_pct {
376            let _ = writeln!(out, "| Dead Exports | {v:.1}% |");
377        }
378        if let Some(v) = vs.maintainability_avg {
379            let _ = writeln!(out, "| Maintainability (avg) | {v:.1} |");
380        }
381        if let Some(v) = vs.hotspot_count {
382            let _ = writeln!(out, "| Hotspots | {v} |");
383        }
384        if let Some(v) = vs.circular_dep_count {
385            let _ = writeln!(out, "| Circular Deps | {v} |");
386        }
387        if let Some(v) = vs.unused_dep_count {
388            let _ = writeln!(out, "| Unused Deps | {v} |");
389        }
390        out.push('\n');
391    }
392
393    if report.findings.is_empty()
394        && report.file_scores.is_empty()
395        && report.hotspots.is_empty()
396        && report.targets.is_empty()
397    {
398        if report.vital_signs.is_none() {
399            let _ = write!(
400                out,
401                "## Fallow: no functions exceed complexity thresholds\n\n\
402                 **{}** functions analyzed (max cyclomatic: {}, max cognitive: {})\n",
403                report.summary.functions_analyzed,
404                report.summary.max_cyclomatic_threshold,
405                report.summary.max_cognitive_threshold,
406            );
407        }
408        return out;
409    }
410
411    if !report.findings.is_empty() {
412        let count = report.summary.functions_above_threshold;
413        let shown = report.findings.len();
414        if shown < count {
415            let _ = write!(
416                out,
417                "## Fallow: {count} high complexity function{} ({shown} shown)\n\n",
418                if count == 1 { "" } else { "s" },
419            );
420        } else {
421            let _ = write!(
422                out,
423                "## Fallow: {count} high complexity function{}\n\n",
424                if count == 1 { "" } else { "s" },
425            );
426        }
427
428        out.push_str("| File | Function | Cyclomatic | Cognitive | Lines |\n");
429        out.push_str("|:-----|:---------|:-----------|:----------|:------|\n");
430
431        for finding in &report.findings {
432            let file_str = rel(&finding.path);
433            let cyc_marker = if finding.cyclomatic > report.summary.max_cyclomatic_threshold {
434                " **!**"
435            } else {
436                ""
437            };
438            let cog_marker = if finding.cognitive > report.summary.max_cognitive_threshold {
439                " **!**"
440            } else {
441                ""
442            };
443            let _ = writeln!(
444                out,
445                "| `{file_str}:{line}` | `{name}` | {cyc}{cyc_marker} | {cog}{cog_marker} | {lines} |",
446                line = finding.line,
447                name = escape_backticks(&finding.name),
448                cyc = finding.cyclomatic,
449                cog = finding.cognitive,
450                lines = finding.line_count,
451            );
452        }
453
454        let s = &report.summary;
455        let _ = write!(
456            out,
457            "\n**{files}** files, **{funcs}** functions analyzed \
458             (thresholds: cyclomatic > {cyc}, cognitive > {cog})\n",
459            files = s.files_analyzed,
460            funcs = s.functions_analyzed,
461            cyc = s.max_cyclomatic_threshold,
462            cog = s.max_cognitive_threshold,
463        );
464    }
465
466    // File health scores table
467    if !report.file_scores.is_empty() {
468        out.push('\n');
469        let _ = writeln!(
470            out,
471            "### File Health Scores ({} files)\n",
472            report.file_scores.len(),
473        );
474        out.push_str("| File | MI | Fan-in | Fan-out | Dead Code | Density |\n");
475        out.push_str("|:-----|:---|:-------|:--------|:----------|:--------|\n");
476
477        for score in &report.file_scores {
478            let file_str = rel(&score.path);
479            let _ = writeln!(
480                out,
481                "| `{file_str}` | {mi:.1} | {fi} | {fan_out} | {dead:.0}% | {density:.2} |",
482                mi = score.maintainability_index,
483                fi = score.fan_in,
484                fan_out = score.fan_out,
485                dead = score.dead_code_ratio * 100.0,
486                density = score.complexity_density,
487            );
488        }
489
490        if let Some(avg) = report.summary.average_maintainability {
491            let _ = write!(out, "\n**Average maintainability index:** {avg:.1}/100\n");
492        }
493    }
494
495    // Hotspot table
496    if !report.hotspots.is_empty() {
497        out.push('\n');
498        let header = if let Some(ref summary) = report.hotspot_summary {
499            format!(
500                "### Hotspots ({} files, since {})\n",
501                report.hotspots.len(),
502                summary.since,
503            )
504        } else {
505            format!("### Hotspots ({} files)\n", report.hotspots.len())
506        };
507        let _ = writeln!(out, "{header}");
508        out.push_str("| File | Score | Commits | Churn | Density | Fan-in | Trend |\n");
509        out.push_str("|:-----|:------|:--------|:------|:--------|:-------|:------|\n");
510
511        for entry in &report.hotspots {
512            let file_str = rel(&entry.path);
513            let _ = writeln!(
514                out,
515                "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} |",
516                score = entry.score,
517                commits = entry.commits,
518                churn = entry.lines_added + entry.lines_deleted,
519                density = entry.complexity_density,
520                fi = entry.fan_in,
521                trend = entry.trend,
522            );
523        }
524
525        if let Some(ref summary) = report.hotspot_summary
526            && summary.files_excluded > 0
527        {
528            let _ = write!(
529                out,
530                "\n*{} file{} excluded (< {} commits)*\n",
531                summary.files_excluded,
532                if summary.files_excluded == 1 { "" } else { "s" },
533                summary.min_commits,
534            );
535        }
536    }
537
538    // Refactoring targets
539    if !report.targets.is_empty() {
540        let _ = write!(
541            out,
542            "\n### Refactoring Targets ({})\n\n",
543            report.targets.len()
544        );
545        out.push_str("| Efficiency | Category | Effort / Confidence | File | Recommendation |\n");
546        out.push_str("|:-----------|:---------|:--------------------|:-----|:---------------|\n");
547        for target in &report.targets {
548            let file_str = normalize_uri(&relative_path(&target.path, root).display().to_string());
549            let category = target.category.label();
550            let effort = target.effort.label();
551            let confidence = target.confidence.label();
552            let _ = writeln!(
553                out,
554                "| {:.1} | {category} | {effort} / {confidence} | `{file_str}` | {} |",
555                target.efficiency, target.recommendation,
556            );
557        }
558    }
559
560    // Metric legend — explains abbreviations used in the tables above
561    let has_scores = !report.file_scores.is_empty();
562    let has_hotspots = !report.hotspots.is_empty();
563    let has_targets = !report.targets.is_empty();
564    if has_scores || has_hotspots || has_targets {
565        out.push_str("\n---\n\n<details><summary>Metric definitions</summary>\n\n");
566        if has_scores {
567            out.push_str("- **MI** — Maintainability Index (0\u{2013}100, higher is better)\n");
568            out.push_str("- **Fan-in** — files that import this file (blast radius)\n");
569            out.push_str("- **Fan-out** — files this file imports (coupling)\n");
570            out.push_str("- **Dead Code** — % of value exports with zero references\n");
571            out.push_str("- **Density** — cyclomatic complexity / lines of code\n");
572        }
573        if has_hotspots {
574            out.push_str(
575                "- **Score** — churn \u{00d7} complexity (0\u{2013}100, higher = riskier)\n",
576            );
577            out.push_str("- **Commits** — commits in the analysis window\n");
578            out.push_str("- **Churn** — total lines added + deleted\n");
579            out.push_str("- **Trend** — accelerating / stable / cooling\n");
580        }
581        if has_targets {
582            out.push_str("- **Efficiency** — priority / effort (higher = better quick-win value, default sort)\n");
583            out.push_str("- **Category** — recommendation type (churn+complexity, high impact, dead code, complexity, coupling, circular dep)\n");
584            out.push_str("- **Effort** — estimated effort (low / medium / high) based on file size, function count, and fan-in\n");
585            out.push_str("- **Confidence** — recommendation reliability (high = deterministic analysis, medium = heuristic, low = git-dependent)\n");
586        }
587        out.push_str("\n[Full metric reference](https://docs.fallow.tools/explanations/metrics)\n\n</details>\n");
588    }
589
590    out
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596    use fallow_core::duplicates::{
597        CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
598        RefactoringKind, RefactoringSuggestion,
599    };
600    use fallow_core::extract::MemberKind;
601    use fallow_core::results::*;
602    use std::path::PathBuf;
603
604    /// Helper: build an `AnalysisResults` populated with one issue of every type.
605    fn sample_results(root: &Path) -> AnalysisResults {
606        let mut r = AnalysisResults::default();
607
608        r.unused_files.push(UnusedFile {
609            path: root.join("src/dead.ts"),
610        });
611        r.unused_exports.push(UnusedExport {
612            path: root.join("src/utils.ts"),
613            export_name: "helperFn".to_string(),
614            is_type_only: false,
615            line: 10,
616            col: 4,
617            span_start: 120,
618            is_re_export: false,
619        });
620        r.unused_types.push(UnusedExport {
621            path: root.join("src/types.ts"),
622            export_name: "OldType".to_string(),
623            is_type_only: true,
624            line: 5,
625            col: 0,
626            span_start: 60,
627            is_re_export: false,
628        });
629        r.unused_dependencies.push(UnusedDependency {
630            package_name: "lodash".to_string(),
631            location: DependencyLocation::Dependencies,
632            path: root.join("package.json"),
633            line: 5,
634        });
635        r.unused_dev_dependencies.push(UnusedDependency {
636            package_name: "jest".to_string(),
637            location: DependencyLocation::DevDependencies,
638            path: root.join("package.json"),
639            line: 5,
640        });
641        r.unused_enum_members.push(UnusedMember {
642            path: root.join("src/enums.ts"),
643            parent_name: "Status".to_string(),
644            member_name: "Deprecated".to_string(),
645            kind: MemberKind::EnumMember,
646            line: 8,
647            col: 2,
648        });
649        r.unused_class_members.push(UnusedMember {
650            path: root.join("src/service.ts"),
651            parent_name: "UserService".to_string(),
652            member_name: "legacyMethod".to_string(),
653            kind: MemberKind::ClassMethod,
654            line: 42,
655            col: 4,
656        });
657        r.unresolved_imports.push(UnresolvedImport {
658            path: root.join("src/app.ts"),
659            specifier: "./missing-module".to_string(),
660            line: 3,
661            col: 0,
662            specifier_col: 0,
663        });
664        r.unlisted_dependencies.push(UnlistedDependency {
665            package_name: "chalk".to_string(),
666            imported_from: vec![ImportSite {
667                path: root.join("src/cli.ts"),
668                line: 2,
669                col: 0,
670            }],
671        });
672        r.duplicate_exports.push(DuplicateExport {
673            export_name: "Config".to_string(),
674            locations: vec![
675                DuplicateLocation {
676                    path: root.join("src/config.ts"),
677                    line: 15,
678                    col: 0,
679                },
680                DuplicateLocation {
681                    path: root.join("src/types.ts"),
682                    line: 30,
683                    col: 0,
684                },
685            ],
686        });
687        r.type_only_dependencies.push(TypeOnlyDependency {
688            package_name: "zod".to_string(),
689            path: root.join("package.json"),
690            line: 8,
691        });
692        r.circular_dependencies.push(CircularDependency {
693            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
694            length: 2,
695            line: 3,
696            col: 0,
697        });
698
699        r
700    }
701
702    #[test]
703    fn markdown_empty_results_no_issues() {
704        let root = PathBuf::from("/project");
705        let results = AnalysisResults::default();
706        let md = build_markdown(&results, &root);
707        assert_eq!(md, "## Fallow: no issues found\n");
708    }
709
710    #[test]
711    fn markdown_contains_header_with_count() {
712        let root = PathBuf::from("/project");
713        let results = sample_results(&root);
714        let md = build_markdown(&results, &root);
715        assert!(md.starts_with(&format!(
716            "## Fallow: {} issues found\n",
717            results.total_issues()
718        )));
719    }
720
721    #[test]
722    fn markdown_contains_all_sections() {
723        let root = PathBuf::from("/project");
724        let results = sample_results(&root);
725        let md = build_markdown(&results, &root);
726
727        assert!(md.contains("### Unused files (1)"));
728        assert!(md.contains("### Unused exports (1)"));
729        assert!(md.contains("### Unused type exports (1)"));
730        assert!(md.contains("### Unused dependencies (1)"));
731        assert!(md.contains("### Unused devDependencies (1)"));
732        assert!(md.contains("### Unused enum members (1)"));
733        assert!(md.contains("### Unused class members (1)"));
734        assert!(md.contains("### Unresolved imports (1)"));
735        assert!(md.contains("### Unlisted dependencies (1)"));
736        assert!(md.contains("### Duplicate exports (1)"));
737        assert!(md.contains("### Type-only dependencies"));
738        assert!(md.contains("### Circular dependencies (1)"));
739    }
740
741    #[test]
742    fn markdown_unused_file_format() {
743        let root = PathBuf::from("/project");
744        let mut results = AnalysisResults::default();
745        results.unused_files.push(UnusedFile {
746            path: root.join("src/dead.ts"),
747        });
748        let md = build_markdown(&results, &root);
749        assert!(md.contains("- `src/dead.ts`"));
750    }
751
752    #[test]
753    fn markdown_unused_export_grouped_by_file() {
754        let root = PathBuf::from("/project");
755        let mut results = AnalysisResults::default();
756        results.unused_exports.push(UnusedExport {
757            path: root.join("src/utils.ts"),
758            export_name: "helperFn".to_string(),
759            is_type_only: false,
760            line: 10,
761            col: 4,
762            span_start: 120,
763            is_re_export: false,
764        });
765        let md = build_markdown(&results, &root);
766        assert!(md.contains("- `src/utils.ts`"));
767        assert!(md.contains(":10 `helperFn`"));
768    }
769
770    #[test]
771    fn markdown_re_export_tagged() {
772        let root = PathBuf::from("/project");
773        let mut results = AnalysisResults::default();
774        results.unused_exports.push(UnusedExport {
775            path: root.join("src/index.ts"),
776            export_name: "reExported".to_string(),
777            is_type_only: false,
778            line: 1,
779            col: 0,
780            span_start: 0,
781            is_re_export: true,
782        });
783        let md = build_markdown(&results, &root);
784        assert!(md.contains("(re-export)"));
785    }
786
787    #[test]
788    fn markdown_unused_dep_format() {
789        let root = PathBuf::from("/project");
790        let mut results = AnalysisResults::default();
791        results.unused_dependencies.push(UnusedDependency {
792            package_name: "lodash".to_string(),
793            location: DependencyLocation::Dependencies,
794            path: root.join("package.json"),
795            line: 5,
796        });
797        let md = build_markdown(&results, &root);
798        assert!(md.contains("- `lodash`"));
799    }
800
801    #[test]
802    fn markdown_circular_dep_format() {
803        let root = PathBuf::from("/project");
804        let mut results = AnalysisResults::default();
805        results.circular_dependencies.push(CircularDependency {
806            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
807            length: 2,
808            line: 3,
809            col: 0,
810        });
811        let md = build_markdown(&results, &root);
812        assert!(md.contains("`src/a.ts`"));
813        assert!(md.contains("`src/b.ts`"));
814        assert!(md.contains("\u{2192}"));
815    }
816
817    #[test]
818    fn markdown_strips_root_prefix() {
819        let root = PathBuf::from("/project");
820        let mut results = AnalysisResults::default();
821        results.unused_files.push(UnusedFile {
822            path: PathBuf::from("/project/src/deep/nested/file.ts"),
823        });
824        let md = build_markdown(&results, &root);
825        assert!(md.contains("`src/deep/nested/file.ts`"));
826        assert!(!md.contains("/project/"));
827    }
828
829    #[test]
830    fn markdown_single_issue_no_plural() {
831        let root = PathBuf::from("/project");
832        let mut results = AnalysisResults::default();
833        results.unused_files.push(UnusedFile {
834            path: root.join("src/dead.ts"),
835        });
836        let md = build_markdown(&results, &root);
837        assert!(md.starts_with("## Fallow: 1 issue found\n"));
838    }
839
840    #[test]
841    fn markdown_type_only_dep_format() {
842        let root = PathBuf::from("/project");
843        let mut results = AnalysisResults::default();
844        results.type_only_dependencies.push(TypeOnlyDependency {
845            package_name: "zod".to_string(),
846            path: root.join("package.json"),
847            line: 8,
848        });
849        let md = build_markdown(&results, &root);
850        assert!(md.contains("### Type-only dependencies"));
851        assert!(md.contains("- `zod`"));
852    }
853
854    #[test]
855    fn markdown_escapes_backticks_in_export_names() {
856        let root = PathBuf::from("/project");
857        let mut results = AnalysisResults::default();
858        results.unused_exports.push(UnusedExport {
859            path: root.join("src/utils.ts"),
860            export_name: "foo`bar".to_string(),
861            is_type_only: false,
862            line: 1,
863            col: 0,
864            span_start: 0,
865            is_re_export: false,
866        });
867        let md = build_markdown(&results, &root);
868        assert!(md.contains("foo\\`bar"));
869        assert!(!md.contains("foo`bar`"));
870    }
871
872    #[test]
873    fn markdown_escapes_backticks_in_package_names() {
874        let root = PathBuf::from("/project");
875        let mut results = AnalysisResults::default();
876        results.unused_dependencies.push(UnusedDependency {
877            package_name: "pkg`name".to_string(),
878            location: DependencyLocation::Dependencies,
879            path: root.join("package.json"),
880            line: 5,
881        });
882        let md = build_markdown(&results, &root);
883        assert!(md.contains("pkg\\`name"));
884    }
885
886    // ── Duplication markdown ──
887
888    #[test]
889    fn duplication_markdown_empty() {
890        let report = DuplicationReport::default();
891        let root = PathBuf::from("/project");
892        let md = build_duplication_markdown(&report, &root);
893        assert_eq!(md, "## Fallow: no code duplication found\n");
894    }
895
896    #[test]
897    fn duplication_markdown_contains_groups() {
898        let root = PathBuf::from("/project");
899        let report = DuplicationReport {
900            clone_groups: vec![CloneGroup {
901                instances: vec![
902                    CloneInstance {
903                        file: root.join("src/a.ts"),
904                        start_line: 1,
905                        end_line: 10,
906                        start_col: 0,
907                        end_col: 0,
908                        fragment: String::new(),
909                    },
910                    CloneInstance {
911                        file: root.join("src/b.ts"),
912                        start_line: 5,
913                        end_line: 14,
914                        start_col: 0,
915                        end_col: 0,
916                        fragment: String::new(),
917                    },
918                ],
919                token_count: 50,
920                line_count: 10,
921            }],
922            clone_families: vec![],
923            stats: DuplicationStats {
924                total_files: 10,
925                files_with_clones: 2,
926                total_lines: 500,
927                duplicated_lines: 20,
928                total_tokens: 2500,
929                duplicated_tokens: 100,
930                clone_groups: 1,
931                clone_instances: 2,
932                duplication_percentage: 4.0,
933            },
934        };
935        let md = build_duplication_markdown(&report, &root);
936        assert!(md.contains("**Clone group 1**"));
937        assert!(md.contains("`src/a.ts:1-10`"));
938        assert!(md.contains("`src/b.ts:5-14`"));
939        assert!(md.contains("4.0% duplication"));
940    }
941
942    #[test]
943    fn duplication_markdown_contains_families() {
944        let root = PathBuf::from("/project");
945        let report = DuplicationReport {
946            clone_groups: vec![CloneGroup {
947                instances: vec![CloneInstance {
948                    file: root.join("src/a.ts"),
949                    start_line: 1,
950                    end_line: 5,
951                    start_col: 0,
952                    end_col: 0,
953                    fragment: String::new(),
954                }],
955                token_count: 30,
956                line_count: 5,
957            }],
958            clone_families: vec![CloneFamily {
959                files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
960                groups: vec![],
961                total_duplicated_lines: 20,
962                total_duplicated_tokens: 100,
963                suggestions: vec![RefactoringSuggestion {
964                    kind: RefactoringKind::ExtractFunction,
965                    description: "Extract shared utility function".to_string(),
966                    estimated_savings: 15,
967                }],
968            }],
969            stats: DuplicationStats {
970                clone_groups: 1,
971                clone_instances: 1,
972                duplication_percentage: 2.0,
973                ..Default::default()
974            },
975        };
976        let md = build_duplication_markdown(&report, &root);
977        assert!(md.contains("### Clone Families"));
978        assert!(md.contains("**Family 1**"));
979        assert!(md.contains("Extract shared utility function"));
980        assert!(md.contains("~15 lines saved"));
981    }
982
983    // ── Health markdown ──
984
985    #[test]
986    fn health_markdown_empty_no_findings() {
987        let root = PathBuf::from("/project");
988        let report = crate::health_types::HealthReport {
989            findings: vec![],
990            summary: crate::health_types::HealthSummary {
991                files_analyzed: 10,
992                functions_analyzed: 50,
993                functions_above_threshold: 0,
994                max_cyclomatic_threshold: 20,
995                max_cognitive_threshold: 15,
996                files_scored: None,
997                average_maintainability: None,
998            },
999            vital_signs: None,
1000            file_scores: vec![],
1001            hotspots: vec![],
1002            hotspot_summary: None,
1003            targets: vec![],
1004            target_thresholds: None,
1005        };
1006        let md = build_health_markdown(&report, &root);
1007        assert!(md.contains("no functions exceed complexity thresholds"));
1008        assert!(md.contains("**50** functions analyzed"));
1009    }
1010
1011    #[test]
1012    fn health_markdown_table_format() {
1013        let root = PathBuf::from("/project");
1014        let report = crate::health_types::HealthReport {
1015            findings: vec![crate::health_types::HealthFinding {
1016                path: root.join("src/utils.ts"),
1017                name: "parseExpression".to_string(),
1018                line: 42,
1019                col: 0,
1020                cyclomatic: 25,
1021                cognitive: 30,
1022                line_count: 80,
1023                exceeded: crate::health_types::ExceededThreshold::Both,
1024            }],
1025            summary: crate::health_types::HealthSummary {
1026                files_analyzed: 10,
1027                functions_analyzed: 50,
1028                functions_above_threshold: 1,
1029                max_cyclomatic_threshold: 20,
1030                max_cognitive_threshold: 15,
1031                files_scored: None,
1032                average_maintainability: None,
1033            },
1034            vital_signs: None,
1035            file_scores: vec![],
1036            hotspots: vec![],
1037            hotspot_summary: None,
1038            targets: vec![],
1039            target_thresholds: None,
1040        };
1041        let md = build_health_markdown(&report, &root);
1042        assert!(md.contains("## Fallow: 1 high complexity function\n"));
1043        assert!(md.contains("| File | Function |"));
1044        assert!(md.contains("`src/utils.ts:42`"));
1045        assert!(md.contains("`parseExpression`"));
1046        assert!(md.contains("25 **!**"));
1047        assert!(md.contains("30 **!**"));
1048        assert!(md.contains("| 80 |"));
1049    }
1050
1051    #[test]
1052    fn health_markdown_no_marker_when_below_threshold() {
1053        let root = PathBuf::from("/project");
1054        let report = crate::health_types::HealthReport {
1055            findings: vec![crate::health_types::HealthFinding {
1056                path: root.join("src/utils.ts"),
1057                name: "helper".to_string(),
1058                line: 10,
1059                col: 0,
1060                cyclomatic: 15,
1061                cognitive: 20,
1062                line_count: 30,
1063                exceeded: crate::health_types::ExceededThreshold::Cognitive,
1064            }],
1065            summary: crate::health_types::HealthSummary {
1066                files_analyzed: 5,
1067                functions_analyzed: 20,
1068                functions_above_threshold: 1,
1069                max_cyclomatic_threshold: 20,
1070                max_cognitive_threshold: 15,
1071                files_scored: None,
1072                average_maintainability: None,
1073            },
1074            vital_signs: None,
1075            file_scores: vec![],
1076            hotspots: vec![],
1077            hotspot_summary: None,
1078            targets: vec![],
1079            target_thresholds: None,
1080        };
1081        let md = build_health_markdown(&report, &root);
1082        // Cyclomatic 15 is below threshold 20, no marker
1083        assert!(md.contains("| 15 |"));
1084        // Cognitive 20 exceeds threshold 15, has marker
1085        assert!(md.contains("20 **!**"));
1086    }
1087
1088    #[test]
1089    fn health_markdown_with_targets() {
1090        use crate::health_types::*;
1091
1092        let root = PathBuf::from("/project");
1093        let report = HealthReport {
1094            findings: vec![],
1095            summary: HealthSummary {
1096                files_analyzed: 10,
1097                functions_analyzed: 50,
1098                functions_above_threshold: 0,
1099                max_cyclomatic_threshold: 20,
1100                max_cognitive_threshold: 15,
1101                files_scored: None,
1102                average_maintainability: None,
1103            },
1104            vital_signs: None,
1105            file_scores: vec![],
1106            hotspots: vec![],
1107            hotspot_summary: None,
1108            targets: vec![
1109                RefactoringTarget {
1110                    path: PathBuf::from("/project/src/complex.ts"),
1111                    priority: 82.5,
1112                    efficiency: 27.5,
1113                    recommendation: "Split high-impact file".into(),
1114                    category: RecommendationCategory::SplitHighImpact,
1115                    effort: crate::health_types::EffortEstimate::High,
1116                    confidence: crate::health_types::Confidence::Medium,
1117                    factors: vec![ContributingFactor {
1118                        metric: "fan_in",
1119                        value: 25.0,
1120                        threshold: 10.0,
1121                        detail: "25 files depend on this".into(),
1122                    }],
1123                    evidence: None,
1124                },
1125                RefactoringTarget {
1126                    path: PathBuf::from("/project/src/legacy.ts"),
1127                    priority: 45.0,
1128                    efficiency: 45.0,
1129                    recommendation: "Remove 5 unused exports".into(),
1130                    category: RecommendationCategory::RemoveDeadCode,
1131                    effort: crate::health_types::EffortEstimate::Low,
1132                    confidence: crate::health_types::Confidence::High,
1133                    factors: vec![],
1134                    evidence: None,
1135                },
1136            ],
1137            target_thresholds: None,
1138        };
1139        let md = build_health_markdown(&report, &root);
1140
1141        // Should have refactoring targets section
1142        assert!(
1143            md.contains("Refactoring Targets"),
1144            "should contain targets heading"
1145        );
1146        assert!(
1147            md.contains("src/complex.ts"),
1148            "should contain target file path"
1149        );
1150        assert!(md.contains("27.5"), "should contain efficiency score");
1151        assert!(
1152            md.contains("Split high-impact file"),
1153            "should contain recommendation"
1154        );
1155        assert!(md.contains("src/legacy.ts"), "should contain second target");
1156    }
1157}