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