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