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::{
6    AnalysisResults, UnusedClassMemberFinding, UnusedEnumMemberFinding, UnusedExport,
7    UnusedExportFinding, UnusedMember, UnusedTypeFinding,
8};
9
10use super::grouping::ResultGroup;
11use super::{normalize_uri, plural, relative_path};
12
13/// Escape backticks in user-controlled strings to prevent breaking markdown code spans.
14fn escape_backticks(s: &str) -> String {
15    s.replace('`', "\\`")
16}
17
18pub(super) fn print_markdown(results: &AnalysisResults, root: &Path) {
19    println!("{}", build_markdown(results, root));
20}
21
22/// Build markdown output for analysis results.
23#[expect(
24    clippy::too_many_lines,
25    reason = "one section per issue type; splitting would fragment the output builder"
26)]
27pub fn build_markdown(results: &AnalysisResults, root: &Path) -> String {
28    let rel = |p: &Path| {
29        escape_backticks(&normalize_uri(
30            &relative_path(p, root).display().to_string(),
31        ))
32    };
33
34    let total = results.total_issues();
35    let mut out = String::new();
36
37    if total == 0 {
38        out.push_str("## Fallow: no issues found\n");
39        return out;
40    }
41
42    let _ = write!(out, "## Fallow: {total} issue{} found\n\n", plural(total));
43
44    // ── Unused files ──
45    markdown_section(&mut out, &results.unused_files, "Unused files", |file| {
46        vec![format!("- `{}`", rel(&file.file.path))]
47    });
48
49    // ── Unused exports ──
50    markdown_grouped_section(
51        &mut out,
52        &results.unused_exports,
53        "Unused exports",
54        root,
55        |e| e.export.path.as_path(),
56        |e: &UnusedExportFinding| format_export(&e.export),
57    );
58
59    // ── Unused types ──
60    markdown_grouped_section(
61        &mut out,
62        &results.unused_types,
63        "Unused type exports",
64        root,
65        |e| e.export.path.as_path(),
66        |e: &UnusedTypeFinding| format_export(&e.export),
67    );
68
69    markdown_grouped_section(
70        &mut out,
71        &results.private_type_leaks,
72        "Private type leaks",
73        root,
74        |e| e.leak.path.as_path(),
75        format_private_type_leak,
76    );
77
78    // ── Unused dependencies ──
79    markdown_section(
80        &mut out,
81        &results.unused_dependencies,
82        "Unused dependencies",
83        |dep| {
84            format_dependency(
85                &dep.dep.package_name,
86                &dep.dep.path,
87                &dep.dep.used_in_workspaces,
88                root,
89            )
90        },
91    );
92
93    // ── Unused devDependencies ──
94    markdown_section(
95        &mut out,
96        &results.unused_dev_dependencies,
97        "Unused devDependencies",
98        |dep| {
99            format_dependency(
100                &dep.dep.package_name,
101                &dep.dep.path,
102                &dep.dep.used_in_workspaces,
103                root,
104            )
105        },
106    );
107
108    // ── Unused optionalDependencies ──
109    markdown_section(
110        &mut out,
111        &results.unused_optional_dependencies,
112        "Unused optionalDependencies",
113        |dep| {
114            format_dependency(
115                &dep.dep.package_name,
116                &dep.dep.path,
117                &dep.dep.used_in_workspaces,
118                root,
119            )
120        },
121    );
122
123    // ── Unused enum members ──
124    markdown_grouped_section(
125        &mut out,
126        &results.unused_enum_members,
127        "Unused enum members",
128        root,
129        |m| m.member.path.as_path(),
130        |m: &UnusedEnumMemberFinding| format_member(&m.member),
131    );
132
133    // ── Unused class members ──
134    markdown_grouped_section(
135        &mut out,
136        &results.unused_class_members,
137        "Unused class members",
138        root,
139        |m| m.member.path.as_path(),
140        |m: &UnusedClassMemberFinding| format_member(&m.member),
141    );
142
143    // ── Unresolved imports ──
144    markdown_grouped_section(
145        &mut out,
146        &results.unresolved_imports,
147        "Unresolved imports",
148        root,
149        |i| i.import.path.as_path(),
150        |i| {
151            format!(
152                ":{} `{}`",
153                i.import.line,
154                escape_backticks(&i.import.specifier)
155            )
156        },
157    );
158
159    // ── Unlisted dependencies ──
160    markdown_section(
161        &mut out,
162        &results.unlisted_dependencies,
163        "Unlisted dependencies",
164        |dep| vec![format!("- `{}`", escape_backticks(&dep.dep.package_name))],
165    );
166
167    // ── Duplicate exports ──
168    markdown_section(
169        &mut out,
170        &results.duplicate_exports,
171        "Duplicate exports",
172        |dup| {
173            let locations: Vec<String> = dup
174                .export
175                .locations
176                .iter()
177                .map(|loc| format!("`{}`", rel(&loc.path)))
178                .collect();
179            vec![format!(
180                "- `{}` in {}",
181                escape_backticks(&dup.export.export_name),
182                locations.join(", ")
183            )]
184        },
185    );
186
187    // ── Type-only dependencies ──
188    markdown_section(
189        &mut out,
190        &results.type_only_dependencies,
191        "Type-only dependencies (consider moving to devDependencies)",
192        |dep| format_dependency(&dep.dep.package_name, &dep.dep.path, &[], root),
193    );
194
195    // ── Test-only dependencies ──
196    markdown_section(
197        &mut out,
198        &results.test_only_dependencies,
199        "Test-only production dependencies (consider moving to devDependencies)",
200        |dep| format_dependency(&dep.dep.package_name, &dep.dep.path, &[], root),
201    );
202
203    // ── Circular dependencies ──
204    markdown_section(
205        &mut out,
206        &results.circular_dependencies,
207        "Circular dependencies",
208        |cycle| {
209            let chain: Vec<String> = cycle.cycle.files.iter().map(|p| rel(p)).collect();
210            let mut display_chain = chain.clone();
211            if let Some(first) = chain.first() {
212                display_chain.push(first.clone());
213            }
214            let cross_pkg_tag = if cycle.cycle.is_cross_package {
215                " *(cross-package)*"
216            } else {
217                ""
218            };
219            vec![format!(
220                "- {}{}",
221                display_chain
222                    .iter()
223                    .map(|s| format!("`{s}`"))
224                    .collect::<Vec<_>>()
225                    .join(" \u{2192} "),
226                cross_pkg_tag
227            )]
228        },
229    );
230
231    // ── Boundary violations ──
232    markdown_section(
233        &mut out,
234        &results.boundary_violations,
235        "Boundary violations",
236        |v| {
237            vec![format!(
238                "- `{}`:{}  \u{2192} `{}` ({} \u{2192} {})",
239                rel(&v.violation.from_path),
240                v.violation.line,
241                rel(&v.violation.to_path),
242                v.violation.from_zone,
243                v.violation.to_zone,
244            )]
245        },
246    );
247
248    // ── Stale suppressions ──
249    markdown_section(
250        &mut out,
251        &results.stale_suppressions,
252        "Stale suppressions",
253        |s| {
254            vec![format!(
255                "- `{}`:{} `{}` ({})",
256                rel(&s.path),
257                s.line,
258                escape_backticks(&s.description()),
259                escape_backticks(&s.explanation()),
260            )]
261        },
262    );
263    markdown_section(
264        &mut out,
265        &results.unused_catalog_entries,
266        "Unused catalog entries",
267        |entry| {
268            let mut row = format!(
269                "- `{}` (`{}`) `{}`:{}",
270                escape_backticks(&entry.entry.entry_name),
271                escape_backticks(&entry.entry.catalog_name),
272                rel(&entry.entry.path),
273                entry.entry.line,
274            );
275            if !entry.entry.hardcoded_consumers.is_empty() {
276                use std::fmt::Write as _;
277                let consumers = entry
278                    .entry
279                    .hardcoded_consumers
280                    .iter()
281                    .map(|p| format!("`{}`", rel(p)))
282                    .collect::<Vec<_>>()
283                    .join(", ");
284                let _ = write!(row, " (hardcoded in {consumers})");
285            }
286            vec![row]
287        },
288    );
289    markdown_section(
290        &mut out,
291        &results.empty_catalog_groups,
292        "Empty catalog groups",
293        |group| {
294            vec![format!(
295                "- `{}` `{}`:{}",
296                escape_backticks(&group.group.catalog_name),
297                rel(&group.group.path),
298                group.group.line,
299            )]
300        },
301    );
302    markdown_section(
303        &mut out,
304        &results.unresolved_catalog_references,
305        "Unresolved catalog references",
306        |finding| {
307            let mut row = format!(
308                "- `{}` (`{}`) `{}`:{}",
309                escape_backticks(&finding.reference.entry_name),
310                escape_backticks(&finding.reference.catalog_name),
311                rel(&finding.reference.path),
312                finding.reference.line,
313            );
314            if !finding.reference.available_in_catalogs.is_empty() {
315                use std::fmt::Write as _;
316                let alts = finding
317                    .reference
318                    .available_in_catalogs
319                    .iter()
320                    .map(|c| format!("`{}`", escape_backticks(c)))
321                    .collect::<Vec<_>>()
322                    .join(", ");
323                let _ = write!(row, " (available in: {alts})");
324            }
325            vec![row]
326        },
327    );
328    markdown_section(
329        &mut out,
330        &results.unused_dependency_overrides,
331        "Unused dependency overrides",
332        |finding| {
333            use std::fmt::Write as _;
334            let mut row = format!(
335                "- `{}` -> `{}` (`{}`) `{}`:{}",
336                escape_backticks(&finding.entry.raw_key),
337                escape_backticks(&finding.entry.version_range),
338                finding.entry.source.as_label(),
339                rel(&finding.entry.path),
340                finding.entry.line,
341            );
342            if let Some(hint) = &finding.entry.hint {
343                let _ = write!(row, " (hint: {})", escape_backticks(hint));
344            }
345            vec![row]
346        },
347    );
348    markdown_section(
349        &mut out,
350        &results.misconfigured_dependency_overrides,
351        "Misconfigured dependency overrides",
352        |finding| {
353            vec![format!(
354                "- `{}` -> `{}` (`{}`) `{}`:{} ({})",
355                escape_backticks(&finding.entry.raw_key),
356                escape_backticks(&finding.entry.raw_value),
357                finding.entry.source.as_label(),
358                rel(&finding.entry.path),
359                finding.entry.line,
360                finding.entry.reason.describe(),
361            )]
362        },
363    );
364
365    out
366}
367
368/// Print grouped markdown output: each group gets an `## owner (N issues)` heading.
369pub(super) fn print_grouped_markdown(groups: &[ResultGroup], root: &Path) {
370    let total: usize = groups.iter().map(|g| g.results.total_issues()).sum();
371
372    if total == 0 {
373        println!("## Fallow: no issues found");
374        return;
375    }
376
377    println!(
378        "## Fallow: {total} issue{} found (grouped)\n",
379        plural(total)
380    );
381
382    for group in groups {
383        let count = group.results.total_issues();
384        if count == 0 {
385            continue;
386        }
387        println!(
388            "## {} ({count} issue{})\n",
389            escape_backticks(&group.key),
390            plural(count)
391        );
392        // Section-mode: surface the section's default owners under the heading
393        // so PR comment dashboards can see who approves without re-opening
394        // CODEOWNERS. `owners` is `None` outside of `--group-by section` and
395        // empty for the `(no section)` / `(unowned)` buckets.
396        if let Some(ref owners) = group.owners
397            && !owners.is_empty()
398        {
399            let joined = owners
400                .iter()
401                .map(|o| escape_backticks(o))
402                .collect::<Vec<_>>()
403                .join(" ");
404            println!("Owners: {joined}\n");
405        }
406        // build_markdown already emits its own `## Fallow: N issues found` header;
407        // we re-use the section-level rendering by extracting just the section body.
408        let body = build_markdown(&group.results, root);
409        // Skip the first `## Fallow: ...` line from build_markdown and print the rest.
410        let sections = body
411            .strip_prefix("## Fallow: no issues found\n")
412            .or_else(|| body.find("\n\n").map(|pos| &body[pos + 2..]))
413            .unwrap_or(&body);
414        print!("{sections}");
415    }
416}
417
418fn format_export(e: &UnusedExport) -> String {
419    let re = if e.is_re_export { " (re-export)" } else { "" };
420    format!(":{} `{}`{re}", e.line, escape_backticks(&e.export_name))
421}
422
423fn format_private_type_leak(
424    entry: &fallow_types::output_dead_code::PrivateTypeLeakFinding,
425) -> String {
426    let e = &entry.leak;
427    format!(
428        ":{} `{}` references private type `{}`",
429        e.line,
430        escape_backticks(&e.export_name),
431        escape_backticks(&e.type_name)
432    )
433}
434
435fn format_member(m: &UnusedMember) -> String {
436    format!(
437        ":{} `{}.{}`",
438        m.line,
439        escape_backticks(&m.parent_name),
440        escape_backticks(&m.member_name)
441    )
442}
443
444fn format_dependency(
445    dep_name: &str,
446    pkg_path: &Path,
447    used_in_workspaces: &[std::path::PathBuf],
448    root: &Path,
449) -> Vec<String> {
450    let name = escape_backticks(dep_name);
451    let pkg_label = relative_path(pkg_path, root).display().to_string();
452    let workspace_context = if used_in_workspaces.is_empty() {
453        String::new()
454    } else {
455        let workspaces = used_in_workspaces
456            .iter()
457            .map(|path| escape_backticks(&relative_path(path, root).display().to_string()))
458            .collect::<Vec<_>>()
459            .join(", ");
460        format!("; imported in {workspaces}")
461    };
462    if pkg_label == "package.json" && workspace_context.is_empty() {
463        vec![format!("- `{name}`")]
464    } else {
465        let label = if pkg_label == "package.json" {
466            workspace_context.trim_start_matches("; ").to_string()
467        } else {
468            format!("{}{workspace_context}", escape_backticks(&pkg_label))
469        };
470        vec![format!("- `{name}` ({label})")]
471    }
472}
473
474/// Emit a markdown section with a header and per-item lines. Skipped if empty.
475fn markdown_section<T>(
476    out: &mut String,
477    items: &[T],
478    title: &str,
479    format_lines: impl Fn(&T) -> Vec<String>,
480) {
481    if items.is_empty() {
482        return;
483    }
484    let _ = write!(out, "### {title} ({})\n\n", items.len());
485    for item in items {
486        for line in format_lines(item) {
487            out.push_str(&line);
488            out.push('\n');
489        }
490    }
491    out.push('\n');
492}
493
494/// Emit a markdown section whose items are grouped by file path.
495fn markdown_grouped_section<'a, T>(
496    out: &mut String,
497    items: &'a [T],
498    title: &str,
499    root: &Path,
500    get_path: impl Fn(&'a T) -> &'a Path,
501    format_detail: impl Fn(&T) -> String,
502) {
503    if items.is_empty() {
504        return;
505    }
506    let _ = write!(out, "### {title} ({})\n\n", items.len());
507
508    let mut indices: Vec<usize> = (0..items.len()).collect();
509    indices.sort_by(|&a, &b| get_path(&items[a]).cmp(get_path(&items[b])));
510
511    let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
512    let mut last_file = String::new();
513    for &i in &indices {
514        let item = &items[i];
515        let file_str = rel(get_path(item));
516        if file_str != last_file {
517            let _ = writeln!(out, "- `{file_str}`");
518            last_file = file_str;
519        }
520        let _ = writeln!(out, "  - {}", format_detail(item));
521    }
522    out.push('\n');
523}
524
525// ── Duplication markdown output ──────────────────────────────────
526
527pub(super) fn print_duplication_markdown(report: &DuplicationReport, root: &Path) {
528    println!("{}", build_duplication_markdown(report, root));
529}
530
531/// Build markdown output for duplication results.
532#[must_use]
533pub fn build_duplication_markdown(report: &DuplicationReport, root: &Path) -> String {
534    let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
535
536    let mut out = String::new();
537
538    if report.clone_groups.is_empty() {
539        out.push_str("## Fallow: no code duplication found\n");
540        return out;
541    }
542
543    let stats = &report.stats;
544    let _ = write!(
545        out,
546        "## Fallow: {} clone group{} found ({:.1}% duplication)\n\n",
547        stats.clone_groups,
548        plural(stats.clone_groups),
549        stats.duplication_percentage,
550    );
551
552    out.push_str("### Duplicates\n\n");
553    for (i, group) in report.clone_groups.iter().enumerate() {
554        let instance_count = group.instances.len();
555        let _ = write!(
556            out,
557            "**Clone group {}** ({} lines, {instance_count} instance{})\n\n",
558            i + 1,
559            group.line_count,
560            plural(instance_count)
561        );
562        for instance in &group.instances {
563            let relative = rel(&instance.file);
564            let _ = writeln!(
565                out,
566                "- `{relative}:{}-{}`",
567                instance.start_line, instance.end_line
568            );
569        }
570        out.push('\n');
571    }
572
573    // Clone families
574    if !report.clone_families.is_empty() {
575        out.push_str("### Clone Families\n\n");
576        for (i, family) in report.clone_families.iter().enumerate() {
577            let file_names: Vec<_> = family.files.iter().map(|f| rel(f)).collect();
578            let _ = write!(
579                out,
580                "**Family {}** ({} group{}, {} lines across {})\n\n",
581                i + 1,
582                family.groups.len(),
583                plural(family.groups.len()),
584                family.total_duplicated_lines,
585                file_names
586                    .iter()
587                    .map(|s| format!("`{s}`"))
588                    .collect::<Vec<_>>()
589                    .join(", "),
590            );
591            for suggestion in &family.suggestions {
592                let savings = if suggestion.estimated_savings > 0 {
593                    format!(" (~{} lines saved)", suggestion.estimated_savings)
594                } else {
595                    String::new()
596                };
597                let _ = writeln!(out, "- {}{savings}", suggestion.description);
598            }
599            out.push('\n');
600        }
601    }
602
603    // Summary line
604    let _ = writeln!(
605        out,
606        "**Summary:** {} duplicated lines ({:.1}%) across {} file{}",
607        stats.duplicated_lines,
608        stats.duplication_percentage,
609        stats.files_with_clones,
610        plural(stats.files_with_clones),
611    );
612
613    out
614}
615
616// ── Health markdown output ──────────────────────────────────────────
617
618pub(super) fn print_health_markdown(report: &crate::health_types::HealthReport, root: &Path) {
619    println!("{}", build_health_markdown(report, root));
620}
621
622/// Build markdown output for health (complexity) results.
623#[must_use]
624pub fn build_health_markdown(report: &crate::health_types::HealthReport, root: &Path) -> String {
625    let mut out = String::new();
626
627    if let Some(ref hs) = report.health_score {
628        let _ = writeln!(out, "## Health Score: {:.0} ({})\n", hs.score, hs.grade);
629    }
630
631    write_trend_section(&mut out, report);
632    write_vital_signs_section(&mut out, report);
633
634    if report.findings.is_empty()
635        && report.file_scores.is_empty()
636        && report.coverage_gaps.is_none()
637        && report.hotspots.is_empty()
638        && report.targets.is_empty()
639        && report.runtime_coverage.is_none()
640    {
641        if report.vital_signs.is_none() {
642            let _ = write!(
643                out,
644                "## Fallow: no functions exceed complexity thresholds\n\n\
645                 **{}** functions analyzed (max cyclomatic: {}, max cognitive: {}, max CRAP: {:.1})\n",
646                report.summary.functions_analyzed,
647                report.summary.max_cyclomatic_threshold,
648                report.summary.max_cognitive_threshold,
649                report.summary.max_crap_threshold,
650            );
651        }
652        return out;
653    }
654
655    write_findings_section(&mut out, report, root);
656    write_runtime_coverage_section(&mut out, report, root);
657    write_coverage_gaps_section(&mut out, report, root);
658    write_file_scores_section(&mut out, report, root);
659    write_hotspots_section(&mut out, report, root);
660    write_targets_section(&mut out, report, root);
661    write_metric_legend(&mut out, report);
662
663    out
664}
665
666fn write_runtime_coverage_section(
667    out: &mut String,
668    report: &crate::health_types::HealthReport,
669    root: &Path,
670) {
671    let Some(ref production) = report.runtime_coverage else {
672        return;
673    };
674    // Prepend a blank line so the heading is not concatenated to the previous
675    // section (GFM requires a blank line before headings to avoid the heading
676    // being parsed as a paragraph continuation).
677    if !out.is_empty() && !out.ends_with("\n\n") {
678        out.push('\n');
679    }
680    let _ = writeln!(
681        out,
682        "## Runtime Coverage\n\n- Verdict: {}\n- Functions tracked: {}\n- Hit: {}\n- Unhit: {}\n- Untracked: {}\n- Coverage: {:.1}%\n- Traces observed: {}\n- Period: {} day(s), {} deployment(s)\n",
683        production.verdict,
684        production.summary.functions_tracked,
685        production.summary.functions_hit,
686        production.summary.functions_unhit,
687        production.summary.functions_untracked,
688        production.summary.coverage_percent,
689        production.summary.trace_count,
690        production.summary.period_days,
691        production.summary.deployments_seen,
692    );
693    if let Some(watermark) = production.watermark {
694        let _ = writeln!(out, "- Watermark: {watermark}\n");
695    }
696    if let Some(ref quality) = production.summary.capture_quality
697        && quality.lazy_parse_warning
698    {
699        let window = super::human::health::format_window(quality.window_seconds);
700        let _ = writeln!(
701            out,
702            "- Capture quality: short window ({} from {} instance(s), {:.1}% of functions untracked); lazy-parsed scripts may not appear.\n",
703            window, quality.instances_observed, quality.untracked_ratio_percent,
704        );
705    }
706    let rel = |p: &Path| {
707        escape_backticks(&normalize_uri(
708            &relative_path(p, root).display().to_string(),
709        ))
710    };
711    if !production.findings.is_empty() {
712        out.push_str("| ID | Path | Function | Verdict | Invocations | Confidence |\n");
713        out.push_str("|:---|:-----|:---------|:--------|------------:|:-----------|\n");
714        for finding in &production.findings {
715            let invocations = finding
716                .invocations
717                .map_or_else(|| "-".to_owned(), |hits| hits.to_string());
718            let _ = writeln!(
719                out,
720                "| `{}` | `{}`:{} | `{}` | {} | {} | {} |",
721                escape_backticks(&finding.id),
722                rel(&finding.path),
723                finding.line,
724                escape_backticks(&finding.function),
725                finding.verdict,
726                invocations,
727                finding.confidence,
728            );
729        }
730        out.push('\n');
731    }
732    if !production.hot_paths.is_empty() {
733        out.push_str("| ID | Hot path | Function | Invocations | Percentile |\n");
734        out.push_str("|:---|:---------|:---------|------------:|-----------:|\n");
735        for entry in &production.hot_paths {
736            let _ = writeln!(
737                out,
738                "| `{}` | `{}`:{} | `{}` | {} | {} |",
739                escape_backticks(&entry.id),
740                rel(&entry.path),
741                entry.line,
742                escape_backticks(&entry.function),
743                entry.invocations,
744                entry.percentile,
745            );
746        }
747        out.push('\n');
748    }
749}
750
751/// Write the trend comparison table to the output.
752fn write_trend_section(out: &mut String, report: &crate::health_types::HealthReport) {
753    let Some(ref trend) = report.health_trend else {
754        return;
755    };
756    let sha_str = trend
757        .compared_to
758        .git_sha
759        .as_deref()
760        .map_or(String::new(), |sha| format!(" ({sha})"));
761    let _ = writeln!(
762        out,
763        "## Trend (vs {}{})\n",
764        trend
765            .compared_to
766            .timestamp
767            .get(..10)
768            .unwrap_or(&trend.compared_to.timestamp),
769        sha_str,
770    );
771    out.push_str("| Metric | Previous | Current | Delta | Direction |\n");
772    out.push_str("|:-------|:---------|:--------|:------|:----------|\n");
773    for m in &trend.metrics {
774        let fmt_val = |v: f64| -> String {
775            if m.unit == "%" {
776                format!("{v:.1}%")
777            } else if (v - v.round()).abs() < 0.05 {
778                format!("{v:.0}")
779            } else {
780                format!("{v:.1}")
781            }
782        };
783        let prev = fmt_val(m.previous);
784        let cur = fmt_val(m.current);
785        let delta = if m.unit == "%" {
786            format!("{:+.1}%", m.delta)
787        } else if (m.delta - m.delta.round()).abs() < 0.05 {
788            format!("{:+.0}", m.delta)
789        } else {
790            format!("{:+.1}", m.delta)
791        };
792        let _ = writeln!(
793            out,
794            "| {} | {} | {} | {} | {} {} |",
795            m.label,
796            prev,
797            cur,
798            delta,
799            m.direction.arrow(),
800            m.direction.label(),
801        );
802    }
803    let md_sha = trend
804        .compared_to
805        .git_sha
806        .as_deref()
807        .map_or(String::new(), |sha| format!(" ({sha})"));
808    let _ = writeln!(
809        out,
810        "\n*vs {}{} · {} {} available*\n",
811        trend
812            .compared_to
813            .timestamp
814            .get(..10)
815            .unwrap_or(&trend.compared_to.timestamp),
816        md_sha,
817        trend.snapshots_loaded,
818        if trend.snapshots_loaded == 1 {
819            "snapshot"
820        } else {
821            "snapshots"
822        },
823    );
824}
825
826/// Write the vital signs summary table to the output.
827fn write_vital_signs_section(out: &mut String, report: &crate::health_types::HealthReport) {
828    let Some(ref vs) = report.vital_signs else {
829        return;
830    };
831    out.push_str("## Vital Signs\n\n");
832    out.push_str("| Metric | Value |\n");
833    out.push_str("|:-------|------:|\n");
834    if vs.total_loc > 0 {
835        let _ = writeln!(out, "| Total LOC | {} |", vs.total_loc);
836    }
837    let _ = writeln!(out, "| Avg Cyclomatic | {:.1} |", vs.avg_cyclomatic);
838    let _ = writeln!(out, "| P90 Cyclomatic | {} |", vs.p90_cyclomatic);
839    if let Some(v) = vs.dead_file_pct {
840        let _ = writeln!(out, "| Dead Files | {v:.1}% |");
841    }
842    if let Some(v) = vs.dead_export_pct {
843        let _ = writeln!(out, "| Dead Exports | {v:.1}% |");
844    }
845    if let Some(v) = vs.maintainability_avg {
846        let _ = writeln!(out, "| Maintainability (avg) | {v:.1} |");
847    }
848    if let Some(v) = vs.hotspot_count {
849        let _ = writeln!(out, "| Hotspots | {v} |");
850    }
851    if let Some(v) = vs.circular_dep_count {
852        let _ = writeln!(out, "| Circular Deps | {v} |");
853    }
854    if let Some(v) = vs.unused_dep_count {
855        let _ = writeln!(out, "| Unused Deps | {v} |");
856    }
857    out.push('\n');
858}
859
860/// Write the complexity findings table to the output.
861fn write_findings_section(
862    out: &mut String,
863    report: &crate::health_types::HealthReport,
864    root: &Path,
865) {
866    if report.findings.is_empty() {
867        return;
868    }
869
870    let rel = |p: &Path| {
871        escape_backticks(&normalize_uri(
872            &relative_path(p, root).display().to_string(),
873        ))
874    };
875
876    let count = report.summary.functions_above_threshold;
877    let shown = report.findings.len();
878    if shown < count {
879        let _ = write!(
880            out,
881            "## Fallow: {count} high complexity function{} ({shown} shown)\n\n",
882            plural(count),
883        );
884    } else {
885        let _ = write!(
886            out,
887            "## Fallow: {count} high complexity function{}\n\n",
888            plural(count),
889        );
890    }
891
892    out.push_str("| File | Function | Severity | Cyclomatic | Cognitive | CRAP | Lines |\n");
893    out.push_str("|:-----|:---------|:---------|:-----------|:----------|:-----|:------|\n");
894
895    for finding in &report.findings {
896        let file_str = rel(&finding.path);
897        let cyc_marker = if finding.cyclomatic > report.summary.max_cyclomatic_threshold {
898            " **!**"
899        } else {
900            ""
901        };
902        let cog_marker = if finding.cognitive > report.summary.max_cognitive_threshold {
903            " **!**"
904        } else {
905            ""
906        };
907        let severity_label = match finding.severity {
908            crate::health_types::FindingSeverity::Critical => "critical",
909            crate::health_types::FindingSeverity::High => "high",
910            crate::health_types::FindingSeverity::Moderate => "moderate",
911        };
912        let crap_cell = match finding.crap {
913            Some(crap) => {
914                let marker = if crap >= report.summary.max_crap_threshold {
915                    " **!**"
916                } else {
917                    ""
918                };
919                format!("{crap:.1}{marker}")
920            }
921            None => "-".to_string(),
922        };
923        let _ = writeln!(
924            out,
925            "| `{file_str}:{line}` | `{name}` | {severity_label} | {cyc}{cyc_marker} | {cog}{cog_marker} | {crap_cell} | {lines} |",
926            line = finding.line,
927            name = escape_backticks(&finding.name),
928            cyc = finding.cyclomatic,
929            cog = finding.cognitive,
930            lines = finding.line_count,
931        );
932    }
933
934    let s = &report.summary;
935    let _ = write!(
936        out,
937        "\n**{files}** files, **{funcs}** functions analyzed \
938         (thresholds: cyclomatic > {cyc}, cognitive > {cog}, CRAP >= {crap:.1})\n",
939        files = s.files_analyzed,
940        funcs = s.functions_analyzed,
941        cyc = s.max_cyclomatic_threshold,
942        cog = s.max_cognitive_threshold,
943        crap = s.max_crap_threshold,
944    );
945}
946
947/// Write the file health scores table to the output.
948fn write_file_scores_section(
949    out: &mut String,
950    report: &crate::health_types::HealthReport,
951    root: &Path,
952) {
953    if report.file_scores.is_empty() {
954        return;
955    }
956
957    let rel = |p: &Path| {
958        escape_backticks(&normalize_uri(
959            &relative_path(p, root).display().to_string(),
960        ))
961    };
962
963    out.push('\n');
964    let _ = writeln!(
965        out,
966        "### File Health Scores ({} files)\n",
967        report.file_scores.len(),
968    );
969    out.push_str("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density | Risk |\n");
970    out.push_str("|:-----|:---------------|:-------|:--------|:----------|:--------|:-----|\n");
971
972    for score in &report.file_scores {
973        let file_str = rel(&score.path);
974        let _ = writeln!(
975            out,
976            "| `{file_str}` | {mi:.1} | {fi} | {fan_out} | {dead:.0}% | {density:.2} | {crap:.1} |",
977            mi = score.maintainability_index,
978            fi = score.fan_in,
979            fan_out = score.fan_out,
980            dead = score.dead_code_ratio * 100.0,
981            density = score.complexity_density,
982            crap = score.crap_max,
983        );
984    }
985
986    if let Some(avg) = report.summary.average_maintainability {
987        let _ = write!(out, "\n**Average maintainability index:** {avg:.1}/100\n");
988    }
989}
990
991fn write_coverage_gaps_section(
992    out: &mut String,
993    report: &crate::health_types::HealthReport,
994    root: &Path,
995) {
996    let Some(ref gaps) = report.coverage_gaps else {
997        return;
998    };
999
1000    out.push('\n');
1001    let _ = writeln!(out, "### Coverage Gaps\n");
1002    let _ = writeln!(
1003        out,
1004        "*{} untested files · {} untested exports · {:.1}% file coverage*\n",
1005        gaps.summary.untested_files, gaps.summary.untested_exports, gaps.summary.file_coverage_pct,
1006    );
1007
1008    if gaps.files.is_empty() && gaps.exports.is_empty() {
1009        out.push_str("_No coverage gaps found in scope._\n");
1010        return;
1011    }
1012
1013    if !gaps.files.is_empty() {
1014        out.push_str("#### Files\n");
1015        for item in &gaps.files {
1016            let file_str = escape_backticks(&normalize_uri(
1017                &relative_path(&item.file.path, root).display().to_string(),
1018            ));
1019            let _ = writeln!(
1020                out,
1021                "- `{file_str}` ({count} value export{})",
1022                if item.file.value_export_count == 1 {
1023                    ""
1024                } else {
1025                    "s"
1026                },
1027                count = item.file.value_export_count,
1028            );
1029        }
1030        out.push('\n');
1031    }
1032
1033    if !gaps.exports.is_empty() {
1034        out.push_str("#### Exports\n");
1035        for item in &gaps.exports {
1036            let file_str = escape_backticks(&normalize_uri(
1037                &relative_path(&item.export.path, root).display().to_string(),
1038            ));
1039            let _ = writeln!(
1040                out,
1041                "- `{file_str}`:{} `{}`",
1042                item.export.line, item.export.export_name
1043            );
1044        }
1045    }
1046}
1047
1048/// Write the hotspots table to the output.
1049/// Render the four ownership table cells (bus, top contributor, declared
1050/// owner, notes) for the markdown hotspots table. Cells fall back to an
1051/// en-dash (U+2013) when ownership data is missing for an entry.
1052fn ownership_md_cells(
1053    ownership: Option<&crate::health_types::OwnershipMetrics>,
1054) -> (String, String, String, String) {
1055    let Some(o) = ownership else {
1056        let dash = "\u{2013}".to_string();
1057        return (dash.clone(), dash.clone(), dash.clone(), dash);
1058    };
1059    let bus = o.bus_factor.to_string();
1060    let top = format!(
1061        "`{}` ({:.0}%)",
1062        o.top_contributor.identifier,
1063        o.top_contributor.share * 100.0,
1064    );
1065    let owner = o
1066        .declared_owner
1067        .as_deref()
1068        .map_or_else(|| "\u{2013}".to_string(), str::to_string);
1069    let mut notes: Vec<&str> = Vec::new();
1070    if o.unowned == Some(true) {
1071        notes.push("**unowned**");
1072    }
1073    if o.drift {
1074        notes.push("drift");
1075    }
1076    let notes_str = if notes.is_empty() {
1077        "\u{2013}".to_string()
1078    } else {
1079        notes.join(", ")
1080    };
1081    (bus, top, owner, notes_str)
1082}
1083
1084fn write_hotspots_section(
1085    out: &mut String,
1086    report: &crate::health_types::HealthReport,
1087    root: &Path,
1088) {
1089    if report.hotspots.is_empty() {
1090        return;
1091    }
1092
1093    let rel = |p: &Path| {
1094        escape_backticks(&normalize_uri(
1095            &relative_path(p, root).display().to_string(),
1096        ))
1097    };
1098
1099    out.push('\n');
1100    let header = report.hotspot_summary.as_ref().map_or_else(
1101        || format!("### Hotspots ({} files)\n", report.hotspots.len()),
1102        |summary| {
1103            format!(
1104                "### Hotspots ({} files, since {})\n",
1105                report.hotspots.len(),
1106                summary.since,
1107            )
1108        },
1109    );
1110    let _ = writeln!(out, "{header}");
1111    // Add ownership columns when at least one entry has ownership data.
1112    let any_ownership = report.hotspots.iter().any(|e| e.ownership.is_some());
1113    if any_ownership {
1114        out.push_str(
1115            "| File | Score | Commits | Churn | Density | Fan-in | Trend | Bus | Top | Owner | Notes |\n"
1116        );
1117        out.push_str(
1118            "|:-----|:------|:--------|:------|:--------|:-------|:------|:----|:----|:------|:------|\n"
1119        );
1120    } else {
1121        out.push_str("| File | Score | Commits | Churn | Density | Fan-in | Trend |\n");
1122        out.push_str("|:-----|:------|:--------|:------|:--------|:-------|:------|\n");
1123    }
1124
1125    for entry in &report.hotspots {
1126        let file_str = rel(&entry.path);
1127        if any_ownership {
1128            let (bus, top, owner, notes) = ownership_md_cells(entry.ownership.as_ref());
1129            let _ = writeln!(
1130                out,
1131                "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} | {bus} | {top} | {owner} | {notes} |",
1132                score = entry.score,
1133                commits = entry.commits,
1134                churn = entry.lines_added + entry.lines_deleted,
1135                density = entry.complexity_density,
1136                fi = entry.fan_in,
1137                trend = entry.trend,
1138            );
1139        } else {
1140            let _ = writeln!(
1141                out,
1142                "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} |",
1143                score = entry.score,
1144                commits = entry.commits,
1145                churn = entry.lines_added + entry.lines_deleted,
1146                density = entry.complexity_density,
1147                fi = entry.fan_in,
1148                trend = entry.trend,
1149            );
1150        }
1151    }
1152
1153    if let Some(ref summary) = report.hotspot_summary
1154        && summary.files_excluded > 0
1155    {
1156        let _ = write!(
1157            out,
1158            "\n*{} file{} excluded (< {} commits)*\n",
1159            summary.files_excluded,
1160            plural(summary.files_excluded),
1161            summary.min_commits,
1162        );
1163    }
1164}
1165
1166/// Write the refactoring targets table to the output.
1167fn write_targets_section(
1168    out: &mut String,
1169    report: &crate::health_types::HealthReport,
1170    root: &Path,
1171) {
1172    if report.targets.is_empty() {
1173        return;
1174    }
1175    let _ = write!(
1176        out,
1177        "\n### Refactoring Targets ({})\n\n",
1178        report.targets.len()
1179    );
1180    out.push_str("| Efficiency | Category | Effort / Confidence | File | Recommendation |\n");
1181    out.push_str("|:-----------|:---------|:--------------------|:-----|:---------------|\n");
1182    for target in &report.targets {
1183        let file_str = normalize_uri(&relative_path(&target.path, root).display().to_string());
1184        let category = target.category.label();
1185        let effort = target.effort.label();
1186        let confidence = target.confidence.label();
1187        let _ = writeln!(
1188            out,
1189            "| {:.1} | {category} | {effort} / {confidence} | `{file_str}` | {} |",
1190            target.efficiency, target.recommendation,
1191        );
1192    }
1193}
1194
1195/// Write the metric legend collapsible section to the output.
1196fn write_metric_legend(out: &mut String, report: &crate::health_types::HealthReport) {
1197    let has_scores = !report.file_scores.is_empty();
1198    let has_coverage = report.coverage_gaps.is_some();
1199    let has_hotspots = !report.hotspots.is_empty();
1200    let has_targets = !report.targets.is_empty();
1201    if !has_scores && !has_coverage && !has_hotspots && !has_targets {
1202        return;
1203    }
1204    out.push_str("\n---\n\n<details><summary>Metric definitions</summary>\n\n");
1205    if has_scores {
1206        out.push_str("- **MI**: Maintainability Index (0\u{2013}100, higher is better)\n");
1207        out.push_str("- **Fan-in**: files that import this file (blast radius)\n");
1208        out.push_str("- **Fan-out**: files this file imports (coupling)\n");
1209        out.push_str("- **Dead Code**: % of value exports with zero references\n");
1210        out.push_str("- **Density**: cyclomatic complexity / lines of code\n");
1211    }
1212    if has_coverage {
1213        out.push_str(
1214            "- **File coverage**: runtime files also reachable from a discovered test root\n",
1215        );
1216        out.push_str("- **Untested export**: export with no reference chain from any test-reachable module\n");
1217    }
1218    if has_hotspots {
1219        out.push_str("- **Score**: churn \u{00d7} complexity (0\u{2013}100, higher = riskier)\n");
1220        out.push_str("- **Commits**: commits in the analysis window\n");
1221        out.push_str("- **Churn**: total lines added + deleted\n");
1222        out.push_str("- **Trend**: accelerating / stable / cooling\n");
1223    }
1224    if has_targets {
1225        out.push_str(
1226            "- **Efficiency**: priority / effort (higher = better quick-win value, default sort)\n",
1227        );
1228        out.push_str("- **Category**: recommendation type (churn+complexity, high impact, dead code, complexity, coupling, circular dep)\n");
1229        out.push_str("- **Effort**: estimated effort (low / medium / high) based on file size, function count, and fan-in\n");
1230        out.push_str("- **Confidence**: recommendation reliability (high = deterministic analysis, medium = heuristic, low = git-dependent)\n");
1231    }
1232    out.push_str(
1233        "\n[Full metric reference](https://docs.fallow.tools/explanations/metrics)\n\n</details>\n",
1234    );
1235}
1236
1237#[cfg(test)]
1238mod tests {
1239    use super::*;
1240    use crate::report::test_helpers::sample_results;
1241    use fallow_core::duplicates::{
1242        CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
1243        RefactoringKind, RefactoringSuggestion,
1244    };
1245    use fallow_core::results::*;
1246    use std::path::PathBuf;
1247
1248    #[test]
1249    fn markdown_empty_results_no_issues() {
1250        let root = PathBuf::from("/project");
1251        let results = AnalysisResults::default();
1252        let md = build_markdown(&results, &root);
1253        assert_eq!(md, "## Fallow: no issues found\n");
1254    }
1255
1256    #[test]
1257    fn markdown_contains_header_with_count() {
1258        let root = PathBuf::from("/project");
1259        let results = sample_results(&root);
1260        let md = build_markdown(&results, &root);
1261        assert!(md.starts_with(&format!(
1262            "## Fallow: {} issues found\n",
1263            results.total_issues()
1264        )));
1265    }
1266
1267    #[test]
1268    fn markdown_contains_all_sections() {
1269        let root = PathBuf::from("/project");
1270        let results = sample_results(&root);
1271        let md = build_markdown(&results, &root);
1272
1273        assert!(md.contains("### Unused files (1)"));
1274        assert!(md.contains("### Unused exports (1)"));
1275        assert!(md.contains("### Unused type exports (1)"));
1276        assert!(md.contains("### Unused dependencies (1)"));
1277        assert!(md.contains("### Unused devDependencies (1)"));
1278        assert!(md.contains("### Unused enum members (1)"));
1279        assert!(md.contains("### Unused class members (1)"));
1280        assert!(md.contains("### Unresolved imports (1)"));
1281        assert!(md.contains("### Unlisted dependencies (1)"));
1282        assert!(md.contains("### Duplicate exports (1)"));
1283        assert!(md.contains("### Type-only dependencies"));
1284        assert!(md.contains("### Test-only production dependencies"));
1285        assert!(md.contains("### Circular dependencies (1)"));
1286    }
1287
1288    #[test]
1289    fn markdown_unused_file_format() {
1290        let root = PathBuf::from("/project");
1291        let mut results = AnalysisResults::default();
1292        results
1293            .unused_files
1294            .push(UnusedFileFinding::with_actions(UnusedFile {
1295                path: root.join("src/dead.ts"),
1296            }));
1297        let md = build_markdown(&results, &root);
1298        assert!(md.contains("- `src/dead.ts`"));
1299    }
1300
1301    #[test]
1302    fn markdown_unused_export_grouped_by_file() {
1303        let root = PathBuf::from("/project");
1304        let mut results = AnalysisResults::default();
1305        results
1306            .unused_exports
1307            .push(UnusedExportFinding::with_actions(UnusedExport {
1308                path: root.join("src/utils.ts"),
1309                export_name: "helperFn".to_string(),
1310                is_type_only: false,
1311                line: 10,
1312                col: 4,
1313                span_start: 120,
1314                is_re_export: false,
1315            }));
1316        let md = build_markdown(&results, &root);
1317        assert!(md.contains("- `src/utils.ts`"));
1318        assert!(md.contains(":10 `helperFn`"));
1319    }
1320
1321    #[test]
1322    fn markdown_re_export_tagged() {
1323        let root = PathBuf::from("/project");
1324        let mut results = AnalysisResults::default();
1325        results
1326            .unused_exports
1327            .push(UnusedExportFinding::with_actions(UnusedExport {
1328                path: root.join("src/index.ts"),
1329                export_name: "reExported".to_string(),
1330                is_type_only: false,
1331                line: 1,
1332                col: 0,
1333                span_start: 0,
1334                is_re_export: true,
1335            }));
1336        let md = build_markdown(&results, &root);
1337        assert!(md.contains("(re-export)"));
1338    }
1339
1340    #[test]
1341    fn markdown_unused_dep_format() {
1342        let root = PathBuf::from("/project");
1343        let mut results = AnalysisResults::default();
1344        results
1345            .unused_dependencies
1346            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1347                package_name: "lodash".to_string(),
1348                location: DependencyLocation::Dependencies,
1349                path: root.join("package.json"),
1350                line: 5,
1351                used_in_workspaces: Vec::new(),
1352            }));
1353        let md = build_markdown(&results, &root);
1354        assert!(md.contains("- `lodash`"));
1355    }
1356
1357    #[test]
1358    fn markdown_circular_dep_format() {
1359        let root = PathBuf::from("/project");
1360        let mut results = AnalysisResults::default();
1361        results
1362            .circular_dependencies
1363            .push(CircularDependencyFinding::with_actions(
1364                CircularDependency {
1365                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1366                    length: 2,
1367                    line: 3,
1368                    col: 0,
1369                    is_cross_package: false,
1370                },
1371            ));
1372        let md = build_markdown(&results, &root);
1373        assert!(md.contains("`src/a.ts`"));
1374        assert!(md.contains("`src/b.ts`"));
1375        assert!(md.contains("\u{2192}"));
1376    }
1377
1378    #[test]
1379    fn markdown_strips_root_prefix() {
1380        let root = PathBuf::from("/project");
1381        let mut results = AnalysisResults::default();
1382        results
1383            .unused_files
1384            .push(UnusedFileFinding::with_actions(UnusedFile {
1385                path: PathBuf::from("/project/src/deep/nested/file.ts"),
1386            }));
1387        let md = build_markdown(&results, &root);
1388        assert!(md.contains("`src/deep/nested/file.ts`"));
1389        assert!(!md.contains("/project/"));
1390    }
1391
1392    #[test]
1393    fn markdown_single_issue_no_plural() {
1394        let root = PathBuf::from("/project");
1395        let mut results = AnalysisResults::default();
1396        results
1397            .unused_files
1398            .push(UnusedFileFinding::with_actions(UnusedFile {
1399                path: root.join("src/dead.ts"),
1400            }));
1401        let md = build_markdown(&results, &root);
1402        assert!(md.starts_with("## Fallow: 1 issue found\n"));
1403    }
1404
1405    #[test]
1406    fn markdown_type_only_dep_format() {
1407        let root = PathBuf::from("/project");
1408        let mut results = AnalysisResults::default();
1409        results
1410            .type_only_dependencies
1411            .push(TypeOnlyDependencyFinding::with_actions(
1412                TypeOnlyDependency {
1413                    package_name: "zod".to_string(),
1414                    path: root.join("package.json"),
1415                    line: 8,
1416                },
1417            ));
1418        let md = build_markdown(&results, &root);
1419        assert!(md.contains("### Type-only dependencies"));
1420        assert!(md.contains("- `zod`"));
1421    }
1422
1423    #[test]
1424    fn markdown_escapes_backticks_in_export_names() {
1425        let root = PathBuf::from("/project");
1426        let mut results = AnalysisResults::default();
1427        results
1428            .unused_exports
1429            .push(UnusedExportFinding::with_actions(UnusedExport {
1430                path: root.join("src/utils.ts"),
1431                export_name: "foo`bar".to_string(),
1432                is_type_only: false,
1433                line: 1,
1434                col: 0,
1435                span_start: 0,
1436                is_re_export: false,
1437            }));
1438        let md = build_markdown(&results, &root);
1439        assert!(md.contains("foo\\`bar"));
1440        assert!(!md.contains("foo`bar`"));
1441    }
1442
1443    #[test]
1444    fn markdown_escapes_backticks_in_package_names() {
1445        let root = PathBuf::from("/project");
1446        let mut results = AnalysisResults::default();
1447        results
1448            .unused_dependencies
1449            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1450                package_name: "pkg`name".to_string(),
1451                location: DependencyLocation::Dependencies,
1452                path: root.join("package.json"),
1453                line: 5,
1454                used_in_workspaces: Vec::new(),
1455            }));
1456        let md = build_markdown(&results, &root);
1457        assert!(md.contains("pkg\\`name"));
1458    }
1459
1460    // ── Duplication markdown ──
1461
1462    #[test]
1463    fn duplication_markdown_empty() {
1464        let report = DuplicationReport::default();
1465        let root = PathBuf::from("/project");
1466        let md = build_duplication_markdown(&report, &root);
1467        assert_eq!(md, "## Fallow: no code duplication found\n");
1468    }
1469
1470    #[test]
1471    fn duplication_markdown_contains_groups() {
1472        let root = PathBuf::from("/project");
1473        let report = DuplicationReport {
1474            clone_groups: vec![CloneGroup {
1475                instances: vec![
1476                    CloneInstance {
1477                        file: root.join("src/a.ts"),
1478                        start_line: 1,
1479                        end_line: 10,
1480                        start_col: 0,
1481                        end_col: 0,
1482                        fragment: String::new(),
1483                    },
1484                    CloneInstance {
1485                        file: root.join("src/b.ts"),
1486                        start_line: 5,
1487                        end_line: 14,
1488                        start_col: 0,
1489                        end_col: 0,
1490                        fragment: String::new(),
1491                    },
1492                ],
1493                token_count: 50,
1494                line_count: 10,
1495            }],
1496            clone_families: vec![],
1497            mirrored_directories: vec![],
1498            stats: DuplicationStats {
1499                total_files: 10,
1500                files_with_clones: 2,
1501                total_lines: 500,
1502                duplicated_lines: 20,
1503                total_tokens: 2500,
1504                duplicated_tokens: 100,
1505                clone_groups: 1,
1506                clone_instances: 2,
1507                duplication_percentage: 4.0,
1508                clone_groups_below_min_occurrences: 0,
1509            },
1510        };
1511        let md = build_duplication_markdown(&report, &root);
1512        assert!(md.contains("**Clone group 1**"));
1513        assert!(md.contains("`src/a.ts:1-10`"));
1514        assert!(md.contains("`src/b.ts:5-14`"));
1515        assert!(md.contains("4.0% duplication"));
1516    }
1517
1518    #[test]
1519    fn duplication_markdown_contains_families() {
1520        let root = PathBuf::from("/project");
1521        let report = DuplicationReport {
1522            clone_groups: vec![CloneGroup {
1523                instances: vec![CloneInstance {
1524                    file: root.join("src/a.ts"),
1525                    start_line: 1,
1526                    end_line: 5,
1527                    start_col: 0,
1528                    end_col: 0,
1529                    fragment: String::new(),
1530                }],
1531                token_count: 30,
1532                line_count: 5,
1533            }],
1534            clone_families: vec![CloneFamily {
1535                files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1536                groups: vec![],
1537                total_duplicated_lines: 20,
1538                total_duplicated_tokens: 100,
1539                suggestions: vec![RefactoringSuggestion {
1540                    kind: RefactoringKind::ExtractFunction,
1541                    description: "Extract shared utility function".to_string(),
1542                    estimated_savings: 15,
1543                }],
1544            }],
1545            mirrored_directories: vec![],
1546            stats: DuplicationStats {
1547                clone_groups: 1,
1548                clone_instances: 1,
1549                duplication_percentage: 2.0,
1550                ..Default::default()
1551            },
1552        };
1553        let md = build_duplication_markdown(&report, &root);
1554        assert!(md.contains("### Clone Families"));
1555        assert!(md.contains("**Family 1**"));
1556        assert!(md.contains("Extract shared utility function"));
1557        assert!(md.contains("~15 lines saved"));
1558    }
1559
1560    // ── Health markdown ──
1561
1562    #[test]
1563    fn health_markdown_empty_no_findings() {
1564        let root = PathBuf::from("/project");
1565        let report = crate::health_types::HealthReport {
1566            summary: crate::health_types::HealthSummary {
1567                files_analyzed: 10,
1568                functions_analyzed: 50,
1569                ..Default::default()
1570            },
1571            ..Default::default()
1572        };
1573        let md = build_health_markdown(&report, &root);
1574        assert!(md.contains("no functions exceed complexity thresholds"));
1575        assert!(md.contains("**50** functions analyzed"));
1576    }
1577
1578    #[test]
1579    fn health_markdown_table_format() {
1580        let root = PathBuf::from("/project");
1581        let report = crate::health_types::HealthReport {
1582            findings: vec![
1583                crate::health_types::ComplexityViolation {
1584                    path: root.join("src/utils.ts"),
1585                    name: "parseExpression".to_string(),
1586                    line: 42,
1587                    col: 0,
1588                    cyclomatic: 25,
1589                    cognitive: 30,
1590                    line_count: 80,
1591                    param_count: 0,
1592                    exceeded: crate::health_types::ExceededThreshold::Both,
1593                    severity: crate::health_types::FindingSeverity::High,
1594                    crap: None,
1595                    coverage_pct: None,
1596                    coverage_tier: None,
1597                    coverage_source: None,
1598                    inherited_from: None,
1599                    component_rollup: None,
1600                }
1601                .into(),
1602            ],
1603            summary: crate::health_types::HealthSummary {
1604                files_analyzed: 10,
1605                functions_analyzed: 50,
1606                functions_above_threshold: 1,
1607                ..Default::default()
1608            },
1609            ..Default::default()
1610        };
1611        let md = build_health_markdown(&report, &root);
1612        assert!(md.contains("## Fallow: 1 high complexity function\n"));
1613        assert!(md.contains("| File | Function |"));
1614        assert!(md.contains("`src/utils.ts:42`"));
1615        assert!(md.contains("`parseExpression`"));
1616        assert!(md.contains("25 **!**"));
1617        assert!(md.contains("30 **!**"));
1618        assert!(md.contains("| 80 |"));
1619        // CRAP column renders `-` when the finding didn't trigger on CRAP.
1620        assert!(md.contains("| - |"));
1621    }
1622
1623    #[test]
1624    fn health_markdown_crap_column_shows_score_and_marker() {
1625        let root = PathBuf::from("/project");
1626        let report = crate::health_types::HealthReport {
1627            findings: vec![
1628                crate::health_types::ComplexityViolation {
1629                    path: root.join("src/risky.ts"),
1630                    name: "branchy".to_string(),
1631                    line: 1,
1632                    col: 0,
1633                    cyclomatic: 67,
1634                    cognitive: 10,
1635                    line_count: 80,
1636                    param_count: 1,
1637                    exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
1638                    severity: crate::health_types::FindingSeverity::Critical,
1639                    crap: Some(182.0),
1640                    coverage_pct: None,
1641                    coverage_tier: None,
1642                    coverage_source: None,
1643                    inherited_from: None,
1644                    component_rollup: None,
1645                }
1646                .into(),
1647            ],
1648            summary: crate::health_types::HealthSummary {
1649                files_analyzed: 1,
1650                functions_analyzed: 1,
1651                functions_above_threshold: 1,
1652                ..Default::default()
1653            },
1654            ..Default::default()
1655        };
1656        let md = build_health_markdown(&report, &root);
1657        assert!(
1658            md.contains("| CRAP |"),
1659            "markdown table should have CRAP column header: {md}"
1660        );
1661        assert!(
1662            md.contains("182.0 **!**"),
1663            "CRAP value should be rendered with a threshold marker: {md}"
1664        );
1665        assert!(
1666            md.contains("CRAP >="),
1667            "trailing summary line should reference the CRAP threshold: {md}"
1668        );
1669    }
1670
1671    #[test]
1672    fn health_markdown_no_marker_when_below_threshold() {
1673        let root = PathBuf::from("/project");
1674        let report = crate::health_types::HealthReport {
1675            findings: vec![
1676                crate::health_types::ComplexityViolation {
1677                    path: root.join("src/utils.ts"),
1678                    name: "helper".to_string(),
1679                    line: 10,
1680                    col: 0,
1681                    cyclomatic: 15,
1682                    cognitive: 20,
1683                    line_count: 30,
1684                    param_count: 0,
1685                    exceeded: crate::health_types::ExceededThreshold::Cognitive,
1686                    severity: crate::health_types::FindingSeverity::High,
1687                    crap: None,
1688                    coverage_pct: None,
1689                    coverage_tier: None,
1690                    coverage_source: None,
1691                    inherited_from: None,
1692                    component_rollup: None,
1693                }
1694                .into(),
1695            ],
1696            summary: crate::health_types::HealthSummary {
1697                files_analyzed: 5,
1698                functions_analyzed: 20,
1699                functions_above_threshold: 1,
1700                ..Default::default()
1701            },
1702            ..Default::default()
1703        };
1704        let md = build_health_markdown(&report, &root);
1705        // Cyclomatic 15 is below threshold 20, no marker
1706        assert!(md.contains("| 15 |"));
1707        // Cognitive 20 exceeds threshold 15, has marker
1708        assert!(md.contains("20 **!**"));
1709    }
1710
1711    #[test]
1712    fn health_markdown_with_targets() {
1713        use crate::health_types::*;
1714
1715        let root = PathBuf::from("/project");
1716        let report = HealthReport {
1717            summary: HealthSummary {
1718                files_analyzed: 10,
1719                functions_analyzed: 50,
1720                ..Default::default()
1721            },
1722            targets: vec![
1723                RefactoringTarget {
1724                    path: PathBuf::from("/project/src/complex.ts"),
1725                    priority: 82.5,
1726                    efficiency: 27.5,
1727                    recommendation: "Split high-impact file".into(),
1728                    category: RecommendationCategory::SplitHighImpact,
1729                    effort: crate::health_types::EffortEstimate::High,
1730                    confidence: crate::health_types::Confidence::Medium,
1731                    factors: vec![ContributingFactor {
1732                        metric: "fan_in",
1733                        value: 25.0,
1734                        threshold: 10.0,
1735                        detail: "25 files depend on this".into(),
1736                    }],
1737                    evidence: None,
1738                }
1739                .into(),
1740                RefactoringTarget {
1741                    path: PathBuf::from("/project/src/legacy.ts"),
1742                    priority: 45.0,
1743                    efficiency: 45.0,
1744                    recommendation: "Remove 5 unused exports".into(),
1745                    category: RecommendationCategory::RemoveDeadCode,
1746                    effort: crate::health_types::EffortEstimate::Low,
1747                    confidence: crate::health_types::Confidence::High,
1748                    factors: vec![],
1749                    evidence: None,
1750                }
1751                .into(),
1752            ],
1753            ..Default::default()
1754        };
1755        let md = build_health_markdown(&report, &root);
1756
1757        // Should have refactoring targets section
1758        assert!(
1759            md.contains("Refactoring Targets"),
1760            "should contain targets heading"
1761        );
1762        assert!(
1763            md.contains("src/complex.ts"),
1764            "should contain target file path"
1765        );
1766        assert!(md.contains("27.5"), "should contain efficiency score");
1767        assert!(
1768            md.contains("Split high-impact file"),
1769            "should contain recommendation"
1770        );
1771        assert!(md.contains("src/legacy.ts"), "should contain second target");
1772    }
1773
1774    #[test]
1775    fn health_markdown_with_coverage_gaps() {
1776        use crate::health_types::*;
1777
1778        let root = PathBuf::from("/project");
1779        let report = HealthReport {
1780            summary: HealthSummary {
1781                files_analyzed: 10,
1782                functions_analyzed: 50,
1783                ..Default::default()
1784            },
1785            coverage_gaps: Some(CoverageGaps {
1786                summary: CoverageGapSummary {
1787                    runtime_files: 2,
1788                    covered_files: 0,
1789                    file_coverage_pct: 0.0,
1790                    untested_files: 1,
1791                    untested_exports: 1,
1792                },
1793                files: vec![UntestedFileFinding::with_actions(
1794                    UntestedFile {
1795                        path: root.join("src/app.ts"),
1796                        value_export_count: 2,
1797                    },
1798                    &root,
1799                )],
1800                exports: vec![UntestedExportFinding::with_actions(
1801                    UntestedExport {
1802                        path: root.join("src/app.ts"),
1803                        export_name: "loader".into(),
1804                        line: 12,
1805                        col: 4,
1806                    },
1807                    &root,
1808                )],
1809            }),
1810            ..Default::default()
1811        };
1812
1813        let md = build_health_markdown(&report, &root);
1814        assert!(md.contains("### Coverage Gaps"));
1815        assert!(md.contains("*1 untested files"));
1816        assert!(md.contains("`src/app.ts` (2 value exports)"));
1817        assert!(md.contains("`src/app.ts`:12 `loader`"));
1818    }
1819
1820    // ── Dependency in workspace package ──
1821
1822    #[test]
1823    fn markdown_dep_in_workspace_shows_package_label() {
1824        let root = PathBuf::from("/project");
1825        let mut results = AnalysisResults::default();
1826        results
1827            .unused_dependencies
1828            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1829                package_name: "lodash".to_string(),
1830                location: DependencyLocation::Dependencies,
1831                path: root.join("packages/core/package.json"),
1832                line: 5,
1833                used_in_workspaces: Vec::new(),
1834            }));
1835        let md = build_markdown(&results, &root);
1836        // Non-root package.json should show the label
1837        assert!(md.contains("(packages/core/package.json)"));
1838    }
1839
1840    #[test]
1841    fn markdown_dep_at_root_no_extra_label() {
1842        let root = PathBuf::from("/project");
1843        let mut results = AnalysisResults::default();
1844        results
1845            .unused_dependencies
1846            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1847                package_name: "lodash".to_string(),
1848                location: DependencyLocation::Dependencies,
1849                path: root.join("package.json"),
1850                line: 5,
1851                used_in_workspaces: Vec::new(),
1852            }));
1853        let md = build_markdown(&results, &root);
1854        assert!(md.contains("- `lodash`"));
1855        assert!(!md.contains("(package.json)"));
1856    }
1857
1858    #[test]
1859    fn markdown_root_dep_with_cross_workspace_context_uses_context_label() {
1860        let root = PathBuf::from("/project");
1861        let mut results = AnalysisResults::default();
1862        results
1863            .unused_dependencies
1864            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1865                package_name: "lodash-es".to_string(),
1866                location: DependencyLocation::Dependencies,
1867                path: root.join("package.json"),
1868                line: 5,
1869                used_in_workspaces: vec![root.join("packages/consumer")],
1870            }));
1871        let md = build_markdown(&results, &root);
1872        assert!(md.contains("- `lodash-es` (imported in packages/consumer)"));
1873        assert!(!md.contains("(package.json; imported in packages/consumer)"));
1874    }
1875
1876    // ── Multiple exports same file grouped ──
1877
1878    #[test]
1879    fn markdown_exports_grouped_by_file() {
1880        let root = PathBuf::from("/project");
1881        let mut results = AnalysisResults::default();
1882        results
1883            .unused_exports
1884            .push(UnusedExportFinding::with_actions(UnusedExport {
1885                path: root.join("src/utils.ts"),
1886                export_name: "alpha".to_string(),
1887                is_type_only: false,
1888                line: 5,
1889                col: 0,
1890                span_start: 0,
1891                is_re_export: false,
1892            }));
1893        results
1894            .unused_exports
1895            .push(UnusedExportFinding::with_actions(UnusedExport {
1896                path: root.join("src/utils.ts"),
1897                export_name: "beta".to_string(),
1898                is_type_only: false,
1899                line: 10,
1900                col: 0,
1901                span_start: 0,
1902                is_re_export: false,
1903            }));
1904        results
1905            .unused_exports
1906            .push(UnusedExportFinding::with_actions(UnusedExport {
1907                path: root.join("src/other.ts"),
1908                export_name: "gamma".to_string(),
1909                is_type_only: false,
1910                line: 1,
1911                col: 0,
1912                span_start: 0,
1913                is_re_export: false,
1914            }));
1915        let md = build_markdown(&results, &root);
1916        // File header should appear only once for utils.ts
1917        let utils_count = md.matches("- `src/utils.ts`").count();
1918        assert_eq!(utils_count, 1, "file header should appear once per file");
1919        // Both exports should be under it as sub-items
1920        assert!(md.contains(":5 `alpha`"));
1921        assert!(md.contains(":10 `beta`"));
1922    }
1923
1924    // ── Multiple issues plural header ──
1925
1926    #[test]
1927    fn markdown_multiple_issues_plural() {
1928        let root = PathBuf::from("/project");
1929        let mut results = AnalysisResults::default();
1930        results
1931            .unused_files
1932            .push(UnusedFileFinding::with_actions(UnusedFile {
1933                path: root.join("src/a.ts"),
1934            }));
1935        results
1936            .unused_files
1937            .push(UnusedFileFinding::with_actions(UnusedFile {
1938                path: root.join("src/b.ts"),
1939            }));
1940        let md = build_markdown(&results, &root);
1941        assert!(md.starts_with("## Fallow: 2 issues found\n"));
1942    }
1943
1944    // ── Duplication markdown with zero estimated savings ──
1945
1946    #[test]
1947    fn duplication_markdown_zero_savings_no_suffix() {
1948        let root = PathBuf::from("/project");
1949        let report = DuplicationReport {
1950            clone_groups: vec![CloneGroup {
1951                instances: vec![CloneInstance {
1952                    file: root.join("src/a.ts"),
1953                    start_line: 1,
1954                    end_line: 5,
1955                    start_col: 0,
1956                    end_col: 0,
1957                    fragment: String::new(),
1958                }],
1959                token_count: 30,
1960                line_count: 5,
1961            }],
1962            clone_families: vec![CloneFamily {
1963                files: vec![root.join("src/a.ts")],
1964                groups: vec![],
1965                total_duplicated_lines: 5,
1966                total_duplicated_tokens: 30,
1967                suggestions: vec![RefactoringSuggestion {
1968                    kind: RefactoringKind::ExtractFunction,
1969                    description: "Extract function".to_string(),
1970                    estimated_savings: 0,
1971                }],
1972            }],
1973            mirrored_directories: vec![],
1974            stats: DuplicationStats {
1975                clone_groups: 1,
1976                clone_instances: 1,
1977                duplication_percentage: 1.0,
1978                ..Default::default()
1979            },
1980        };
1981        let md = build_duplication_markdown(&report, &root);
1982        assert!(md.contains("Extract function"));
1983        assert!(!md.contains("lines saved"));
1984    }
1985
1986    // ── Health markdown vital signs ──
1987
1988    #[test]
1989    fn health_markdown_vital_signs_table() {
1990        let root = PathBuf::from("/project");
1991        let report = crate::health_types::HealthReport {
1992            summary: crate::health_types::HealthSummary {
1993                files_analyzed: 10,
1994                functions_analyzed: 50,
1995                ..Default::default()
1996            },
1997            vital_signs: Some(crate::health_types::VitalSigns {
1998                avg_cyclomatic: 3.5,
1999                p90_cyclomatic: 12,
2000                dead_file_pct: Some(5.0),
2001                dead_export_pct: Some(10.2),
2002                duplication_pct: None,
2003                maintainability_avg: Some(72.3),
2004                hotspot_count: Some(3),
2005                circular_dep_count: Some(1),
2006                unused_dep_count: Some(2),
2007                counts: None,
2008                unit_size_profile: None,
2009                unit_interfacing_profile: None,
2010                p95_fan_in: None,
2011                coupling_high_pct: None,
2012                total_loc: 15_200,
2013                ..Default::default()
2014            }),
2015            ..Default::default()
2016        };
2017        let md = build_health_markdown(&report, &root);
2018        assert!(md.contains("## Vital Signs"));
2019        assert!(md.contains("| Metric | Value |"));
2020        assert!(md.contains("| Total LOC | 15200 |"));
2021        assert!(md.contains("| Avg Cyclomatic | 3.5 |"));
2022        assert!(md.contains("| P90 Cyclomatic | 12 |"));
2023        assert!(md.contains("| Dead Files | 5.0% |"));
2024        assert!(md.contains("| Dead Exports | 10.2% |"));
2025        assert!(md.contains("| Maintainability (avg) | 72.3 |"));
2026        assert!(md.contains("| Hotspots | 3 |"));
2027        assert!(md.contains("| Circular Deps | 1 |"));
2028        assert!(md.contains("| Unused Deps | 2 |"));
2029    }
2030
2031    // ── Health markdown file scores ──
2032
2033    #[test]
2034    fn health_markdown_file_scores_table() {
2035        let root = PathBuf::from("/project");
2036        let report = crate::health_types::HealthReport {
2037            findings: vec![
2038                crate::health_types::ComplexityViolation {
2039                    path: root.join("src/dummy.ts"),
2040                    name: "fn".to_string(),
2041                    line: 1,
2042                    col: 0,
2043                    cyclomatic: 25,
2044                    cognitive: 20,
2045                    line_count: 50,
2046                    param_count: 0,
2047                    exceeded: crate::health_types::ExceededThreshold::Both,
2048                    severity: crate::health_types::FindingSeverity::High,
2049                    crap: None,
2050                    coverage_pct: None,
2051                    coverage_tier: None,
2052                    coverage_source: None,
2053                    inherited_from: None,
2054                    component_rollup: None,
2055                }
2056                .into(),
2057            ],
2058            summary: crate::health_types::HealthSummary {
2059                files_analyzed: 5,
2060                functions_analyzed: 10,
2061                functions_above_threshold: 1,
2062                files_scored: Some(1),
2063                average_maintainability: Some(65.0),
2064                ..Default::default()
2065            },
2066            file_scores: vec![crate::health_types::FileHealthScore {
2067                path: root.join("src/utils.ts"),
2068                fan_in: 5,
2069                fan_out: 3,
2070                dead_code_ratio: 0.25,
2071                complexity_density: 0.8,
2072                maintainability_index: 72.5,
2073                total_cyclomatic: 40,
2074                total_cognitive: 30,
2075                function_count: 10,
2076                lines: 200,
2077                crap_max: 0.0,
2078                crap_above_threshold: 0,
2079            }],
2080            ..Default::default()
2081        };
2082        let md = build_health_markdown(&report, &root);
2083        assert!(md.contains("### File Health Scores (1 files)"));
2084        assert!(md.contains("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density |"));
2085        assert!(md.contains("| `src/utils.ts` | 72.5 | 5 | 3 | 25% | 0.80 |"));
2086        assert!(md.contains("**Average maintainability index:** 65.0/100"));
2087    }
2088
2089    // ── Health markdown hotspots ──
2090
2091    #[test]
2092    fn health_markdown_hotspots_table() {
2093        let root = PathBuf::from("/project");
2094        let report = crate::health_types::HealthReport {
2095            findings: vec![
2096                crate::health_types::ComplexityViolation {
2097                    path: root.join("src/dummy.ts"),
2098                    name: "fn".to_string(),
2099                    line: 1,
2100                    col: 0,
2101                    cyclomatic: 25,
2102                    cognitive: 20,
2103                    line_count: 50,
2104                    param_count: 0,
2105                    exceeded: crate::health_types::ExceededThreshold::Both,
2106                    severity: crate::health_types::FindingSeverity::High,
2107                    crap: None,
2108                    coverage_pct: None,
2109                    coverage_tier: None,
2110                    coverage_source: None,
2111                    inherited_from: None,
2112                    component_rollup: None,
2113                }
2114                .into(),
2115            ],
2116            summary: crate::health_types::HealthSummary {
2117                files_analyzed: 5,
2118                functions_analyzed: 10,
2119                functions_above_threshold: 1,
2120                ..Default::default()
2121            },
2122            hotspots: vec![
2123                crate::health_types::HotspotEntry {
2124                    path: root.join("src/hot.ts"),
2125                    score: 85.0,
2126                    commits: 42,
2127                    weighted_commits: 35.0,
2128                    lines_added: 500,
2129                    lines_deleted: 200,
2130                    complexity_density: 1.2,
2131                    fan_in: 10,
2132                    trend: fallow_core::churn::ChurnTrend::Accelerating,
2133                    ownership: None,
2134                    is_test_path: false,
2135                }
2136                .into(),
2137            ],
2138            hotspot_summary: Some(crate::health_types::HotspotSummary {
2139                since: "6 months".to_string(),
2140                min_commits: 3,
2141                files_analyzed: 50,
2142                files_excluded: 5,
2143                shallow_clone: false,
2144            }),
2145            ..Default::default()
2146        };
2147        let md = build_health_markdown(&report, &root);
2148        assert!(md.contains("### Hotspots (1 files, since 6 months)"));
2149        assert!(md.contains("| `src/hot.ts` | 85.0 | 42 | 700 | 1.20 | 10 | accelerating |"));
2150        assert!(md.contains("*5 files excluded (< 3 commits)*"));
2151    }
2152
2153    // ── Health markdown metric legend ──
2154
2155    #[test]
2156    fn health_markdown_metric_legend_with_scores() {
2157        let root = PathBuf::from("/project");
2158        let report = crate::health_types::HealthReport {
2159            findings: vec![
2160                crate::health_types::ComplexityViolation {
2161                    path: root.join("src/x.ts"),
2162                    name: "f".to_string(),
2163                    line: 1,
2164                    col: 0,
2165                    cyclomatic: 25,
2166                    cognitive: 20,
2167                    line_count: 10,
2168                    param_count: 0,
2169                    exceeded: crate::health_types::ExceededThreshold::Both,
2170                    severity: crate::health_types::FindingSeverity::High,
2171                    crap: None,
2172                    coverage_pct: None,
2173                    coverage_tier: None,
2174                    coverage_source: None,
2175                    inherited_from: None,
2176                    component_rollup: None,
2177                }
2178                .into(),
2179            ],
2180            summary: crate::health_types::HealthSummary {
2181                files_analyzed: 1,
2182                functions_analyzed: 1,
2183                functions_above_threshold: 1,
2184                files_scored: Some(1),
2185                average_maintainability: Some(70.0),
2186                ..Default::default()
2187            },
2188            file_scores: vec![crate::health_types::FileHealthScore {
2189                path: root.join("src/x.ts"),
2190                fan_in: 1,
2191                fan_out: 1,
2192                dead_code_ratio: 0.0,
2193                complexity_density: 0.5,
2194                maintainability_index: 80.0,
2195                total_cyclomatic: 10,
2196                total_cognitive: 8,
2197                function_count: 2,
2198                lines: 50,
2199                crap_max: 0.0,
2200                crap_above_threshold: 0,
2201            }],
2202            ..Default::default()
2203        };
2204        let md = build_health_markdown(&report, &root);
2205        assert!(md.contains("<details><summary>Metric definitions</summary>"));
2206        assert!(md.contains("**MI**: Maintainability Index"));
2207        assert!(md.contains("**Fan-in**"));
2208        assert!(md.contains("Full metric reference"));
2209    }
2210
2211    // ── Health markdown truncated findings ──
2212
2213    #[test]
2214    fn health_markdown_truncated_findings_shown_count() {
2215        let root = PathBuf::from("/project");
2216        let report = crate::health_types::HealthReport {
2217            findings: vec![
2218                crate::health_types::ComplexityViolation {
2219                    path: root.join("src/x.ts"),
2220                    name: "f".to_string(),
2221                    line: 1,
2222                    col: 0,
2223                    cyclomatic: 25,
2224                    cognitive: 20,
2225                    line_count: 10,
2226                    param_count: 0,
2227                    exceeded: crate::health_types::ExceededThreshold::Both,
2228                    severity: crate::health_types::FindingSeverity::High,
2229                    crap: None,
2230                    coverage_pct: None,
2231                    coverage_tier: None,
2232                    coverage_source: None,
2233                    inherited_from: None,
2234                    component_rollup: None,
2235                }
2236                .into(),
2237            ],
2238            summary: crate::health_types::HealthSummary {
2239                files_analyzed: 10,
2240                functions_analyzed: 50,
2241                functions_above_threshold: 5, // 5 total but only 1 shown
2242                ..Default::default()
2243            },
2244            ..Default::default()
2245        };
2246        let md = build_health_markdown(&report, &root);
2247        assert!(md.contains("5 high complexity functions (1 shown)"));
2248    }
2249
2250    // ── escape_backticks ──
2251
2252    #[test]
2253    fn escape_backticks_handles_multiple() {
2254        assert_eq!(escape_backticks("a`b`c"), "a\\`b\\`c");
2255    }
2256
2257    #[test]
2258    fn escape_backticks_no_backticks_unchanged() {
2259        assert_eq!(escape_backticks("hello"), "hello");
2260    }
2261
2262    // ── Unresolved import in markdown ──
2263
2264    #[test]
2265    fn markdown_unresolved_import_grouped_by_file() {
2266        let root = PathBuf::from("/project");
2267        let mut results = AnalysisResults::default();
2268        results
2269            .unresolved_imports
2270            .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
2271                path: root.join("src/app.ts"),
2272                specifier: "./missing".to_string(),
2273                line: 3,
2274                col: 0,
2275                specifier_col: 0,
2276            }));
2277        let md = build_markdown(&results, &root);
2278        assert!(md.contains("### Unresolved imports (1)"));
2279        assert!(md.contains("- `src/app.ts`"));
2280        assert!(md.contains(":3 `./missing`"));
2281    }
2282
2283    // ── Markdown optional dep ──
2284
2285    #[test]
2286    fn markdown_unused_optional_dep() {
2287        let root = PathBuf::from("/project");
2288        let mut results = AnalysisResults::default();
2289        results
2290            .unused_optional_dependencies
2291            .push(UnusedOptionalDependencyFinding::with_actions(
2292                UnusedDependency {
2293                    package_name: "fsevents".to_string(),
2294                    location: DependencyLocation::OptionalDependencies,
2295                    path: root.join("package.json"),
2296                    line: 12,
2297                    used_in_workspaces: Vec::new(),
2298                },
2299            ));
2300        let md = build_markdown(&results, &root);
2301        assert!(md.contains("### Unused optionalDependencies (1)"));
2302        assert!(md.contains("- `fsevents`"));
2303    }
2304
2305    // ── Health markdown no hotspot exclusion message when 0 excluded ──
2306
2307    #[test]
2308    fn health_markdown_hotspots_no_excluded_message() {
2309        let root = PathBuf::from("/project");
2310        let report = crate::health_types::HealthReport {
2311            findings: vec![
2312                crate::health_types::ComplexityViolation {
2313                    path: root.join("src/x.ts"),
2314                    name: "f".to_string(),
2315                    line: 1,
2316                    col: 0,
2317                    cyclomatic: 25,
2318                    cognitive: 20,
2319                    line_count: 10,
2320                    param_count: 0,
2321                    exceeded: crate::health_types::ExceededThreshold::Both,
2322                    severity: crate::health_types::FindingSeverity::High,
2323                    crap: None,
2324                    coverage_pct: None,
2325                    coverage_tier: None,
2326                    coverage_source: None,
2327                    inherited_from: None,
2328                    component_rollup: None,
2329                }
2330                .into(),
2331            ],
2332            summary: crate::health_types::HealthSummary {
2333                files_analyzed: 5,
2334                functions_analyzed: 10,
2335                functions_above_threshold: 1,
2336                ..Default::default()
2337            },
2338            hotspots: vec![
2339                crate::health_types::HotspotEntry {
2340                    path: root.join("src/hot.ts"),
2341                    score: 50.0,
2342                    commits: 10,
2343                    weighted_commits: 8.0,
2344                    lines_added: 100,
2345                    lines_deleted: 50,
2346                    complexity_density: 0.5,
2347                    fan_in: 3,
2348                    trend: fallow_core::churn::ChurnTrend::Stable,
2349                    ownership: None,
2350                    is_test_path: false,
2351                }
2352                .into(),
2353            ],
2354            hotspot_summary: Some(crate::health_types::HotspotSummary {
2355                since: "6 months".to_string(),
2356                min_commits: 3,
2357                files_analyzed: 50,
2358                files_excluded: 0,
2359                shallow_clone: false,
2360            }),
2361            ..Default::default()
2362        };
2363        let md = build_health_markdown(&report, &root);
2364        assert!(!md.contains("files excluded"));
2365    }
2366
2367    // ── Duplication markdown plural ──
2368
2369    #[test]
2370    fn duplication_markdown_single_group_no_plural() {
2371        let root = PathBuf::from("/project");
2372        let report = DuplicationReport {
2373            clone_groups: vec![CloneGroup {
2374                instances: vec![CloneInstance {
2375                    file: root.join("src/a.ts"),
2376                    start_line: 1,
2377                    end_line: 5,
2378                    start_col: 0,
2379                    end_col: 0,
2380                    fragment: String::new(),
2381                }],
2382                token_count: 30,
2383                line_count: 5,
2384            }],
2385            clone_families: vec![],
2386            mirrored_directories: vec![],
2387            stats: DuplicationStats {
2388                clone_groups: 1,
2389                clone_instances: 1,
2390                duplication_percentage: 2.0,
2391                ..Default::default()
2392            },
2393        };
2394        let md = build_duplication_markdown(&report, &root);
2395        assert!(md.contains("1 clone group found"));
2396        assert!(!md.contains("1 clone groups found"));
2397    }
2398}