Skip to main content

fallow_cli/report/
markdown.rs

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