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    // 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                plural(count),
419            );
420        } else {
421            let _ = write!(
422                out,
423                "## Fallow: {count} high complexity function{}\n\n",
424                plural(count),
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                plural(summary.files_excluded),
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 crate::report::test_helpers::sample_results;
597    use fallow_core::duplicates::{
598        CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
599        RefactoringKind, RefactoringSuggestion,
600    };
601    use fallow_core::results::*;
602    use std::path::PathBuf;
603
604    #[test]
605    fn markdown_empty_results_no_issues() {
606        let root = PathBuf::from("/project");
607        let results = AnalysisResults::default();
608        let md = build_markdown(&results, &root);
609        assert_eq!(md, "## Fallow: no issues found\n");
610    }
611
612    #[test]
613    fn markdown_contains_header_with_count() {
614        let root = PathBuf::from("/project");
615        let results = sample_results(&root);
616        let md = build_markdown(&results, &root);
617        assert!(md.starts_with(&format!(
618            "## Fallow: {} issues found\n",
619            results.total_issues()
620        )));
621    }
622
623    #[test]
624    fn markdown_contains_all_sections() {
625        let root = PathBuf::from("/project");
626        let results = sample_results(&root);
627        let md = build_markdown(&results, &root);
628
629        assert!(md.contains("### Unused files (1)"));
630        assert!(md.contains("### Unused exports (1)"));
631        assert!(md.contains("### Unused type exports (1)"));
632        assert!(md.contains("### Unused dependencies (1)"));
633        assert!(md.contains("### Unused devDependencies (1)"));
634        assert!(md.contains("### Unused enum members (1)"));
635        assert!(md.contains("### Unused class members (1)"));
636        assert!(md.contains("### Unresolved imports (1)"));
637        assert!(md.contains("### Unlisted dependencies (1)"));
638        assert!(md.contains("### Duplicate exports (1)"));
639        assert!(md.contains("### Type-only dependencies"));
640        assert!(md.contains("### Test-only production dependencies"));
641        assert!(md.contains("### Circular dependencies (1)"));
642    }
643
644    #[test]
645    fn markdown_unused_file_format() {
646        let root = PathBuf::from("/project");
647        let mut results = AnalysisResults::default();
648        results.unused_files.push(UnusedFile {
649            path: root.join("src/dead.ts"),
650        });
651        let md = build_markdown(&results, &root);
652        assert!(md.contains("- `src/dead.ts`"));
653    }
654
655    #[test]
656    fn markdown_unused_export_grouped_by_file() {
657        let root = PathBuf::from("/project");
658        let mut results = AnalysisResults::default();
659        results.unused_exports.push(UnusedExport {
660            path: root.join("src/utils.ts"),
661            export_name: "helperFn".to_string(),
662            is_type_only: false,
663            line: 10,
664            col: 4,
665            span_start: 120,
666            is_re_export: false,
667        });
668        let md = build_markdown(&results, &root);
669        assert!(md.contains("- `src/utils.ts`"));
670        assert!(md.contains(":10 `helperFn`"));
671    }
672
673    #[test]
674    fn markdown_re_export_tagged() {
675        let root = PathBuf::from("/project");
676        let mut results = AnalysisResults::default();
677        results.unused_exports.push(UnusedExport {
678            path: root.join("src/index.ts"),
679            export_name: "reExported".to_string(),
680            is_type_only: false,
681            line: 1,
682            col: 0,
683            span_start: 0,
684            is_re_export: true,
685        });
686        let md = build_markdown(&results, &root);
687        assert!(md.contains("(re-export)"));
688    }
689
690    #[test]
691    fn markdown_unused_dep_format() {
692        let root = PathBuf::from("/project");
693        let mut results = AnalysisResults::default();
694        results.unused_dependencies.push(UnusedDependency {
695            package_name: "lodash".to_string(),
696            location: DependencyLocation::Dependencies,
697            path: root.join("package.json"),
698            line: 5,
699        });
700        let md = build_markdown(&results, &root);
701        assert!(md.contains("- `lodash`"));
702    }
703
704    #[test]
705    fn markdown_circular_dep_format() {
706        let root = PathBuf::from("/project");
707        let mut results = AnalysisResults::default();
708        results.circular_dependencies.push(CircularDependency {
709            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
710            length: 2,
711            line: 3,
712            col: 0,
713        });
714        let md = build_markdown(&results, &root);
715        assert!(md.contains("`src/a.ts`"));
716        assert!(md.contains("`src/b.ts`"));
717        assert!(md.contains("\u{2192}"));
718    }
719
720    #[test]
721    fn markdown_strips_root_prefix() {
722        let root = PathBuf::from("/project");
723        let mut results = AnalysisResults::default();
724        results.unused_files.push(UnusedFile {
725            path: PathBuf::from("/project/src/deep/nested/file.ts"),
726        });
727        let md = build_markdown(&results, &root);
728        assert!(md.contains("`src/deep/nested/file.ts`"));
729        assert!(!md.contains("/project/"));
730    }
731
732    #[test]
733    fn markdown_single_issue_no_plural() {
734        let root = PathBuf::from("/project");
735        let mut results = AnalysisResults::default();
736        results.unused_files.push(UnusedFile {
737            path: root.join("src/dead.ts"),
738        });
739        let md = build_markdown(&results, &root);
740        assert!(md.starts_with("## Fallow: 1 issue found\n"));
741    }
742
743    #[test]
744    fn markdown_type_only_dep_format() {
745        let root = PathBuf::from("/project");
746        let mut results = AnalysisResults::default();
747        results.type_only_dependencies.push(TypeOnlyDependency {
748            package_name: "zod".to_string(),
749            path: root.join("package.json"),
750            line: 8,
751        });
752        let md = build_markdown(&results, &root);
753        assert!(md.contains("### Type-only dependencies"));
754        assert!(md.contains("- `zod`"));
755    }
756
757    #[test]
758    fn markdown_escapes_backticks_in_export_names() {
759        let root = PathBuf::from("/project");
760        let mut results = AnalysisResults::default();
761        results.unused_exports.push(UnusedExport {
762            path: root.join("src/utils.ts"),
763            export_name: "foo`bar".to_string(),
764            is_type_only: false,
765            line: 1,
766            col: 0,
767            span_start: 0,
768            is_re_export: false,
769        });
770        let md = build_markdown(&results, &root);
771        assert!(md.contains("foo\\`bar"));
772        assert!(!md.contains("foo`bar`"));
773    }
774
775    #[test]
776    fn markdown_escapes_backticks_in_package_names() {
777        let root = PathBuf::from("/project");
778        let mut results = AnalysisResults::default();
779        results.unused_dependencies.push(UnusedDependency {
780            package_name: "pkg`name".to_string(),
781            location: DependencyLocation::Dependencies,
782            path: root.join("package.json"),
783            line: 5,
784        });
785        let md = build_markdown(&results, &root);
786        assert!(md.contains("pkg\\`name"));
787    }
788
789    // ── Duplication markdown ──
790
791    #[test]
792    fn duplication_markdown_empty() {
793        let report = DuplicationReport::default();
794        let root = PathBuf::from("/project");
795        let md = build_duplication_markdown(&report, &root);
796        assert_eq!(md, "## Fallow: no code duplication found\n");
797    }
798
799    #[test]
800    fn duplication_markdown_contains_groups() {
801        let root = PathBuf::from("/project");
802        let report = DuplicationReport {
803            clone_groups: vec![CloneGroup {
804                instances: vec![
805                    CloneInstance {
806                        file: root.join("src/a.ts"),
807                        start_line: 1,
808                        end_line: 10,
809                        start_col: 0,
810                        end_col: 0,
811                        fragment: String::new(),
812                    },
813                    CloneInstance {
814                        file: root.join("src/b.ts"),
815                        start_line: 5,
816                        end_line: 14,
817                        start_col: 0,
818                        end_col: 0,
819                        fragment: String::new(),
820                    },
821                ],
822                token_count: 50,
823                line_count: 10,
824            }],
825            clone_families: vec![],
826            stats: DuplicationStats {
827                total_files: 10,
828                files_with_clones: 2,
829                total_lines: 500,
830                duplicated_lines: 20,
831                total_tokens: 2500,
832                duplicated_tokens: 100,
833                clone_groups: 1,
834                clone_instances: 2,
835                duplication_percentage: 4.0,
836            },
837        };
838        let md = build_duplication_markdown(&report, &root);
839        assert!(md.contains("**Clone group 1**"));
840        assert!(md.contains("`src/a.ts:1-10`"));
841        assert!(md.contains("`src/b.ts:5-14`"));
842        assert!(md.contains("4.0% duplication"));
843    }
844
845    #[test]
846    fn duplication_markdown_contains_families() {
847        let root = PathBuf::from("/project");
848        let report = DuplicationReport {
849            clone_groups: vec![CloneGroup {
850                instances: vec![CloneInstance {
851                    file: root.join("src/a.ts"),
852                    start_line: 1,
853                    end_line: 5,
854                    start_col: 0,
855                    end_col: 0,
856                    fragment: String::new(),
857                }],
858                token_count: 30,
859                line_count: 5,
860            }],
861            clone_families: vec![CloneFamily {
862                files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
863                groups: vec![],
864                total_duplicated_lines: 20,
865                total_duplicated_tokens: 100,
866                suggestions: vec![RefactoringSuggestion {
867                    kind: RefactoringKind::ExtractFunction,
868                    description: "Extract shared utility function".to_string(),
869                    estimated_savings: 15,
870                }],
871            }],
872            stats: DuplicationStats {
873                clone_groups: 1,
874                clone_instances: 1,
875                duplication_percentage: 2.0,
876                ..Default::default()
877            },
878        };
879        let md = build_duplication_markdown(&report, &root);
880        assert!(md.contains("### Clone Families"));
881        assert!(md.contains("**Family 1**"));
882        assert!(md.contains("Extract shared utility function"));
883        assert!(md.contains("~15 lines saved"));
884    }
885
886    // ── Health markdown ──
887
888    #[test]
889    fn health_markdown_empty_no_findings() {
890        let root = PathBuf::from("/project");
891        let report = crate::health_types::HealthReport {
892            findings: vec![],
893            summary: crate::health_types::HealthSummary {
894                files_analyzed: 10,
895                functions_analyzed: 50,
896                functions_above_threshold: 0,
897                max_cyclomatic_threshold: 20,
898                max_cognitive_threshold: 15,
899                files_scored: None,
900                average_maintainability: None,
901            },
902            vital_signs: None,
903            file_scores: vec![],
904            hotspots: vec![],
905            hotspot_summary: None,
906            targets: vec![],
907            target_thresholds: None,
908        };
909        let md = build_health_markdown(&report, &root);
910        assert!(md.contains("no functions exceed complexity thresholds"));
911        assert!(md.contains("**50** functions analyzed"));
912    }
913
914    #[test]
915    fn health_markdown_table_format() {
916        let root = PathBuf::from("/project");
917        let report = crate::health_types::HealthReport {
918            findings: vec![crate::health_types::HealthFinding {
919                path: root.join("src/utils.ts"),
920                name: "parseExpression".to_string(),
921                line: 42,
922                col: 0,
923                cyclomatic: 25,
924                cognitive: 30,
925                line_count: 80,
926                exceeded: crate::health_types::ExceededThreshold::Both,
927            }],
928            summary: crate::health_types::HealthSummary {
929                files_analyzed: 10,
930                functions_analyzed: 50,
931                functions_above_threshold: 1,
932                max_cyclomatic_threshold: 20,
933                max_cognitive_threshold: 15,
934                files_scored: None,
935                average_maintainability: None,
936            },
937            vital_signs: None,
938            file_scores: vec![],
939            hotspots: vec![],
940            hotspot_summary: None,
941            targets: vec![],
942            target_thresholds: None,
943        };
944        let md = build_health_markdown(&report, &root);
945        assert!(md.contains("## Fallow: 1 high complexity function\n"));
946        assert!(md.contains("| File | Function |"));
947        assert!(md.contains("`src/utils.ts:42`"));
948        assert!(md.contains("`parseExpression`"));
949        assert!(md.contains("25 **!**"));
950        assert!(md.contains("30 **!**"));
951        assert!(md.contains("| 80 |"));
952    }
953
954    #[test]
955    fn health_markdown_no_marker_when_below_threshold() {
956        let root = PathBuf::from("/project");
957        let report = crate::health_types::HealthReport {
958            findings: vec![crate::health_types::HealthFinding {
959                path: root.join("src/utils.ts"),
960                name: "helper".to_string(),
961                line: 10,
962                col: 0,
963                cyclomatic: 15,
964                cognitive: 20,
965                line_count: 30,
966                exceeded: crate::health_types::ExceededThreshold::Cognitive,
967            }],
968            summary: crate::health_types::HealthSummary {
969                files_analyzed: 5,
970                functions_analyzed: 20,
971                functions_above_threshold: 1,
972                max_cyclomatic_threshold: 20,
973                max_cognitive_threshold: 15,
974                files_scored: None,
975                average_maintainability: None,
976            },
977            vital_signs: None,
978            file_scores: vec![],
979            hotspots: vec![],
980            hotspot_summary: None,
981            targets: vec![],
982            target_thresholds: None,
983        };
984        let md = build_health_markdown(&report, &root);
985        // Cyclomatic 15 is below threshold 20, no marker
986        assert!(md.contains("| 15 |"));
987        // Cognitive 20 exceeds threshold 15, has marker
988        assert!(md.contains("20 **!**"));
989    }
990
991    #[test]
992    fn health_markdown_with_targets() {
993        use crate::health_types::*;
994
995        let root = PathBuf::from("/project");
996        let report = HealthReport {
997            findings: vec![],
998            summary: HealthSummary {
999                files_analyzed: 10,
1000                functions_analyzed: 50,
1001                functions_above_threshold: 0,
1002                max_cyclomatic_threshold: 20,
1003                max_cognitive_threshold: 15,
1004                files_scored: None,
1005                average_maintainability: None,
1006            },
1007            vital_signs: None,
1008            file_scores: vec![],
1009            hotspots: vec![],
1010            hotspot_summary: None,
1011            targets: vec![
1012                RefactoringTarget {
1013                    path: PathBuf::from("/project/src/complex.ts"),
1014                    priority: 82.5,
1015                    efficiency: 27.5,
1016                    recommendation: "Split high-impact file".into(),
1017                    category: RecommendationCategory::SplitHighImpact,
1018                    effort: crate::health_types::EffortEstimate::High,
1019                    confidence: crate::health_types::Confidence::Medium,
1020                    factors: vec![ContributingFactor {
1021                        metric: "fan_in",
1022                        value: 25.0,
1023                        threshold: 10.0,
1024                        detail: "25 files depend on this".into(),
1025                    }],
1026                    evidence: None,
1027                },
1028                RefactoringTarget {
1029                    path: PathBuf::from("/project/src/legacy.ts"),
1030                    priority: 45.0,
1031                    efficiency: 45.0,
1032                    recommendation: "Remove 5 unused exports".into(),
1033                    category: RecommendationCategory::RemoveDeadCode,
1034                    effort: crate::health_types::EffortEstimate::Low,
1035                    confidence: crate::health_types::Confidence::High,
1036                    factors: vec![],
1037                    evidence: None,
1038                },
1039            ],
1040            target_thresholds: None,
1041        };
1042        let md = build_health_markdown(&report, &root);
1043
1044        // Should have refactoring targets section
1045        assert!(
1046            md.contains("Refactoring Targets"),
1047            "should contain targets heading"
1048        );
1049        assert!(
1050            md.contains("src/complex.ts"),
1051            "should contain target file path"
1052        );
1053        assert!(md.contains("27.5"), "should contain efficiency score");
1054        assert!(
1055            md.contains("Split high-impact file"),
1056            "should contain recommendation"
1057        );
1058        assert!(md.contains("src/legacy.ts"), "should contain second target");
1059    }
1060
1061    // ── Dependency in workspace package ──
1062
1063    #[test]
1064    fn markdown_dep_in_workspace_shows_package_label() {
1065        let root = PathBuf::from("/project");
1066        let mut results = AnalysisResults::default();
1067        results.unused_dependencies.push(UnusedDependency {
1068            package_name: "lodash".to_string(),
1069            location: DependencyLocation::Dependencies,
1070            path: root.join("packages/core/package.json"),
1071            line: 5,
1072        });
1073        let md = build_markdown(&results, &root);
1074        // Non-root package.json should show the label
1075        assert!(md.contains("(packages/core/package.json)"));
1076    }
1077
1078    #[test]
1079    fn markdown_dep_at_root_no_extra_label() {
1080        let root = PathBuf::from("/project");
1081        let mut results = AnalysisResults::default();
1082        results.unused_dependencies.push(UnusedDependency {
1083            package_name: "lodash".to_string(),
1084            location: DependencyLocation::Dependencies,
1085            path: root.join("package.json"),
1086            line: 5,
1087        });
1088        let md = build_markdown(&results, &root);
1089        assert!(md.contains("- `lodash`"));
1090        assert!(!md.contains("(package.json)"));
1091    }
1092
1093    // ── Multiple exports same file grouped ──
1094
1095    #[test]
1096    fn markdown_exports_grouped_by_file() {
1097        let root = PathBuf::from("/project");
1098        let mut results = AnalysisResults::default();
1099        results.unused_exports.push(UnusedExport {
1100            path: root.join("src/utils.ts"),
1101            export_name: "alpha".to_string(),
1102            is_type_only: false,
1103            line: 5,
1104            col: 0,
1105            span_start: 0,
1106            is_re_export: false,
1107        });
1108        results.unused_exports.push(UnusedExport {
1109            path: root.join("src/utils.ts"),
1110            export_name: "beta".to_string(),
1111            is_type_only: false,
1112            line: 10,
1113            col: 0,
1114            span_start: 0,
1115            is_re_export: false,
1116        });
1117        results.unused_exports.push(UnusedExport {
1118            path: root.join("src/other.ts"),
1119            export_name: "gamma".to_string(),
1120            is_type_only: false,
1121            line: 1,
1122            col: 0,
1123            span_start: 0,
1124            is_re_export: false,
1125        });
1126        let md = build_markdown(&results, &root);
1127        // File header should appear only once for utils.ts
1128        let utils_count = md.matches("- `src/utils.ts`").count();
1129        assert_eq!(utils_count, 1, "file header should appear once per file");
1130        // Both exports should be under it as sub-items
1131        assert!(md.contains(":5 `alpha`"));
1132        assert!(md.contains(":10 `beta`"));
1133    }
1134
1135    // ── Multiple issues plural header ──
1136
1137    #[test]
1138    fn markdown_multiple_issues_plural() {
1139        let root = PathBuf::from("/project");
1140        let mut results = AnalysisResults::default();
1141        results.unused_files.push(UnusedFile {
1142            path: root.join("src/a.ts"),
1143        });
1144        results.unused_files.push(UnusedFile {
1145            path: root.join("src/b.ts"),
1146        });
1147        let md = build_markdown(&results, &root);
1148        assert!(md.starts_with("## Fallow: 2 issues found\n"));
1149    }
1150
1151    // ── Duplication markdown with zero estimated savings ──
1152
1153    #[test]
1154    fn duplication_markdown_zero_savings_no_suffix() {
1155        let root = PathBuf::from("/project");
1156        let report = DuplicationReport {
1157            clone_groups: vec![CloneGroup {
1158                instances: vec![CloneInstance {
1159                    file: root.join("src/a.ts"),
1160                    start_line: 1,
1161                    end_line: 5,
1162                    start_col: 0,
1163                    end_col: 0,
1164                    fragment: String::new(),
1165                }],
1166                token_count: 30,
1167                line_count: 5,
1168            }],
1169            clone_families: vec![CloneFamily {
1170                files: vec![root.join("src/a.ts")],
1171                groups: vec![],
1172                total_duplicated_lines: 5,
1173                total_duplicated_tokens: 30,
1174                suggestions: vec![RefactoringSuggestion {
1175                    kind: RefactoringKind::ExtractFunction,
1176                    description: "Extract function".to_string(),
1177                    estimated_savings: 0,
1178                }],
1179            }],
1180            stats: DuplicationStats {
1181                clone_groups: 1,
1182                clone_instances: 1,
1183                duplication_percentage: 1.0,
1184                ..Default::default()
1185            },
1186        };
1187        let md = build_duplication_markdown(&report, &root);
1188        assert!(md.contains("Extract function"));
1189        assert!(!md.contains("lines saved"));
1190    }
1191
1192    // ── Health markdown vital signs ──
1193
1194    #[test]
1195    fn health_markdown_vital_signs_table() {
1196        let root = PathBuf::from("/project");
1197        let report = crate::health_types::HealthReport {
1198            findings: vec![],
1199            summary: crate::health_types::HealthSummary {
1200                files_analyzed: 10,
1201                functions_analyzed: 50,
1202                functions_above_threshold: 0,
1203                max_cyclomatic_threshold: 20,
1204                max_cognitive_threshold: 15,
1205                files_scored: None,
1206                average_maintainability: None,
1207            },
1208            vital_signs: Some(crate::health_types::VitalSigns {
1209                avg_cyclomatic: 3.5,
1210                p90_cyclomatic: 12,
1211                dead_file_pct: Some(5.0),
1212                dead_export_pct: Some(10.2),
1213                duplication_pct: None,
1214                maintainability_avg: Some(72.3),
1215                hotspot_count: Some(3),
1216                circular_dep_count: Some(1),
1217                unused_dep_count: Some(2),
1218            }),
1219            file_scores: vec![],
1220            hotspots: vec![],
1221            hotspot_summary: None,
1222            targets: vec![],
1223            target_thresholds: None,
1224        };
1225        let md = build_health_markdown(&report, &root);
1226        assert!(md.contains("## Vital Signs"));
1227        assert!(md.contains("| Metric | Value |"));
1228        assert!(md.contains("| Avg Cyclomatic | 3.5 |"));
1229        assert!(md.contains("| P90 Cyclomatic | 12 |"));
1230        assert!(md.contains("| Dead Files | 5.0% |"));
1231        assert!(md.contains("| Dead Exports | 10.2% |"));
1232        assert!(md.contains("| Maintainability (avg) | 72.3 |"));
1233        assert!(md.contains("| Hotspots | 3 |"));
1234        assert!(md.contains("| Circular Deps | 1 |"));
1235        assert!(md.contains("| Unused Deps | 2 |"));
1236    }
1237
1238    // ── Health markdown file scores ──
1239
1240    #[test]
1241    fn health_markdown_file_scores_table() {
1242        let root = PathBuf::from("/project");
1243        let report = crate::health_types::HealthReport {
1244            findings: vec![crate::health_types::HealthFinding {
1245                path: root.join("src/dummy.ts"),
1246                name: "fn".to_string(),
1247                line: 1,
1248                col: 0,
1249                cyclomatic: 25,
1250                cognitive: 20,
1251                line_count: 50,
1252                exceeded: crate::health_types::ExceededThreshold::Both,
1253            }],
1254            summary: crate::health_types::HealthSummary {
1255                files_analyzed: 5,
1256                functions_analyzed: 10,
1257                functions_above_threshold: 1,
1258                max_cyclomatic_threshold: 20,
1259                max_cognitive_threshold: 15,
1260                files_scored: Some(1),
1261                average_maintainability: Some(65.0),
1262            },
1263            vital_signs: None,
1264            file_scores: vec![crate::health_types::FileHealthScore {
1265                path: root.join("src/utils.ts"),
1266                fan_in: 5,
1267                fan_out: 3,
1268                dead_code_ratio: 0.25,
1269                complexity_density: 0.8,
1270                maintainability_index: 72.5,
1271                total_cyclomatic: 40,
1272                total_cognitive: 30,
1273                function_count: 10,
1274                lines: 200,
1275            }],
1276            hotspots: vec![],
1277            hotspot_summary: None,
1278            targets: vec![],
1279            target_thresholds: None,
1280        };
1281        let md = build_health_markdown(&report, &root);
1282        assert!(md.contains("### File Health Scores (1 files)"));
1283        assert!(md.contains("| File | MI | Fan-in | Fan-out | Dead Code | Density |"));
1284        assert!(md.contains("| `src/utils.ts` | 72.5 | 5 | 3 | 25% | 0.80 |"));
1285        assert!(md.contains("**Average maintainability index:** 65.0/100"));
1286    }
1287
1288    // ── Health markdown hotspots ──
1289
1290    #[test]
1291    fn health_markdown_hotspots_table() {
1292        let root = PathBuf::from("/project");
1293        let report = crate::health_types::HealthReport {
1294            findings: vec![crate::health_types::HealthFinding {
1295                path: root.join("src/dummy.ts"),
1296                name: "fn".to_string(),
1297                line: 1,
1298                col: 0,
1299                cyclomatic: 25,
1300                cognitive: 20,
1301                line_count: 50,
1302                exceeded: crate::health_types::ExceededThreshold::Both,
1303            }],
1304            summary: crate::health_types::HealthSummary {
1305                files_analyzed: 5,
1306                functions_analyzed: 10,
1307                functions_above_threshold: 1,
1308                max_cyclomatic_threshold: 20,
1309                max_cognitive_threshold: 15,
1310                files_scored: None,
1311                average_maintainability: None,
1312            },
1313            vital_signs: None,
1314            file_scores: vec![],
1315            hotspots: vec![crate::health_types::HotspotEntry {
1316                path: root.join("src/hot.ts"),
1317                score: 85.0,
1318                commits: 42,
1319                weighted_commits: 35.0,
1320                lines_added: 500,
1321                lines_deleted: 200,
1322                complexity_density: 1.2,
1323                fan_in: 10,
1324                trend: fallow_core::churn::ChurnTrend::Accelerating,
1325            }],
1326            hotspot_summary: Some(crate::health_types::HotspotSummary {
1327                since: "6 months".to_string(),
1328                min_commits: 3,
1329                files_analyzed: 50,
1330                files_excluded: 5,
1331                shallow_clone: false,
1332            }),
1333            targets: vec![],
1334            target_thresholds: None,
1335        };
1336        let md = build_health_markdown(&report, &root);
1337        assert!(md.contains("### Hotspots (1 files, since 6 months)"));
1338        assert!(md.contains("| `src/hot.ts` | 85.0 | 42 | 700 | 1.20 | 10 | accelerating |"));
1339        assert!(md.contains("*5 files excluded (< 3 commits)*"));
1340    }
1341
1342    // ── Health markdown metric legend ──
1343
1344    #[test]
1345    fn health_markdown_metric_legend_with_scores() {
1346        let root = PathBuf::from("/project");
1347        let report = crate::health_types::HealthReport {
1348            findings: vec![crate::health_types::HealthFinding {
1349                path: root.join("src/x.ts"),
1350                name: "f".to_string(),
1351                line: 1,
1352                col: 0,
1353                cyclomatic: 25,
1354                cognitive: 20,
1355                line_count: 10,
1356                exceeded: crate::health_types::ExceededThreshold::Both,
1357            }],
1358            summary: crate::health_types::HealthSummary {
1359                files_analyzed: 1,
1360                functions_analyzed: 1,
1361                functions_above_threshold: 1,
1362                max_cyclomatic_threshold: 20,
1363                max_cognitive_threshold: 15,
1364                files_scored: Some(1),
1365                average_maintainability: Some(70.0),
1366            },
1367            vital_signs: None,
1368            file_scores: vec![crate::health_types::FileHealthScore {
1369                path: root.join("src/x.ts"),
1370                fan_in: 1,
1371                fan_out: 1,
1372                dead_code_ratio: 0.0,
1373                complexity_density: 0.5,
1374                maintainability_index: 80.0,
1375                total_cyclomatic: 10,
1376                total_cognitive: 8,
1377                function_count: 2,
1378                lines: 50,
1379            }],
1380            hotspots: vec![],
1381            hotspot_summary: None,
1382            targets: vec![],
1383            target_thresholds: None,
1384        };
1385        let md = build_health_markdown(&report, &root);
1386        assert!(md.contains("<details><summary>Metric definitions</summary>"));
1387        assert!(md.contains("**MI** \u{2014} Maintainability Index"));
1388        assert!(md.contains("**Fan-in**"));
1389        assert!(md.contains("Full metric reference"));
1390    }
1391
1392    // ── Health markdown truncated findings ──
1393
1394    #[test]
1395    fn health_markdown_truncated_findings_shown_count() {
1396        let root = PathBuf::from("/project");
1397        let report = crate::health_types::HealthReport {
1398            findings: vec![crate::health_types::HealthFinding {
1399                path: root.join("src/x.ts"),
1400                name: "f".to_string(),
1401                line: 1,
1402                col: 0,
1403                cyclomatic: 25,
1404                cognitive: 20,
1405                line_count: 10,
1406                exceeded: crate::health_types::ExceededThreshold::Both,
1407            }],
1408            summary: crate::health_types::HealthSummary {
1409                files_analyzed: 10,
1410                functions_analyzed: 50,
1411                functions_above_threshold: 5, // 5 total but only 1 shown
1412                max_cyclomatic_threshold: 20,
1413                max_cognitive_threshold: 15,
1414                files_scored: None,
1415                average_maintainability: None,
1416            },
1417            vital_signs: None,
1418            file_scores: vec![],
1419            hotspots: vec![],
1420            hotspot_summary: None,
1421            targets: vec![],
1422            target_thresholds: None,
1423        };
1424        let md = build_health_markdown(&report, &root);
1425        assert!(md.contains("5 high complexity functions (1 shown)"));
1426    }
1427
1428    // ── escape_backticks ──
1429
1430    #[test]
1431    fn escape_backticks_handles_multiple() {
1432        assert_eq!(escape_backticks("a`b`c"), "a\\`b\\`c");
1433    }
1434
1435    #[test]
1436    fn escape_backticks_no_backticks_unchanged() {
1437        assert_eq!(escape_backticks("hello"), "hello");
1438    }
1439
1440    // ── Unresolved import in markdown ──
1441
1442    #[test]
1443    fn markdown_unresolved_import_grouped_by_file() {
1444        let root = PathBuf::from("/project");
1445        let mut results = AnalysisResults::default();
1446        results.unresolved_imports.push(UnresolvedImport {
1447            path: root.join("src/app.ts"),
1448            specifier: "./missing".to_string(),
1449            line: 3,
1450            col: 0,
1451            specifier_col: 0,
1452        });
1453        let md = build_markdown(&results, &root);
1454        assert!(md.contains("### Unresolved imports (1)"));
1455        assert!(md.contains("- `src/app.ts`"));
1456        assert!(md.contains(":3 `./missing`"));
1457    }
1458
1459    // ── Markdown optional dep ──
1460
1461    #[test]
1462    fn markdown_unused_optional_dep() {
1463        let root = PathBuf::from("/project");
1464        let mut results = AnalysisResults::default();
1465        results.unused_optional_dependencies.push(UnusedDependency {
1466            package_name: "fsevents".to_string(),
1467            location: DependencyLocation::OptionalDependencies,
1468            path: root.join("package.json"),
1469            line: 12,
1470        });
1471        let md = build_markdown(&results, &root);
1472        assert!(md.contains("### Unused optionalDependencies (1)"));
1473        assert!(md.contains("- `fsevents`"));
1474    }
1475
1476    // ── Health markdown no hotspot exclusion message when 0 excluded ──
1477
1478    #[test]
1479    fn health_markdown_hotspots_no_excluded_message() {
1480        let root = PathBuf::from("/project");
1481        let report = crate::health_types::HealthReport {
1482            findings: vec![crate::health_types::HealthFinding {
1483                path: root.join("src/x.ts"),
1484                name: "f".to_string(),
1485                line: 1,
1486                col: 0,
1487                cyclomatic: 25,
1488                cognitive: 20,
1489                line_count: 10,
1490                exceeded: crate::health_types::ExceededThreshold::Both,
1491            }],
1492            summary: crate::health_types::HealthSummary {
1493                files_analyzed: 5,
1494                functions_analyzed: 10,
1495                functions_above_threshold: 1,
1496                max_cyclomatic_threshold: 20,
1497                max_cognitive_threshold: 15,
1498                files_scored: None,
1499                average_maintainability: None,
1500            },
1501            vital_signs: None,
1502            file_scores: vec![],
1503            hotspots: vec![crate::health_types::HotspotEntry {
1504                path: root.join("src/hot.ts"),
1505                score: 50.0,
1506                commits: 10,
1507                weighted_commits: 8.0,
1508                lines_added: 100,
1509                lines_deleted: 50,
1510                complexity_density: 0.5,
1511                fan_in: 3,
1512                trend: fallow_core::churn::ChurnTrend::Stable,
1513            }],
1514            hotspot_summary: Some(crate::health_types::HotspotSummary {
1515                since: "6 months".to_string(),
1516                min_commits: 3,
1517                files_analyzed: 50,
1518                files_excluded: 0,
1519                shallow_clone: false,
1520            }),
1521            targets: vec![],
1522            target_thresholds: None,
1523        };
1524        let md = build_health_markdown(&report, &root);
1525        assert!(!md.contains("files excluded"));
1526    }
1527
1528    // ── Duplication markdown plural ──
1529
1530    #[test]
1531    fn duplication_markdown_single_group_no_plural() {
1532        let root = PathBuf::from("/project");
1533        let report = DuplicationReport {
1534            clone_groups: vec![CloneGroup {
1535                instances: vec![CloneInstance {
1536                    file: root.join("src/a.ts"),
1537                    start_line: 1,
1538                    end_line: 5,
1539                    start_col: 0,
1540                    end_col: 0,
1541                    fragment: String::new(),
1542                }],
1543                token_count: 30,
1544                line_count: 5,
1545            }],
1546            clone_families: vec![],
1547            stats: DuplicationStats {
1548                clone_groups: 1,
1549                clone_instances: 1,
1550                duplication_percentage: 2.0,
1551                ..Default::default()
1552            },
1553        };
1554        let md = build_duplication_markdown(&report, &root);
1555        assert!(md.contains("1 clone group found"));
1556        assert!(!md.contains("1 clone groups found"));
1557    }
1558}