Skip to main content

fallow_cli/report/
markdown.rs

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