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                    is_cross_package: false,
1437                },
1438            ));
1439        let md = build_markdown(&results, &root);
1440        assert!(md.contains("`src/a.ts`"));
1441        assert!(md.contains("`src/b.ts`"));
1442        assert!(md.contains("\u{2192}"));
1443    }
1444
1445    #[test]
1446    fn markdown_strips_root_prefix() {
1447        let root = PathBuf::from("/project");
1448        let mut results = AnalysisResults::default();
1449        results
1450            .unused_files
1451            .push(UnusedFileFinding::with_actions(UnusedFile {
1452                path: PathBuf::from("/project/src/deep/nested/file.ts"),
1453            }));
1454        let md = build_markdown(&results, &root);
1455        assert!(md.contains("`src/deep/nested/file.ts`"));
1456        assert!(!md.contains("/project/"));
1457    }
1458
1459    #[test]
1460    fn markdown_single_issue_no_plural() {
1461        let root = PathBuf::from("/project");
1462        let mut results = AnalysisResults::default();
1463        results
1464            .unused_files
1465            .push(UnusedFileFinding::with_actions(UnusedFile {
1466                path: root.join("src/dead.ts"),
1467            }));
1468        let md = build_markdown(&results, &root);
1469        assert!(md.starts_with("## Fallow: 1 issue found\n"));
1470    }
1471
1472    #[test]
1473    fn markdown_type_only_dep_format() {
1474        let root = PathBuf::from("/project");
1475        let mut results = AnalysisResults::default();
1476        results
1477            .type_only_dependencies
1478            .push(TypeOnlyDependencyFinding::with_actions(
1479                TypeOnlyDependency {
1480                    package_name: "zod".to_string(),
1481                    path: root.join("package.json"),
1482                    line: 8,
1483                },
1484            ));
1485        let md = build_markdown(&results, &root);
1486        assert!(md.contains("### Type-only dependencies"));
1487        assert!(md.contains("- `zod`"));
1488    }
1489
1490    #[test]
1491    fn markdown_escapes_backticks_in_export_names() {
1492        let root = PathBuf::from("/project");
1493        let mut results = AnalysisResults::default();
1494        results
1495            .unused_exports
1496            .push(UnusedExportFinding::with_actions(UnusedExport {
1497                path: root.join("src/utils.ts"),
1498                export_name: "foo`bar".to_string(),
1499                is_type_only: false,
1500                line: 1,
1501                col: 0,
1502                span_start: 0,
1503                is_re_export: false,
1504            }));
1505        let md = build_markdown(&results, &root);
1506        assert!(md.contains("foo\\`bar"));
1507        assert!(!md.contains("foo`bar`"));
1508    }
1509
1510    #[test]
1511    fn markdown_escapes_backticks_in_package_names() {
1512        let root = PathBuf::from("/project");
1513        let mut results = AnalysisResults::default();
1514        results
1515            .unused_dependencies
1516            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1517                package_name: "pkg`name".to_string(),
1518                location: DependencyLocation::Dependencies,
1519                path: root.join("package.json"),
1520                line: 5,
1521                used_in_workspaces: Vec::new(),
1522            }));
1523        let md = build_markdown(&results, &root);
1524        assert!(md.contains("pkg\\`name"));
1525    }
1526
1527    #[test]
1528    fn duplication_markdown_empty() {
1529        let report = DuplicationReport::default();
1530        let root = PathBuf::from("/project");
1531        let md = build_duplication_markdown(&report, &root);
1532        assert_eq!(md, "## Fallow: no code duplication found\n");
1533    }
1534
1535    #[test]
1536    fn duplication_markdown_contains_groups() {
1537        let root = PathBuf::from("/project");
1538        let report = DuplicationReport {
1539            clone_groups: vec![CloneGroup {
1540                instances: vec![
1541                    CloneInstance {
1542                        file: root.join("src/a.ts"),
1543                        start_line: 1,
1544                        end_line: 10,
1545                        start_col: 0,
1546                        end_col: 0,
1547                        fragment: String::new(),
1548                    },
1549                    CloneInstance {
1550                        file: root.join("src/b.ts"),
1551                        start_line: 5,
1552                        end_line: 14,
1553                        start_col: 0,
1554                        end_col: 0,
1555                        fragment: String::new(),
1556                    },
1557                ],
1558                token_count: 50,
1559                line_count: 10,
1560            }],
1561            clone_families: vec![],
1562            mirrored_directories: vec![],
1563            stats: DuplicationStats {
1564                total_files: 10,
1565                files_with_clones: 2,
1566                total_lines: 500,
1567                duplicated_lines: 20,
1568                total_tokens: 2500,
1569                duplicated_tokens: 100,
1570                clone_groups: 1,
1571                clone_instances: 2,
1572                duplication_percentage: 4.0,
1573                clone_groups_below_min_occurrences: 0,
1574            },
1575        };
1576        let md = build_duplication_markdown(&report, &root);
1577        assert!(md.contains("**Clone group 1**"));
1578        assert!(md.contains("`src/a.ts:1-10`"));
1579        assert!(md.contains("`src/b.ts:5-14`"));
1580        assert!(md.contains("4.0% duplication"));
1581    }
1582
1583    #[test]
1584    fn duplication_markdown_contains_families() {
1585        let root = PathBuf::from("/project");
1586        let report = DuplicationReport {
1587            clone_groups: vec![CloneGroup {
1588                instances: vec![CloneInstance {
1589                    file: root.join("src/a.ts"),
1590                    start_line: 1,
1591                    end_line: 5,
1592                    start_col: 0,
1593                    end_col: 0,
1594                    fragment: String::new(),
1595                }],
1596                token_count: 30,
1597                line_count: 5,
1598            }],
1599            clone_families: vec![CloneFamily {
1600                files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1601                groups: vec![],
1602                total_duplicated_lines: 20,
1603                total_duplicated_tokens: 100,
1604                suggestions: vec![RefactoringSuggestion {
1605                    kind: RefactoringKind::ExtractFunction,
1606                    description: "Extract shared utility function".to_string(),
1607                    estimated_savings: 15,
1608                }],
1609            }],
1610            mirrored_directories: vec![],
1611            stats: DuplicationStats {
1612                clone_groups: 1,
1613                clone_instances: 1,
1614                duplication_percentage: 2.0,
1615                ..Default::default()
1616            },
1617        };
1618        let md = build_duplication_markdown(&report, &root);
1619        assert!(md.contains("### Clone Families"));
1620        assert!(md.contains("**Family 1**"));
1621        assert!(md.contains("Extract shared utility function"));
1622        assert!(md.contains("~15 lines saved"));
1623    }
1624
1625    #[test]
1626    fn health_markdown_empty_no_findings() {
1627        let root = PathBuf::from("/project");
1628        let report = crate::health_types::HealthReport {
1629            summary: crate::health_types::HealthSummary {
1630                files_analyzed: 10,
1631                functions_analyzed: 50,
1632                ..Default::default()
1633            },
1634            ..Default::default()
1635        };
1636        let md = build_health_markdown(&report, &root);
1637        assert!(md.contains("no functions exceed complexity thresholds"));
1638        assert!(md.contains("**50** functions analyzed"));
1639    }
1640
1641    #[test]
1642    fn health_markdown_table_format() {
1643        let root = PathBuf::from("/project");
1644        let report = crate::health_types::HealthReport {
1645            findings: vec![
1646                crate::health_types::ComplexityViolation {
1647                    path: root.join("src/utils.ts"),
1648                    name: "parseExpression".to_string(),
1649                    line: 42,
1650                    col: 0,
1651                    cyclomatic: 25,
1652                    cognitive: 30,
1653                    line_count: 80,
1654                    param_count: 0,
1655                    exceeded: crate::health_types::ExceededThreshold::Both,
1656                    severity: crate::health_types::FindingSeverity::High,
1657                    crap: None,
1658                    coverage_pct: None,
1659                    coverage_tier: None,
1660                    coverage_source: None,
1661                    inherited_from: None,
1662                    component_rollup: None,
1663                }
1664                .into(),
1665            ],
1666            summary: crate::health_types::HealthSummary {
1667                files_analyzed: 10,
1668                functions_analyzed: 50,
1669                functions_above_threshold: 1,
1670                ..Default::default()
1671            },
1672            ..Default::default()
1673        };
1674        let md = build_health_markdown(&report, &root);
1675        assert!(md.contains("## Fallow: 1 high complexity function\n"));
1676        assert!(md.contains("| File | Function |"));
1677        assert!(md.contains("`src/utils.ts:42`"));
1678        assert!(md.contains("`parseExpression`"));
1679        assert!(md.contains("25 **!**"));
1680        assert!(md.contains("30 **!**"));
1681        assert!(md.contains("| 80 |"));
1682        assert!(md.contains("| - |"));
1683    }
1684
1685    #[test]
1686    fn health_markdown_includes_coverage_intelligence_and_ambiguity_summary() {
1687        use crate::health_types::{
1688            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
1689            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
1690            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
1691            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
1692            CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
1693            HealthReport, HealthSummary,
1694        };
1695
1696        let root = PathBuf::from("/project");
1697        let mut report = HealthReport {
1698            summary: HealthSummary {
1699                files_analyzed: 10,
1700                functions_analyzed: 50,
1701                ..Default::default()
1702            },
1703            coverage_intelligence: Some(CoverageIntelligenceReport {
1704                schema_version: CoverageIntelligenceSchemaVersion::V1,
1705                verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
1706                summary: CoverageIntelligenceSummary {
1707                    findings: 1,
1708                    high_confidence_deletes: 1,
1709                    ..Default::default()
1710                },
1711                findings: vec![CoverageIntelligenceFinding {
1712                    id: "fallow:coverage-intel:abc123".to_owned(),
1713                    path: root.join("src/dead.ts"),
1714                    identity: Some("deadPath".to_owned()),
1715                    line: 9,
1716                    verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
1717                    signals: vec![CoverageIntelligenceSignal::RuntimeCold],
1718                    recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
1719                    confidence: CoverageIntelligenceConfidence::High,
1720                    related_ids: vec!["fallow:prod:deadbeef".to_owned()],
1721                    evidence: CoverageIntelligenceEvidence {
1722                        match_confidence: CoverageIntelligenceMatchConfidence::Direct,
1723                        ..Default::default()
1724                    },
1725                    actions: vec![CoverageIntelligenceAction {
1726                        kind: "delete-after-confirming-owner".to_owned(),
1727                        description: "Confirm ownership".to_owned(),
1728                        auto_fixable: false,
1729                    }],
1730                }],
1731            }),
1732            ..Default::default()
1733        };
1734
1735        let md = build_health_markdown(&report, &root);
1736        assert!(md.contains("## Coverage Intelligence"));
1737        assert!(md.contains("fallow:coverage-intel:abc123"));
1738        assert!(md.contains("delete-after-confirming-owner"));
1739        assert!(md.contains("runtime_cold"));
1740
1741        report.coverage_intelligence = Some(CoverageIntelligenceReport {
1742            schema_version: CoverageIntelligenceSchemaVersion::V1,
1743            verdict: CoverageIntelligenceVerdict::Clean,
1744            summary: CoverageIntelligenceSummary {
1745                skipped_ambiguous_matches: 2,
1746                ..Default::default()
1747            },
1748            findings: vec![],
1749        });
1750        let md = build_health_markdown(&report, &root);
1751        assert!(md.contains("2 ambiguous evidence matches were skipped"));
1752        assert!(!md.contains("| ID | Path |"));
1753    }
1754
1755    #[test]
1756    fn health_markdown_crap_column_shows_score_and_marker() {
1757        let root = PathBuf::from("/project");
1758        let report = crate::health_types::HealthReport {
1759            findings: vec![
1760                crate::health_types::ComplexityViolation {
1761                    path: root.join("src/risky.ts"),
1762                    name: "branchy".to_string(),
1763                    line: 1,
1764                    col: 0,
1765                    cyclomatic: 67,
1766                    cognitive: 10,
1767                    line_count: 80,
1768                    param_count: 1,
1769                    exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
1770                    severity: crate::health_types::FindingSeverity::Critical,
1771                    crap: Some(182.0),
1772                    coverage_pct: None,
1773                    coverage_tier: None,
1774                    coverage_source: None,
1775                    inherited_from: None,
1776                    component_rollup: None,
1777                }
1778                .into(),
1779            ],
1780            summary: crate::health_types::HealthSummary {
1781                files_analyzed: 1,
1782                functions_analyzed: 1,
1783                functions_above_threshold: 1,
1784                ..Default::default()
1785            },
1786            ..Default::default()
1787        };
1788        let md = build_health_markdown(&report, &root);
1789        assert!(
1790            md.contains("| CRAP |"),
1791            "markdown table should have CRAP column header: {md}"
1792        );
1793        assert!(
1794            md.contains("182.0 **!**"),
1795            "CRAP value should be rendered with a threshold marker: {md}"
1796        );
1797        assert!(
1798            md.contains("CRAP >="),
1799            "trailing summary line should reference the CRAP threshold: {md}"
1800        );
1801    }
1802
1803    #[test]
1804    fn health_markdown_no_marker_when_below_threshold() {
1805        let root = PathBuf::from("/project");
1806        let report = crate::health_types::HealthReport {
1807            findings: vec![
1808                crate::health_types::ComplexityViolation {
1809                    path: root.join("src/utils.ts"),
1810                    name: "helper".to_string(),
1811                    line: 10,
1812                    col: 0,
1813                    cyclomatic: 15,
1814                    cognitive: 20,
1815                    line_count: 30,
1816                    param_count: 0,
1817                    exceeded: crate::health_types::ExceededThreshold::Cognitive,
1818                    severity: crate::health_types::FindingSeverity::High,
1819                    crap: None,
1820                    coverage_pct: None,
1821                    coverage_tier: None,
1822                    coverage_source: None,
1823                    inherited_from: None,
1824                    component_rollup: None,
1825                }
1826                .into(),
1827            ],
1828            summary: crate::health_types::HealthSummary {
1829                files_analyzed: 5,
1830                functions_analyzed: 20,
1831                functions_above_threshold: 1,
1832                ..Default::default()
1833            },
1834            ..Default::default()
1835        };
1836        let md = build_health_markdown(&report, &root);
1837        assert!(md.contains("| 15 |"));
1838        assert!(md.contains("20 **!**"));
1839    }
1840
1841    #[test]
1842    fn health_markdown_with_targets() {
1843        use crate::health_types::*;
1844
1845        let root = PathBuf::from("/project");
1846        let report = HealthReport {
1847            summary: HealthSummary {
1848                files_analyzed: 10,
1849                functions_analyzed: 50,
1850                ..Default::default()
1851            },
1852            targets: vec![
1853                RefactoringTarget {
1854                    path: PathBuf::from("/project/src/complex.ts"),
1855                    priority: 82.5,
1856                    efficiency: 27.5,
1857                    recommendation: "Split high-impact file".into(),
1858                    category: RecommendationCategory::SplitHighImpact,
1859                    effort: crate::health_types::EffortEstimate::High,
1860                    confidence: crate::health_types::Confidence::Medium,
1861                    factors: vec![ContributingFactor {
1862                        metric: "fan_in",
1863                        value: 25.0,
1864                        threshold: 10.0,
1865                        detail: "25 files depend on this".into(),
1866                    }],
1867                    evidence: None,
1868                }
1869                .into(),
1870                RefactoringTarget {
1871                    path: PathBuf::from("/project/src/legacy.ts"),
1872                    priority: 45.0,
1873                    efficiency: 45.0,
1874                    recommendation: "Remove 5 unused exports".into(),
1875                    category: RecommendationCategory::RemoveDeadCode,
1876                    effort: crate::health_types::EffortEstimate::Low,
1877                    confidence: crate::health_types::Confidence::High,
1878                    factors: vec![],
1879                    evidence: None,
1880                }
1881                .into(),
1882            ],
1883            ..Default::default()
1884        };
1885        let md = build_health_markdown(&report, &root);
1886
1887        assert!(
1888            md.contains("Refactoring Targets"),
1889            "should contain targets heading"
1890        );
1891        assert!(
1892            md.contains("src/complex.ts"),
1893            "should contain target file path"
1894        );
1895        assert!(md.contains("27.5"), "should contain efficiency score");
1896        assert!(
1897            md.contains("Split high-impact file"),
1898            "should contain recommendation"
1899        );
1900        assert!(md.contains("src/legacy.ts"), "should contain second target");
1901    }
1902
1903    #[test]
1904    fn health_markdown_with_coverage_gaps() {
1905        use crate::health_types::*;
1906
1907        let root = PathBuf::from("/project");
1908        let report = HealthReport {
1909            summary: HealthSummary {
1910                files_analyzed: 10,
1911                functions_analyzed: 50,
1912                ..Default::default()
1913            },
1914            coverage_gaps: Some(CoverageGaps {
1915                summary: CoverageGapSummary {
1916                    runtime_files: 2,
1917                    covered_files: 0,
1918                    file_coverage_pct: 0.0,
1919                    untested_files: 1,
1920                    untested_exports: 1,
1921                },
1922                files: vec![UntestedFileFinding::with_actions(
1923                    UntestedFile {
1924                        path: root.join("src/app.ts"),
1925                        value_export_count: 2,
1926                    },
1927                    &root,
1928                )],
1929                exports: vec![UntestedExportFinding::with_actions(
1930                    UntestedExport {
1931                        path: root.join("src/app.ts"),
1932                        export_name: "loader".into(),
1933                        line: 12,
1934                        col: 4,
1935                    },
1936                    &root,
1937                )],
1938            }),
1939            ..Default::default()
1940        };
1941
1942        let md = build_health_markdown(&report, &root);
1943        assert!(md.contains("### Coverage Gaps"));
1944        assert!(md.contains("*1 untested files"));
1945        assert!(md.contains("`src/app.ts` (2 value exports)"));
1946        assert!(md.contains("`src/app.ts`:12 `loader`"));
1947    }
1948
1949    #[test]
1950    fn markdown_dep_in_workspace_shows_package_label() {
1951        let root = PathBuf::from("/project");
1952        let mut results = AnalysisResults::default();
1953        results
1954            .unused_dependencies
1955            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1956                package_name: "lodash".to_string(),
1957                location: DependencyLocation::Dependencies,
1958                path: root.join("packages/core/package.json"),
1959                line: 5,
1960                used_in_workspaces: Vec::new(),
1961            }));
1962        let md = build_markdown(&results, &root);
1963        assert!(md.contains("(packages/core/package.json)"));
1964    }
1965
1966    #[test]
1967    fn markdown_dep_at_root_no_extra_label() {
1968        let root = PathBuf::from("/project");
1969        let mut results = AnalysisResults::default();
1970        results
1971            .unused_dependencies
1972            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1973                package_name: "lodash".to_string(),
1974                location: DependencyLocation::Dependencies,
1975                path: root.join("package.json"),
1976                line: 5,
1977                used_in_workspaces: Vec::new(),
1978            }));
1979        let md = build_markdown(&results, &root);
1980        assert!(md.contains("- `lodash`"));
1981        assert!(!md.contains("(package.json)"));
1982    }
1983
1984    #[test]
1985    fn markdown_root_dep_with_cross_workspace_context_uses_context_label() {
1986        let root = PathBuf::from("/project");
1987        let mut results = AnalysisResults::default();
1988        results
1989            .unused_dependencies
1990            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1991                package_name: "lodash-es".to_string(),
1992                location: DependencyLocation::Dependencies,
1993                path: root.join("package.json"),
1994                line: 5,
1995                used_in_workspaces: vec![root.join("packages/consumer")],
1996            }));
1997        let md = build_markdown(&results, &root);
1998        assert!(md.contains("- `lodash-es` (imported in packages/consumer)"));
1999        assert!(!md.contains("(package.json; imported in packages/consumer)"));
2000    }
2001
2002    #[test]
2003    fn markdown_exports_grouped_by_file() {
2004        let root = PathBuf::from("/project");
2005        let mut results = AnalysisResults::default();
2006        results
2007            .unused_exports
2008            .push(UnusedExportFinding::with_actions(UnusedExport {
2009                path: root.join("src/utils.ts"),
2010                export_name: "alpha".to_string(),
2011                is_type_only: false,
2012                line: 5,
2013                col: 0,
2014                span_start: 0,
2015                is_re_export: false,
2016            }));
2017        results
2018            .unused_exports
2019            .push(UnusedExportFinding::with_actions(UnusedExport {
2020                path: root.join("src/utils.ts"),
2021                export_name: "beta".to_string(),
2022                is_type_only: false,
2023                line: 10,
2024                col: 0,
2025                span_start: 0,
2026                is_re_export: false,
2027            }));
2028        results
2029            .unused_exports
2030            .push(UnusedExportFinding::with_actions(UnusedExport {
2031                path: root.join("src/other.ts"),
2032                export_name: "gamma".to_string(),
2033                is_type_only: false,
2034                line: 1,
2035                col: 0,
2036                span_start: 0,
2037                is_re_export: false,
2038            }));
2039        let md = build_markdown(&results, &root);
2040        let utils_count = md.matches("- `src/utils.ts`").count();
2041        assert_eq!(utils_count, 1, "file header should appear once per file");
2042        assert!(md.contains(":5 `alpha`"));
2043        assert!(md.contains(":10 `beta`"));
2044    }
2045
2046    #[test]
2047    fn markdown_multiple_issues_plural() {
2048        let root = PathBuf::from("/project");
2049        let mut results = AnalysisResults::default();
2050        results
2051            .unused_files
2052            .push(UnusedFileFinding::with_actions(UnusedFile {
2053                path: root.join("src/a.ts"),
2054            }));
2055        results
2056            .unused_files
2057            .push(UnusedFileFinding::with_actions(UnusedFile {
2058                path: root.join("src/b.ts"),
2059            }));
2060        let md = build_markdown(&results, &root);
2061        assert!(md.starts_with("## Fallow: 2 issues found\n"));
2062    }
2063
2064    #[test]
2065    fn duplication_markdown_zero_savings_no_suffix() {
2066        let root = PathBuf::from("/project");
2067        let report = DuplicationReport {
2068            clone_groups: vec![CloneGroup {
2069                instances: vec![CloneInstance {
2070                    file: root.join("src/a.ts"),
2071                    start_line: 1,
2072                    end_line: 5,
2073                    start_col: 0,
2074                    end_col: 0,
2075                    fragment: String::new(),
2076                }],
2077                token_count: 30,
2078                line_count: 5,
2079            }],
2080            clone_families: vec![CloneFamily {
2081                files: vec![root.join("src/a.ts")],
2082                groups: vec![],
2083                total_duplicated_lines: 5,
2084                total_duplicated_tokens: 30,
2085                suggestions: vec![RefactoringSuggestion {
2086                    kind: RefactoringKind::ExtractFunction,
2087                    description: "Extract function".to_string(),
2088                    estimated_savings: 0,
2089                }],
2090            }],
2091            mirrored_directories: vec![],
2092            stats: DuplicationStats {
2093                clone_groups: 1,
2094                clone_instances: 1,
2095                duplication_percentage: 1.0,
2096                ..Default::default()
2097            },
2098        };
2099        let md = build_duplication_markdown(&report, &root);
2100        assert!(md.contains("Extract function"));
2101        assert!(!md.contains("lines saved"));
2102    }
2103
2104    #[test]
2105    fn health_markdown_vital_signs_table() {
2106        let root = PathBuf::from("/project");
2107        let report = crate::health_types::HealthReport {
2108            summary: crate::health_types::HealthSummary {
2109                files_analyzed: 10,
2110                functions_analyzed: 50,
2111                ..Default::default()
2112            },
2113            vital_signs: Some(crate::health_types::VitalSigns {
2114                avg_cyclomatic: 3.5,
2115                p90_cyclomatic: 12,
2116                dead_file_pct: Some(5.0),
2117                dead_export_pct: Some(10.2),
2118                duplication_pct: None,
2119                maintainability_avg: Some(72.3),
2120                hotspot_count: Some(3),
2121                circular_dep_count: Some(1),
2122                unused_dep_count: Some(2),
2123                counts: None,
2124                unit_size_profile: None,
2125                unit_interfacing_profile: None,
2126                p95_fan_in: None,
2127                coupling_high_pct: None,
2128                total_loc: 15_200,
2129                ..Default::default()
2130            }),
2131            hotspot_summary: Some(crate::health_types::HotspotSummary {
2132                since: "6 months".to_string(),
2133                min_commits: 3,
2134                files_analyzed: 50,
2135                files_excluded: 0,
2136                shallow_clone: false,
2137            }),
2138            ..Default::default()
2139        };
2140        let md = build_health_markdown(&report, &root);
2141        assert!(md.contains("## Vital Signs"));
2142        assert!(md.contains("| Metric | Value |"));
2143        assert!(md.contains("| Total LOC | 15200 |"));
2144        assert!(md.contains("| Avg Cyclomatic | 3.5 |"));
2145        assert!(md.contains("| P90 Cyclomatic | 12 |"));
2146        assert!(md.contains("| Dead Files | 5.0% |"));
2147        assert!(md.contains("| Dead Exports | 10.2% |"));
2148        assert!(md.contains("| Maintainability (avg) | 72.3 |"));
2149        assert!(md.contains("| Hotspots (since 6 months) | 3 |"));
2150        assert!(md.contains("| Circular Deps | 1 |"));
2151        assert!(md.contains("| Unused Deps | 2 |"));
2152    }
2153
2154    #[test]
2155    fn health_markdown_hotspots_without_summary_omits_window() {
2156        let root = PathBuf::from("/project");
2157        let report = crate::health_types::HealthReport {
2158            vital_signs: Some(crate::health_types::VitalSigns {
2159                avg_cyclomatic: 2.0,
2160                p90_cyclomatic: 5,
2161                hotspot_count: Some(0),
2162                total_loc: 1_000,
2163                ..Default::default()
2164            }),
2165            hotspot_summary: None,
2166            ..Default::default()
2167        };
2168        let md = build_health_markdown(&report, &root);
2169        assert!(md.contains("| Hotspots | 0 |"));
2170        assert!(!md.contains("Hotspots (since"));
2171    }
2172
2173    #[test]
2174    fn health_markdown_file_scores_table() {
2175        let root = PathBuf::from("/project");
2176        let report = crate::health_types::HealthReport {
2177            findings: vec![
2178                crate::health_types::ComplexityViolation {
2179                    path: root.join("src/dummy.ts"),
2180                    name: "fn".to_string(),
2181                    line: 1,
2182                    col: 0,
2183                    cyclomatic: 25,
2184                    cognitive: 20,
2185                    line_count: 50,
2186                    param_count: 0,
2187                    exceeded: crate::health_types::ExceededThreshold::Both,
2188                    severity: crate::health_types::FindingSeverity::High,
2189                    crap: None,
2190                    coverage_pct: None,
2191                    coverage_tier: None,
2192                    coverage_source: None,
2193                    inherited_from: None,
2194                    component_rollup: None,
2195                }
2196                .into(),
2197            ],
2198            summary: crate::health_types::HealthSummary {
2199                files_analyzed: 5,
2200                functions_analyzed: 10,
2201                functions_above_threshold: 1,
2202                files_scored: Some(1),
2203                average_maintainability: Some(65.0),
2204                ..Default::default()
2205            },
2206            file_scores: vec![crate::health_types::FileHealthScore {
2207                path: root.join("src/utils.ts"),
2208                fan_in: 5,
2209                fan_out: 3,
2210                dead_code_ratio: 0.25,
2211                complexity_density: 0.8,
2212                maintainability_index: 72.5,
2213                total_cyclomatic: 40,
2214                total_cognitive: 30,
2215                function_count: 10,
2216                lines: 200,
2217                crap_max: 0.0,
2218                crap_above_threshold: 0,
2219            }],
2220            ..Default::default()
2221        };
2222        let md = build_health_markdown(&report, &root);
2223        assert!(md.contains("### File Health Scores (1 files)"));
2224        assert!(md.contains("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density |"));
2225        assert!(md.contains("| `src/utils.ts` | 72.5 | 5 | 3 | 25% | 0.80 |"));
2226        assert!(md.contains("**Average maintainability index:** 65.0/100"));
2227    }
2228
2229    #[test]
2230    fn health_markdown_hotspots_table() {
2231        let root = PathBuf::from("/project");
2232        let report = crate::health_types::HealthReport {
2233            findings: vec![
2234                crate::health_types::ComplexityViolation {
2235                    path: root.join("src/dummy.ts"),
2236                    name: "fn".to_string(),
2237                    line: 1,
2238                    col: 0,
2239                    cyclomatic: 25,
2240                    cognitive: 20,
2241                    line_count: 50,
2242                    param_count: 0,
2243                    exceeded: crate::health_types::ExceededThreshold::Both,
2244                    severity: crate::health_types::FindingSeverity::High,
2245                    crap: None,
2246                    coverage_pct: None,
2247                    coverage_tier: None,
2248                    coverage_source: None,
2249                    inherited_from: None,
2250                    component_rollup: None,
2251                }
2252                .into(),
2253            ],
2254            summary: crate::health_types::HealthSummary {
2255                files_analyzed: 5,
2256                functions_analyzed: 10,
2257                functions_above_threshold: 1,
2258                ..Default::default()
2259            },
2260            hotspots: vec![
2261                crate::health_types::HotspotEntry {
2262                    path: root.join("src/hot.ts"),
2263                    score: 85.0,
2264                    commits: 42,
2265                    weighted_commits: 35.0,
2266                    lines_added: 500,
2267                    lines_deleted: 200,
2268                    complexity_density: 1.2,
2269                    fan_in: 10,
2270                    trend: fallow_core::churn::ChurnTrend::Accelerating,
2271                    ownership: None,
2272                    is_test_path: false,
2273                }
2274                .into(),
2275            ],
2276            hotspot_summary: Some(crate::health_types::HotspotSummary {
2277                since: "6 months".to_string(),
2278                min_commits: 3,
2279                files_analyzed: 50,
2280                files_excluded: 5,
2281                shallow_clone: false,
2282            }),
2283            ..Default::default()
2284        };
2285        let md = build_health_markdown(&report, &root);
2286        assert!(md.contains("### Hotspots (1 files, since 6 months)"));
2287        assert!(md.contains("| `src/hot.ts` | 85.0 | 42 | 700 | 1.20 | 10 | accelerating |"));
2288        assert!(md.contains("*5 files excluded (< 3 commits)*"));
2289    }
2290
2291    #[test]
2292    fn health_markdown_metric_legend_with_scores() {
2293        let root = PathBuf::from("/project");
2294        let report = crate::health_types::HealthReport {
2295            findings: vec![
2296                crate::health_types::ComplexityViolation {
2297                    path: root.join("src/x.ts"),
2298                    name: "f".to_string(),
2299                    line: 1,
2300                    col: 0,
2301                    cyclomatic: 25,
2302                    cognitive: 20,
2303                    line_count: 10,
2304                    param_count: 0,
2305                    exceeded: crate::health_types::ExceededThreshold::Both,
2306                    severity: crate::health_types::FindingSeverity::High,
2307                    crap: None,
2308                    coverage_pct: None,
2309                    coverage_tier: None,
2310                    coverage_source: None,
2311                    inherited_from: None,
2312                    component_rollup: None,
2313                }
2314                .into(),
2315            ],
2316            summary: crate::health_types::HealthSummary {
2317                files_analyzed: 1,
2318                functions_analyzed: 1,
2319                functions_above_threshold: 1,
2320                files_scored: Some(1),
2321                average_maintainability: Some(70.0),
2322                ..Default::default()
2323            },
2324            file_scores: vec![crate::health_types::FileHealthScore {
2325                path: root.join("src/x.ts"),
2326                fan_in: 1,
2327                fan_out: 1,
2328                dead_code_ratio: 0.0,
2329                complexity_density: 0.5,
2330                maintainability_index: 80.0,
2331                total_cyclomatic: 10,
2332                total_cognitive: 8,
2333                function_count: 2,
2334                lines: 50,
2335                crap_max: 0.0,
2336                crap_above_threshold: 0,
2337            }],
2338            ..Default::default()
2339        };
2340        let md = build_health_markdown(&report, &root);
2341        assert!(md.contains("<details><summary>Metric definitions</summary>"));
2342        assert!(md.contains("**MI**: Maintainability Index"));
2343        assert!(md.contains("**Fan-in**"));
2344        assert!(md.contains("Full metric reference"));
2345    }
2346
2347    #[test]
2348    fn health_markdown_truncated_findings_shown_count() {
2349        let root = PathBuf::from("/project");
2350        let report = crate::health_types::HealthReport {
2351            findings: vec![
2352                crate::health_types::ComplexityViolation {
2353                    path: root.join("src/x.ts"),
2354                    name: "f".to_string(),
2355                    line: 1,
2356                    col: 0,
2357                    cyclomatic: 25,
2358                    cognitive: 20,
2359                    line_count: 10,
2360                    param_count: 0,
2361                    exceeded: crate::health_types::ExceededThreshold::Both,
2362                    severity: crate::health_types::FindingSeverity::High,
2363                    crap: None,
2364                    coverage_pct: None,
2365                    coverage_tier: None,
2366                    coverage_source: None,
2367                    inherited_from: None,
2368                    component_rollup: None,
2369                }
2370                .into(),
2371            ],
2372            summary: crate::health_types::HealthSummary {
2373                files_analyzed: 10,
2374                functions_analyzed: 50,
2375                functions_above_threshold: 5, // 5 total but only 1 shown
2376                ..Default::default()
2377            },
2378            ..Default::default()
2379        };
2380        let md = build_health_markdown(&report, &root);
2381        assert!(md.contains("5 high complexity functions (1 shown)"));
2382    }
2383
2384    #[test]
2385    fn escape_backticks_handles_multiple() {
2386        assert_eq!(escape_backticks("a`b`c"), "a\\`b\\`c");
2387    }
2388
2389    #[test]
2390    fn escape_backticks_no_backticks_unchanged() {
2391        assert_eq!(escape_backticks("hello"), "hello");
2392    }
2393
2394    #[test]
2395    fn markdown_unresolved_import_grouped_by_file() {
2396        let root = PathBuf::from("/project");
2397        let mut results = AnalysisResults::default();
2398        results
2399            .unresolved_imports
2400            .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
2401                path: root.join("src/app.ts"),
2402                specifier: "./missing".to_string(),
2403                line: 3,
2404                col: 0,
2405                specifier_col: 0,
2406            }));
2407        let md = build_markdown(&results, &root);
2408        assert!(md.contains("### Unresolved imports (1)"));
2409        assert!(md.contains("- `src/app.ts`"));
2410        assert!(md.contains(":3 `./missing`"));
2411    }
2412
2413    #[test]
2414    fn markdown_unused_optional_dep() {
2415        let root = PathBuf::from("/project");
2416        let mut results = AnalysisResults::default();
2417        results
2418            .unused_optional_dependencies
2419            .push(UnusedOptionalDependencyFinding::with_actions(
2420                UnusedDependency {
2421                    package_name: "fsevents".to_string(),
2422                    location: DependencyLocation::OptionalDependencies,
2423                    path: root.join("package.json"),
2424                    line: 12,
2425                    used_in_workspaces: Vec::new(),
2426                },
2427            ));
2428        let md = build_markdown(&results, &root);
2429        assert!(md.contains("### Unused optionalDependencies (1)"));
2430        assert!(md.contains("- `fsevents`"));
2431    }
2432
2433    #[test]
2434    fn health_markdown_hotspots_no_excluded_message() {
2435        let root = PathBuf::from("/project");
2436        let report = crate::health_types::HealthReport {
2437            findings: vec![
2438                crate::health_types::ComplexityViolation {
2439                    path: root.join("src/x.ts"),
2440                    name: "f".to_string(),
2441                    line: 1,
2442                    col: 0,
2443                    cyclomatic: 25,
2444                    cognitive: 20,
2445                    line_count: 10,
2446                    param_count: 0,
2447                    exceeded: crate::health_types::ExceededThreshold::Both,
2448                    severity: crate::health_types::FindingSeverity::High,
2449                    crap: None,
2450                    coverage_pct: None,
2451                    coverage_tier: None,
2452                    coverage_source: None,
2453                    inherited_from: None,
2454                    component_rollup: None,
2455                }
2456                .into(),
2457            ],
2458            summary: crate::health_types::HealthSummary {
2459                files_analyzed: 5,
2460                functions_analyzed: 10,
2461                functions_above_threshold: 1,
2462                ..Default::default()
2463            },
2464            hotspots: vec![
2465                crate::health_types::HotspotEntry {
2466                    path: root.join("src/hot.ts"),
2467                    score: 50.0,
2468                    commits: 10,
2469                    weighted_commits: 8.0,
2470                    lines_added: 100,
2471                    lines_deleted: 50,
2472                    complexity_density: 0.5,
2473                    fan_in: 3,
2474                    trend: fallow_core::churn::ChurnTrend::Stable,
2475                    ownership: None,
2476                    is_test_path: false,
2477                }
2478                .into(),
2479            ],
2480            hotspot_summary: Some(crate::health_types::HotspotSummary {
2481                since: "6 months".to_string(),
2482                min_commits: 3,
2483                files_analyzed: 50,
2484                files_excluded: 0,
2485                shallow_clone: false,
2486            }),
2487            ..Default::default()
2488        };
2489        let md = build_health_markdown(&report, &root);
2490        assert!(!md.contains("files excluded"));
2491    }
2492
2493    #[test]
2494    fn duplication_markdown_single_group_no_plural() {
2495        let root = PathBuf::from("/project");
2496        let report = DuplicationReport {
2497            clone_groups: vec![CloneGroup {
2498                instances: vec![CloneInstance {
2499                    file: root.join("src/a.ts"),
2500                    start_line: 1,
2501                    end_line: 5,
2502                    start_col: 0,
2503                    end_col: 0,
2504                    fragment: String::new(),
2505                }],
2506                token_count: 30,
2507                line_count: 5,
2508            }],
2509            clone_families: vec![],
2510            mirrored_directories: vec![],
2511            stats: DuplicationStats {
2512                clone_groups: 1,
2513                clone_instances: 1,
2514                duplication_percentage: 2.0,
2515                ..Default::default()
2516            },
2517        };
2518        let md = build_duplication_markdown(&report, &root);
2519        assert!(md.contains("1 clone group found"));
2520        assert!(!md.contains("1 clone groups found"));
2521    }
2522}