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