Skip to main content

fallow_cli/report/
markdown.rs

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