Skip to main content

fallow_cli/report/
markdown.rs

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