Skip to main content

fallow_cli/report/
markdown.rs

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