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