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 an
912/// en-dash (U+2013) 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(
1087            "- **Efficiency**: priority / effort (higher = better quick-win value, default sort)\n",
1088        );
1089        out.push_str("- **Category**: recommendation type (churn+complexity, high impact, dead code, complexity, coupling, circular dep)\n");
1090        out.push_str("- **Effort**: estimated effort (low / medium / high) based on file size, function count, and fan-in\n");
1091        out.push_str("- **Confidence**: recommendation reliability (high = deterministic analysis, medium = heuristic, low = git-dependent)\n");
1092    }
1093    out.push_str(
1094        "\n[Full metric reference](https://docs.fallow.tools/explanations/metrics)\n\n</details>\n",
1095    );
1096}
1097
1098#[cfg(test)]
1099mod tests {
1100    use super::*;
1101    use crate::report::test_helpers::sample_results;
1102    use fallow_core::duplicates::{
1103        CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
1104        RefactoringKind, RefactoringSuggestion,
1105    };
1106    use fallow_core::results::*;
1107    use std::path::PathBuf;
1108
1109    #[test]
1110    fn markdown_empty_results_no_issues() {
1111        let root = PathBuf::from("/project");
1112        let results = AnalysisResults::default();
1113        let md = build_markdown(&results, &root);
1114        assert_eq!(md, "## Fallow: no issues found\n");
1115    }
1116
1117    #[test]
1118    fn markdown_contains_header_with_count() {
1119        let root = PathBuf::from("/project");
1120        let results = sample_results(&root);
1121        let md = build_markdown(&results, &root);
1122        assert!(md.starts_with(&format!(
1123            "## Fallow: {} issues found\n",
1124            results.total_issues()
1125        )));
1126    }
1127
1128    #[test]
1129    fn markdown_contains_all_sections() {
1130        let root = PathBuf::from("/project");
1131        let results = sample_results(&root);
1132        let md = build_markdown(&results, &root);
1133
1134        assert!(md.contains("### Unused files (1)"));
1135        assert!(md.contains("### Unused exports (1)"));
1136        assert!(md.contains("### Unused type exports (1)"));
1137        assert!(md.contains("### Unused dependencies (1)"));
1138        assert!(md.contains("### Unused devDependencies (1)"));
1139        assert!(md.contains("### Unused enum members (1)"));
1140        assert!(md.contains("### Unused class members (1)"));
1141        assert!(md.contains("### Unresolved imports (1)"));
1142        assert!(md.contains("### Unlisted dependencies (1)"));
1143        assert!(md.contains("### Duplicate exports (1)"));
1144        assert!(md.contains("### Type-only dependencies"));
1145        assert!(md.contains("### Test-only production dependencies"));
1146        assert!(md.contains("### Circular dependencies (1)"));
1147    }
1148
1149    #[test]
1150    fn markdown_unused_file_format() {
1151        let root = PathBuf::from("/project");
1152        let mut results = AnalysisResults::default();
1153        results.unused_files.push(UnusedFile {
1154            path: root.join("src/dead.ts"),
1155        });
1156        let md = build_markdown(&results, &root);
1157        assert!(md.contains("- `src/dead.ts`"));
1158    }
1159
1160    #[test]
1161    fn markdown_unused_export_grouped_by_file() {
1162        let root = PathBuf::from("/project");
1163        let mut results = AnalysisResults::default();
1164        results.unused_exports.push(UnusedExport {
1165            path: root.join("src/utils.ts"),
1166            export_name: "helperFn".to_string(),
1167            is_type_only: false,
1168            line: 10,
1169            col: 4,
1170            span_start: 120,
1171            is_re_export: false,
1172        });
1173        let md = build_markdown(&results, &root);
1174        assert!(md.contains("- `src/utils.ts`"));
1175        assert!(md.contains(":10 `helperFn`"));
1176    }
1177
1178    #[test]
1179    fn markdown_re_export_tagged() {
1180        let root = PathBuf::from("/project");
1181        let mut results = AnalysisResults::default();
1182        results.unused_exports.push(UnusedExport {
1183            path: root.join("src/index.ts"),
1184            export_name: "reExported".to_string(),
1185            is_type_only: false,
1186            line: 1,
1187            col: 0,
1188            span_start: 0,
1189            is_re_export: true,
1190        });
1191        let md = build_markdown(&results, &root);
1192        assert!(md.contains("(re-export)"));
1193    }
1194
1195    #[test]
1196    fn markdown_unused_dep_format() {
1197        let root = PathBuf::from("/project");
1198        let mut results = AnalysisResults::default();
1199        results.unused_dependencies.push(UnusedDependency {
1200            package_name: "lodash".to_string(),
1201            location: DependencyLocation::Dependencies,
1202            path: root.join("package.json"),
1203            line: 5,
1204            used_in_workspaces: Vec::new(),
1205        });
1206        let md = build_markdown(&results, &root);
1207        assert!(md.contains("- `lodash`"));
1208    }
1209
1210    #[test]
1211    fn markdown_circular_dep_format() {
1212        let root = PathBuf::from("/project");
1213        let mut results = AnalysisResults::default();
1214        results.circular_dependencies.push(CircularDependency {
1215            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1216            length: 2,
1217            line: 3,
1218            col: 0,
1219            is_cross_package: false,
1220        });
1221        let md = build_markdown(&results, &root);
1222        assert!(md.contains("`src/a.ts`"));
1223        assert!(md.contains("`src/b.ts`"));
1224        assert!(md.contains("\u{2192}"));
1225    }
1226
1227    #[test]
1228    fn markdown_strips_root_prefix() {
1229        let root = PathBuf::from("/project");
1230        let mut results = AnalysisResults::default();
1231        results.unused_files.push(UnusedFile {
1232            path: PathBuf::from("/project/src/deep/nested/file.ts"),
1233        });
1234        let md = build_markdown(&results, &root);
1235        assert!(md.contains("`src/deep/nested/file.ts`"));
1236        assert!(!md.contains("/project/"));
1237    }
1238
1239    #[test]
1240    fn markdown_single_issue_no_plural() {
1241        let root = PathBuf::from("/project");
1242        let mut results = AnalysisResults::default();
1243        results.unused_files.push(UnusedFile {
1244            path: root.join("src/dead.ts"),
1245        });
1246        let md = build_markdown(&results, &root);
1247        assert!(md.starts_with("## Fallow: 1 issue found\n"));
1248    }
1249
1250    #[test]
1251    fn markdown_type_only_dep_format() {
1252        let root = PathBuf::from("/project");
1253        let mut results = AnalysisResults::default();
1254        results.type_only_dependencies.push(TypeOnlyDependency {
1255            package_name: "zod".to_string(),
1256            path: root.join("package.json"),
1257            line: 8,
1258        });
1259        let md = build_markdown(&results, &root);
1260        assert!(md.contains("### Type-only dependencies"));
1261        assert!(md.contains("- `zod`"));
1262    }
1263
1264    #[test]
1265    fn markdown_escapes_backticks_in_export_names() {
1266        let root = PathBuf::from("/project");
1267        let mut results = AnalysisResults::default();
1268        results.unused_exports.push(UnusedExport {
1269            path: root.join("src/utils.ts"),
1270            export_name: "foo`bar".to_string(),
1271            is_type_only: false,
1272            line: 1,
1273            col: 0,
1274            span_start: 0,
1275            is_re_export: false,
1276        });
1277        let md = build_markdown(&results, &root);
1278        assert!(md.contains("foo\\`bar"));
1279        assert!(!md.contains("foo`bar`"));
1280    }
1281
1282    #[test]
1283    fn markdown_escapes_backticks_in_package_names() {
1284        let root = PathBuf::from("/project");
1285        let mut results = AnalysisResults::default();
1286        results.unused_dependencies.push(UnusedDependency {
1287            package_name: "pkg`name".to_string(),
1288            location: DependencyLocation::Dependencies,
1289            path: root.join("package.json"),
1290            line: 5,
1291            used_in_workspaces: Vec::new(),
1292        });
1293        let md = build_markdown(&results, &root);
1294        assert!(md.contains("pkg\\`name"));
1295    }
1296
1297    // ── Duplication markdown ──
1298
1299    #[test]
1300    fn duplication_markdown_empty() {
1301        let report = DuplicationReport::default();
1302        let root = PathBuf::from("/project");
1303        let md = build_duplication_markdown(&report, &root);
1304        assert_eq!(md, "## Fallow: no code duplication found\n");
1305    }
1306
1307    #[test]
1308    fn duplication_markdown_contains_groups() {
1309        let root = PathBuf::from("/project");
1310        let report = DuplicationReport {
1311            clone_groups: vec![CloneGroup {
1312                instances: vec![
1313                    CloneInstance {
1314                        file: root.join("src/a.ts"),
1315                        start_line: 1,
1316                        end_line: 10,
1317                        start_col: 0,
1318                        end_col: 0,
1319                        fragment: String::new(),
1320                    },
1321                    CloneInstance {
1322                        file: root.join("src/b.ts"),
1323                        start_line: 5,
1324                        end_line: 14,
1325                        start_col: 0,
1326                        end_col: 0,
1327                        fragment: String::new(),
1328                    },
1329                ],
1330                token_count: 50,
1331                line_count: 10,
1332            }],
1333            clone_families: vec![],
1334            mirrored_directories: vec![],
1335            stats: DuplicationStats {
1336                total_files: 10,
1337                files_with_clones: 2,
1338                total_lines: 500,
1339                duplicated_lines: 20,
1340                total_tokens: 2500,
1341                duplicated_tokens: 100,
1342                clone_groups: 1,
1343                clone_instances: 2,
1344                duplication_percentage: 4.0,
1345            },
1346        };
1347        let md = build_duplication_markdown(&report, &root);
1348        assert!(md.contains("**Clone group 1**"));
1349        assert!(md.contains("`src/a.ts:1-10`"));
1350        assert!(md.contains("`src/b.ts:5-14`"));
1351        assert!(md.contains("4.0% duplication"));
1352    }
1353
1354    #[test]
1355    fn duplication_markdown_contains_families() {
1356        let root = PathBuf::from("/project");
1357        let report = DuplicationReport {
1358            clone_groups: vec![CloneGroup {
1359                instances: vec![CloneInstance {
1360                    file: root.join("src/a.ts"),
1361                    start_line: 1,
1362                    end_line: 5,
1363                    start_col: 0,
1364                    end_col: 0,
1365                    fragment: String::new(),
1366                }],
1367                token_count: 30,
1368                line_count: 5,
1369            }],
1370            clone_families: vec![CloneFamily {
1371                files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1372                groups: vec![],
1373                total_duplicated_lines: 20,
1374                total_duplicated_tokens: 100,
1375                suggestions: vec![RefactoringSuggestion {
1376                    kind: RefactoringKind::ExtractFunction,
1377                    description: "Extract shared utility function".to_string(),
1378                    estimated_savings: 15,
1379                }],
1380            }],
1381            mirrored_directories: vec![],
1382            stats: DuplicationStats {
1383                clone_groups: 1,
1384                clone_instances: 1,
1385                duplication_percentage: 2.0,
1386                ..Default::default()
1387            },
1388        };
1389        let md = build_duplication_markdown(&report, &root);
1390        assert!(md.contains("### Clone Families"));
1391        assert!(md.contains("**Family 1**"));
1392        assert!(md.contains("Extract shared utility function"));
1393        assert!(md.contains("~15 lines saved"));
1394    }
1395
1396    // ── Health markdown ──
1397
1398    #[test]
1399    fn health_markdown_empty_no_findings() {
1400        let root = PathBuf::from("/project");
1401        let report = crate::health_types::HealthReport {
1402            summary: crate::health_types::HealthSummary {
1403                files_analyzed: 10,
1404                functions_analyzed: 50,
1405                ..Default::default()
1406            },
1407            ..Default::default()
1408        };
1409        let md = build_health_markdown(&report, &root);
1410        assert!(md.contains("no functions exceed complexity thresholds"));
1411        assert!(md.contains("**50** functions analyzed"));
1412    }
1413
1414    #[test]
1415    fn health_markdown_table_format() {
1416        let root = PathBuf::from("/project");
1417        let report = crate::health_types::HealthReport {
1418            findings: vec![crate::health_types::HealthFinding {
1419                path: root.join("src/utils.ts"),
1420                name: "parseExpression".to_string(),
1421                line: 42,
1422                col: 0,
1423                cyclomatic: 25,
1424                cognitive: 30,
1425                line_count: 80,
1426                param_count: 0,
1427                exceeded: crate::health_types::ExceededThreshold::Both,
1428                severity: crate::health_types::FindingSeverity::High,
1429                crap: None,
1430                coverage_pct: None,
1431                coverage_tier: None,
1432            }],
1433            summary: crate::health_types::HealthSummary {
1434                files_analyzed: 10,
1435                functions_analyzed: 50,
1436                functions_above_threshold: 1,
1437                ..Default::default()
1438            },
1439            ..Default::default()
1440        };
1441        let md = build_health_markdown(&report, &root);
1442        assert!(md.contains("## Fallow: 1 high complexity function\n"));
1443        assert!(md.contains("| File | Function |"));
1444        assert!(md.contains("`src/utils.ts:42`"));
1445        assert!(md.contains("`parseExpression`"));
1446        assert!(md.contains("25 **!**"));
1447        assert!(md.contains("30 **!**"));
1448        assert!(md.contains("| 80 |"));
1449        // CRAP column renders `-` when the finding didn't trigger on CRAP.
1450        assert!(md.contains("| - |"));
1451    }
1452
1453    #[test]
1454    fn health_markdown_crap_column_shows_score_and_marker() {
1455        let root = PathBuf::from("/project");
1456        let report = crate::health_types::HealthReport {
1457            findings: vec![crate::health_types::HealthFinding {
1458                path: root.join("src/risky.ts"),
1459                name: "branchy".to_string(),
1460                line: 1,
1461                col: 0,
1462                cyclomatic: 67,
1463                cognitive: 10,
1464                line_count: 80,
1465                param_count: 1,
1466                exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
1467                severity: crate::health_types::FindingSeverity::Critical,
1468                crap: Some(182.0),
1469                coverage_pct: None,
1470                coverage_tier: None,
1471            }],
1472            summary: crate::health_types::HealthSummary {
1473                files_analyzed: 1,
1474                functions_analyzed: 1,
1475                functions_above_threshold: 1,
1476                ..Default::default()
1477            },
1478            ..Default::default()
1479        };
1480        let md = build_health_markdown(&report, &root);
1481        assert!(
1482            md.contains("| CRAP |"),
1483            "markdown table should have CRAP column header: {md}"
1484        );
1485        assert!(
1486            md.contains("182.0 **!**"),
1487            "CRAP value should be rendered with a threshold marker: {md}"
1488        );
1489        assert!(
1490            md.contains("CRAP >="),
1491            "trailing summary line should reference the CRAP threshold: {md}"
1492        );
1493    }
1494
1495    #[test]
1496    fn health_markdown_no_marker_when_below_threshold() {
1497        let root = PathBuf::from("/project");
1498        let report = crate::health_types::HealthReport {
1499            findings: vec![crate::health_types::HealthFinding {
1500                path: root.join("src/utils.ts"),
1501                name: "helper".to_string(),
1502                line: 10,
1503                col: 0,
1504                cyclomatic: 15,
1505                cognitive: 20,
1506                line_count: 30,
1507                param_count: 0,
1508                exceeded: crate::health_types::ExceededThreshold::Cognitive,
1509                severity: crate::health_types::FindingSeverity::High,
1510                crap: None,
1511                coverage_pct: None,
1512                coverage_tier: None,
1513            }],
1514            summary: crate::health_types::HealthSummary {
1515                files_analyzed: 5,
1516                functions_analyzed: 20,
1517                functions_above_threshold: 1,
1518                ..Default::default()
1519            },
1520            ..Default::default()
1521        };
1522        let md = build_health_markdown(&report, &root);
1523        // Cyclomatic 15 is below threshold 20, no marker
1524        assert!(md.contains("| 15 |"));
1525        // Cognitive 20 exceeds threshold 15, has marker
1526        assert!(md.contains("20 **!**"));
1527    }
1528
1529    #[test]
1530    fn health_markdown_with_targets() {
1531        use crate::health_types::*;
1532
1533        let root = PathBuf::from("/project");
1534        let report = HealthReport {
1535            summary: HealthSummary {
1536                files_analyzed: 10,
1537                functions_analyzed: 50,
1538                ..Default::default()
1539            },
1540            targets: vec![
1541                RefactoringTarget {
1542                    path: PathBuf::from("/project/src/complex.ts"),
1543                    priority: 82.5,
1544                    efficiency: 27.5,
1545                    recommendation: "Split high-impact file".into(),
1546                    category: RecommendationCategory::SplitHighImpact,
1547                    effort: crate::health_types::EffortEstimate::High,
1548                    confidence: crate::health_types::Confidence::Medium,
1549                    factors: vec![ContributingFactor {
1550                        metric: "fan_in",
1551                        value: 25.0,
1552                        threshold: 10.0,
1553                        detail: "25 files depend on this".into(),
1554                    }],
1555                    evidence: None,
1556                },
1557                RefactoringTarget {
1558                    path: PathBuf::from("/project/src/legacy.ts"),
1559                    priority: 45.0,
1560                    efficiency: 45.0,
1561                    recommendation: "Remove 5 unused exports".into(),
1562                    category: RecommendationCategory::RemoveDeadCode,
1563                    effort: crate::health_types::EffortEstimate::Low,
1564                    confidence: crate::health_types::Confidence::High,
1565                    factors: vec![],
1566                    evidence: None,
1567                },
1568            ],
1569            ..Default::default()
1570        };
1571        let md = build_health_markdown(&report, &root);
1572
1573        // Should have refactoring targets section
1574        assert!(
1575            md.contains("Refactoring Targets"),
1576            "should contain targets heading"
1577        );
1578        assert!(
1579            md.contains("src/complex.ts"),
1580            "should contain target file path"
1581        );
1582        assert!(md.contains("27.5"), "should contain efficiency score");
1583        assert!(
1584            md.contains("Split high-impact file"),
1585            "should contain recommendation"
1586        );
1587        assert!(md.contains("src/legacy.ts"), "should contain second target");
1588    }
1589
1590    #[test]
1591    fn health_markdown_with_coverage_gaps() {
1592        use crate::health_types::*;
1593
1594        let root = PathBuf::from("/project");
1595        let report = HealthReport {
1596            summary: HealthSummary {
1597                files_analyzed: 10,
1598                functions_analyzed: 50,
1599                ..Default::default()
1600            },
1601            coverage_gaps: Some(CoverageGaps {
1602                summary: CoverageGapSummary {
1603                    runtime_files: 2,
1604                    covered_files: 0,
1605                    file_coverage_pct: 0.0,
1606                    untested_files: 1,
1607                    untested_exports: 1,
1608                },
1609                files: vec![UntestedFile {
1610                    path: root.join("src/app.ts"),
1611                    value_export_count: 2,
1612                }],
1613                exports: vec![UntestedExport {
1614                    path: root.join("src/app.ts"),
1615                    export_name: "loader".into(),
1616                    line: 12,
1617                    col: 4,
1618                }],
1619            }),
1620            ..Default::default()
1621        };
1622
1623        let md = build_health_markdown(&report, &root);
1624        assert!(md.contains("### Coverage Gaps"));
1625        assert!(md.contains("*1 untested files"));
1626        assert!(md.contains("`src/app.ts` (2 value exports)"));
1627        assert!(md.contains("`src/app.ts`:12 `loader`"));
1628    }
1629
1630    // ── Dependency in workspace package ──
1631
1632    #[test]
1633    fn markdown_dep_in_workspace_shows_package_label() {
1634        let root = PathBuf::from("/project");
1635        let mut results = AnalysisResults::default();
1636        results.unused_dependencies.push(UnusedDependency {
1637            package_name: "lodash".to_string(),
1638            location: DependencyLocation::Dependencies,
1639            path: root.join("packages/core/package.json"),
1640            line: 5,
1641            used_in_workspaces: Vec::new(),
1642        });
1643        let md = build_markdown(&results, &root);
1644        // Non-root package.json should show the label
1645        assert!(md.contains("(packages/core/package.json)"));
1646    }
1647
1648    #[test]
1649    fn markdown_dep_at_root_no_extra_label() {
1650        let root = PathBuf::from("/project");
1651        let mut results = AnalysisResults::default();
1652        results.unused_dependencies.push(UnusedDependency {
1653            package_name: "lodash".to_string(),
1654            location: DependencyLocation::Dependencies,
1655            path: root.join("package.json"),
1656            line: 5,
1657            used_in_workspaces: Vec::new(),
1658        });
1659        let md = build_markdown(&results, &root);
1660        assert!(md.contains("- `lodash`"));
1661        assert!(!md.contains("(package.json)"));
1662    }
1663
1664    #[test]
1665    fn markdown_root_dep_with_cross_workspace_context_uses_context_label() {
1666        let root = PathBuf::from("/project");
1667        let mut results = AnalysisResults::default();
1668        results.unused_dependencies.push(UnusedDependency {
1669            package_name: "lodash-es".to_string(),
1670            location: DependencyLocation::Dependencies,
1671            path: root.join("package.json"),
1672            line: 5,
1673            used_in_workspaces: vec![root.join("packages/consumer")],
1674        });
1675        let md = build_markdown(&results, &root);
1676        assert!(md.contains("- `lodash-es` (imported in packages/consumer)"));
1677        assert!(!md.contains("(package.json; imported in packages/consumer)"));
1678    }
1679
1680    // ── Multiple exports same file grouped ──
1681
1682    #[test]
1683    fn markdown_exports_grouped_by_file() {
1684        let root = PathBuf::from("/project");
1685        let mut results = AnalysisResults::default();
1686        results.unused_exports.push(UnusedExport {
1687            path: root.join("src/utils.ts"),
1688            export_name: "alpha".to_string(),
1689            is_type_only: false,
1690            line: 5,
1691            col: 0,
1692            span_start: 0,
1693            is_re_export: false,
1694        });
1695        results.unused_exports.push(UnusedExport {
1696            path: root.join("src/utils.ts"),
1697            export_name: "beta".to_string(),
1698            is_type_only: false,
1699            line: 10,
1700            col: 0,
1701            span_start: 0,
1702            is_re_export: false,
1703        });
1704        results.unused_exports.push(UnusedExport {
1705            path: root.join("src/other.ts"),
1706            export_name: "gamma".to_string(),
1707            is_type_only: false,
1708            line: 1,
1709            col: 0,
1710            span_start: 0,
1711            is_re_export: false,
1712        });
1713        let md = build_markdown(&results, &root);
1714        // File header should appear only once for utils.ts
1715        let utils_count = md.matches("- `src/utils.ts`").count();
1716        assert_eq!(utils_count, 1, "file header should appear once per file");
1717        // Both exports should be under it as sub-items
1718        assert!(md.contains(":5 `alpha`"));
1719        assert!(md.contains(":10 `beta`"));
1720    }
1721
1722    // ── Multiple issues plural header ──
1723
1724    #[test]
1725    fn markdown_multiple_issues_plural() {
1726        let root = PathBuf::from("/project");
1727        let mut results = AnalysisResults::default();
1728        results.unused_files.push(UnusedFile {
1729            path: root.join("src/a.ts"),
1730        });
1731        results.unused_files.push(UnusedFile {
1732            path: root.join("src/b.ts"),
1733        });
1734        let md = build_markdown(&results, &root);
1735        assert!(md.starts_with("## Fallow: 2 issues found\n"));
1736    }
1737
1738    // ── Duplication markdown with zero estimated savings ──
1739
1740    #[test]
1741    fn duplication_markdown_zero_savings_no_suffix() {
1742        let root = PathBuf::from("/project");
1743        let report = DuplicationReport {
1744            clone_groups: vec![CloneGroup {
1745                instances: vec![CloneInstance {
1746                    file: root.join("src/a.ts"),
1747                    start_line: 1,
1748                    end_line: 5,
1749                    start_col: 0,
1750                    end_col: 0,
1751                    fragment: String::new(),
1752                }],
1753                token_count: 30,
1754                line_count: 5,
1755            }],
1756            clone_families: vec![CloneFamily {
1757                files: vec![root.join("src/a.ts")],
1758                groups: vec![],
1759                total_duplicated_lines: 5,
1760                total_duplicated_tokens: 30,
1761                suggestions: vec![RefactoringSuggestion {
1762                    kind: RefactoringKind::ExtractFunction,
1763                    description: "Extract function".to_string(),
1764                    estimated_savings: 0,
1765                }],
1766            }],
1767            mirrored_directories: vec![],
1768            stats: DuplicationStats {
1769                clone_groups: 1,
1770                clone_instances: 1,
1771                duplication_percentage: 1.0,
1772                ..Default::default()
1773            },
1774        };
1775        let md = build_duplication_markdown(&report, &root);
1776        assert!(md.contains("Extract function"));
1777        assert!(!md.contains("lines saved"));
1778    }
1779
1780    // ── Health markdown vital signs ──
1781
1782    #[test]
1783    fn health_markdown_vital_signs_table() {
1784        let root = PathBuf::from("/project");
1785        let report = crate::health_types::HealthReport {
1786            summary: crate::health_types::HealthSummary {
1787                files_analyzed: 10,
1788                functions_analyzed: 50,
1789                ..Default::default()
1790            },
1791            vital_signs: Some(crate::health_types::VitalSigns {
1792                avg_cyclomatic: 3.5,
1793                p90_cyclomatic: 12,
1794                dead_file_pct: Some(5.0),
1795                dead_export_pct: Some(10.2),
1796                duplication_pct: None,
1797                maintainability_avg: Some(72.3),
1798                hotspot_count: Some(3),
1799                circular_dep_count: Some(1),
1800                unused_dep_count: Some(2),
1801                counts: None,
1802                unit_size_profile: None,
1803                unit_interfacing_profile: None,
1804                p95_fan_in: None,
1805                coupling_high_pct: None,
1806                total_loc: 15_200,
1807                ..Default::default()
1808            }),
1809            ..Default::default()
1810        };
1811        let md = build_health_markdown(&report, &root);
1812        assert!(md.contains("## Vital Signs"));
1813        assert!(md.contains("| Metric | Value |"));
1814        assert!(md.contains("| Total LOC | 15200 |"));
1815        assert!(md.contains("| Avg Cyclomatic | 3.5 |"));
1816        assert!(md.contains("| P90 Cyclomatic | 12 |"));
1817        assert!(md.contains("| Dead Files | 5.0% |"));
1818        assert!(md.contains("| Dead Exports | 10.2% |"));
1819        assert!(md.contains("| Maintainability (avg) | 72.3 |"));
1820        assert!(md.contains("| Hotspots | 3 |"));
1821        assert!(md.contains("| Circular Deps | 1 |"));
1822        assert!(md.contains("| Unused Deps | 2 |"));
1823    }
1824
1825    // ── Health markdown file scores ──
1826
1827    #[test]
1828    fn health_markdown_file_scores_table() {
1829        let root = PathBuf::from("/project");
1830        let report = crate::health_types::HealthReport {
1831            findings: vec![crate::health_types::HealthFinding {
1832                path: root.join("src/dummy.ts"),
1833                name: "fn".to_string(),
1834                line: 1,
1835                col: 0,
1836                cyclomatic: 25,
1837                cognitive: 20,
1838                line_count: 50,
1839                param_count: 0,
1840                exceeded: crate::health_types::ExceededThreshold::Both,
1841                severity: crate::health_types::FindingSeverity::High,
1842                crap: None,
1843                coverage_pct: None,
1844                coverage_tier: None,
1845            }],
1846            summary: crate::health_types::HealthSummary {
1847                files_analyzed: 5,
1848                functions_analyzed: 10,
1849                functions_above_threshold: 1,
1850                files_scored: Some(1),
1851                average_maintainability: Some(65.0),
1852                ..Default::default()
1853            },
1854            file_scores: vec![crate::health_types::FileHealthScore {
1855                path: root.join("src/utils.ts"),
1856                fan_in: 5,
1857                fan_out: 3,
1858                dead_code_ratio: 0.25,
1859                complexity_density: 0.8,
1860                maintainability_index: 72.5,
1861                total_cyclomatic: 40,
1862                total_cognitive: 30,
1863                function_count: 10,
1864                lines: 200,
1865                crap_max: 0.0,
1866                crap_above_threshold: 0,
1867            }],
1868            ..Default::default()
1869        };
1870        let md = build_health_markdown(&report, &root);
1871        assert!(md.contains("### File Health Scores (1 files)"));
1872        assert!(md.contains("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density |"));
1873        assert!(md.contains("| `src/utils.ts` | 72.5 | 5 | 3 | 25% | 0.80 |"));
1874        assert!(md.contains("**Average maintainability index:** 65.0/100"));
1875    }
1876
1877    // ── Health markdown hotspots ──
1878
1879    #[test]
1880    fn health_markdown_hotspots_table() {
1881        let root = PathBuf::from("/project");
1882        let report = crate::health_types::HealthReport {
1883            findings: vec![crate::health_types::HealthFinding {
1884                path: root.join("src/dummy.ts"),
1885                name: "fn".to_string(),
1886                line: 1,
1887                col: 0,
1888                cyclomatic: 25,
1889                cognitive: 20,
1890                line_count: 50,
1891                param_count: 0,
1892                exceeded: crate::health_types::ExceededThreshold::Both,
1893                severity: crate::health_types::FindingSeverity::High,
1894                crap: None,
1895                coverage_pct: None,
1896                coverage_tier: None,
1897            }],
1898            summary: crate::health_types::HealthSummary {
1899                files_analyzed: 5,
1900                functions_analyzed: 10,
1901                functions_above_threshold: 1,
1902                ..Default::default()
1903            },
1904            hotspots: vec![crate::health_types::HotspotEntry {
1905                path: root.join("src/hot.ts"),
1906                score: 85.0,
1907                commits: 42,
1908                weighted_commits: 35.0,
1909                lines_added: 500,
1910                lines_deleted: 200,
1911                complexity_density: 1.2,
1912                fan_in: 10,
1913                trend: fallow_core::churn::ChurnTrend::Accelerating,
1914                ownership: None,
1915                is_test_path: false,
1916            }],
1917            hotspot_summary: Some(crate::health_types::HotspotSummary {
1918                since: "6 months".to_string(),
1919                min_commits: 3,
1920                files_analyzed: 50,
1921                files_excluded: 5,
1922                shallow_clone: false,
1923            }),
1924            ..Default::default()
1925        };
1926        let md = build_health_markdown(&report, &root);
1927        assert!(md.contains("### Hotspots (1 files, since 6 months)"));
1928        assert!(md.contains("| `src/hot.ts` | 85.0 | 42 | 700 | 1.20 | 10 | accelerating |"));
1929        assert!(md.contains("*5 files excluded (< 3 commits)*"));
1930    }
1931
1932    // ── Health markdown metric legend ──
1933
1934    #[test]
1935    fn health_markdown_metric_legend_with_scores() {
1936        let root = PathBuf::from("/project");
1937        let report = crate::health_types::HealthReport {
1938            findings: vec![crate::health_types::HealthFinding {
1939                path: root.join("src/x.ts"),
1940                name: "f".to_string(),
1941                line: 1,
1942                col: 0,
1943                cyclomatic: 25,
1944                cognitive: 20,
1945                line_count: 10,
1946                param_count: 0,
1947                exceeded: crate::health_types::ExceededThreshold::Both,
1948                severity: crate::health_types::FindingSeverity::High,
1949                crap: None,
1950                coverage_pct: None,
1951                coverage_tier: None,
1952            }],
1953            summary: crate::health_types::HealthSummary {
1954                files_analyzed: 1,
1955                functions_analyzed: 1,
1956                functions_above_threshold: 1,
1957                files_scored: Some(1),
1958                average_maintainability: Some(70.0),
1959                ..Default::default()
1960            },
1961            file_scores: vec![crate::health_types::FileHealthScore {
1962                path: root.join("src/x.ts"),
1963                fan_in: 1,
1964                fan_out: 1,
1965                dead_code_ratio: 0.0,
1966                complexity_density: 0.5,
1967                maintainability_index: 80.0,
1968                total_cyclomatic: 10,
1969                total_cognitive: 8,
1970                function_count: 2,
1971                lines: 50,
1972                crap_max: 0.0,
1973                crap_above_threshold: 0,
1974            }],
1975            ..Default::default()
1976        };
1977        let md = build_health_markdown(&report, &root);
1978        assert!(md.contains("<details><summary>Metric definitions</summary>"));
1979        assert!(md.contains("**MI**: Maintainability Index"));
1980        assert!(md.contains("**Fan-in**"));
1981        assert!(md.contains("Full metric reference"));
1982    }
1983
1984    // ── Health markdown truncated findings ──
1985
1986    #[test]
1987    fn health_markdown_truncated_findings_shown_count() {
1988        let root = PathBuf::from("/project");
1989        let report = crate::health_types::HealthReport {
1990            findings: vec![crate::health_types::HealthFinding {
1991                path: root.join("src/x.ts"),
1992                name: "f".to_string(),
1993                line: 1,
1994                col: 0,
1995                cyclomatic: 25,
1996                cognitive: 20,
1997                line_count: 10,
1998                param_count: 0,
1999                exceeded: crate::health_types::ExceededThreshold::Both,
2000                severity: crate::health_types::FindingSeverity::High,
2001                crap: None,
2002                coverage_pct: None,
2003                coverage_tier: None,
2004            }],
2005            summary: crate::health_types::HealthSummary {
2006                files_analyzed: 10,
2007                functions_analyzed: 50,
2008                functions_above_threshold: 5, // 5 total but only 1 shown
2009                ..Default::default()
2010            },
2011            ..Default::default()
2012        };
2013        let md = build_health_markdown(&report, &root);
2014        assert!(md.contains("5 high complexity functions (1 shown)"));
2015    }
2016
2017    // ── escape_backticks ──
2018
2019    #[test]
2020    fn escape_backticks_handles_multiple() {
2021        assert_eq!(escape_backticks("a`b`c"), "a\\`b\\`c");
2022    }
2023
2024    #[test]
2025    fn escape_backticks_no_backticks_unchanged() {
2026        assert_eq!(escape_backticks("hello"), "hello");
2027    }
2028
2029    // ── Unresolved import in markdown ──
2030
2031    #[test]
2032    fn markdown_unresolved_import_grouped_by_file() {
2033        let root = PathBuf::from("/project");
2034        let mut results = AnalysisResults::default();
2035        results.unresolved_imports.push(UnresolvedImport {
2036            path: root.join("src/app.ts"),
2037            specifier: "./missing".to_string(),
2038            line: 3,
2039            col: 0,
2040            specifier_col: 0,
2041        });
2042        let md = build_markdown(&results, &root);
2043        assert!(md.contains("### Unresolved imports (1)"));
2044        assert!(md.contains("- `src/app.ts`"));
2045        assert!(md.contains(":3 `./missing`"));
2046    }
2047
2048    // ── Markdown optional dep ──
2049
2050    #[test]
2051    fn markdown_unused_optional_dep() {
2052        let root = PathBuf::from("/project");
2053        let mut results = AnalysisResults::default();
2054        results.unused_optional_dependencies.push(UnusedDependency {
2055            package_name: "fsevents".to_string(),
2056            location: DependencyLocation::OptionalDependencies,
2057            path: root.join("package.json"),
2058            line: 12,
2059            used_in_workspaces: Vec::new(),
2060        });
2061        let md = build_markdown(&results, &root);
2062        assert!(md.contains("### Unused optionalDependencies (1)"));
2063        assert!(md.contains("- `fsevents`"));
2064    }
2065
2066    // ── Health markdown no hotspot exclusion message when 0 excluded ──
2067
2068    #[test]
2069    fn health_markdown_hotspots_no_excluded_message() {
2070        let root = PathBuf::from("/project");
2071        let report = crate::health_types::HealthReport {
2072            findings: vec![crate::health_types::HealthFinding {
2073                path: root.join("src/x.ts"),
2074                name: "f".to_string(),
2075                line: 1,
2076                col: 0,
2077                cyclomatic: 25,
2078                cognitive: 20,
2079                line_count: 10,
2080                param_count: 0,
2081                exceeded: crate::health_types::ExceededThreshold::Both,
2082                severity: crate::health_types::FindingSeverity::High,
2083                crap: None,
2084                coverage_pct: None,
2085                coverage_tier: None,
2086            }],
2087            summary: crate::health_types::HealthSummary {
2088                files_analyzed: 5,
2089                functions_analyzed: 10,
2090                functions_above_threshold: 1,
2091                ..Default::default()
2092            },
2093            hotspots: vec![crate::health_types::HotspotEntry {
2094                path: root.join("src/hot.ts"),
2095                score: 50.0,
2096                commits: 10,
2097                weighted_commits: 8.0,
2098                lines_added: 100,
2099                lines_deleted: 50,
2100                complexity_density: 0.5,
2101                fan_in: 3,
2102                trend: fallow_core::churn::ChurnTrend::Stable,
2103                ownership: None,
2104                is_test_path: false,
2105            }],
2106            hotspot_summary: Some(crate::health_types::HotspotSummary {
2107                since: "6 months".to_string(),
2108                min_commits: 3,
2109                files_analyzed: 50,
2110                files_excluded: 0,
2111                shallow_clone: false,
2112            }),
2113            ..Default::default()
2114        };
2115        let md = build_health_markdown(&report, &root);
2116        assert!(!md.contains("files excluded"));
2117    }
2118
2119    // ── Duplication markdown plural ──
2120
2121    #[test]
2122    fn duplication_markdown_single_group_no_plural() {
2123        let root = PathBuf::from("/project");
2124        let report = DuplicationReport {
2125            clone_groups: vec![CloneGroup {
2126                instances: vec![CloneInstance {
2127                    file: root.join("src/a.ts"),
2128                    start_line: 1,
2129                    end_line: 5,
2130                    start_col: 0,
2131                    end_col: 0,
2132                    fragment: String::new(),
2133                }],
2134                token_count: 30,
2135                line_count: 5,
2136            }],
2137            clone_families: vec![],
2138            mirrored_directories: vec![],
2139            stats: DuplicationStats {
2140                clone_groups: 1,
2141                clone_instances: 1,
2142                duplication_percentage: 2.0,
2143                ..Default::default()
2144            },
2145        };
2146        let md = build_duplication_markdown(&report, &root);
2147        assert!(md.contains("1 clone group found"));
2148        assert!(!md.contains("1 clone groups found"));
2149    }
2150}