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