Skip to main content

fallow_cli/report/
markdown.rs

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