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