Skip to main content

fallow_api/
markdown_output.rs

1use std::borrow::Cow;
2use std::fmt::Write;
3use std::path::Path;
4
5use fallow_engine::duplicates::DuplicationReport;
6use fallow_types::output_dead_code::*;
7use fallow_types::results::{AnalysisResults, UnusedExport, UnusedMember};
8
9use fallow_output::normalize_uri;
10
11use crate::ResultGroup;
12
13fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
14    path.strip_prefix(root).unwrap_or(path)
15}
16
17fn plural(count: usize) -> &'static str {
18    if count == 1 { "" } else { "s" }
19}
20
21fn format_window(seconds: u64) -> String {
22    if seconds < 60 {
23        return format!("{seconds} s");
24    }
25    let minutes = seconds / 60;
26    if minutes < 120 {
27        return format!("{minutes} min");
28    }
29    let hours = minutes / 60;
30    if hours < 48 {
31        format!("{hours} h")
32    } else {
33        format!("{} d", hours / 24)
34    }
35}
36
37/// Escape backticks in user-controlled strings to prevent breaking markdown code spans.
38fn escape_backticks(s: &str) -> String {
39    s.replace('`', "\\`")
40}
41
42fn display_complexity_entry_name(name: &str) -> Cow<'_, str> {
43    match name {
44        "<template>" => Cow::Borrowed("<template> (template complexity)"),
45        "<component>" => Cow::Borrowed("<component> (component rollup)"),
46        _ => Cow::Borrowed(name),
47    }
48}
49
50/// Build markdown output for analysis results.
51pub fn build_markdown(results: &AnalysisResults, root: &Path) -> String {
52    let total = results.total_issues();
53    let mut out = String::new();
54
55    if total == 0 {
56        out.push_str("## Fallow: no issues found\n");
57        return out;
58    }
59
60    let _ = write!(out, "## Fallow: {total} issue{} found\n\n", plural(total));
61
62    push_markdown_primary_sections(&mut out, results, root);
63    push_markdown_import_sections(&mut out, results, root);
64    push_markdown_dependency_detail_sections(&mut out, results, root);
65    push_markdown_graph_sections(&mut out, results, &|path| {
66        markdown_relative_path(path, root)
67    });
68    push_markdown_catalog_sections(&mut out, results, &|path| {
69        markdown_relative_path(path, root)
70    });
71
72    out
73}
74
75fn markdown_relative_path(path: &Path, root: &Path) -> String {
76    escape_backticks(&normalize_uri(
77        &relative_path(path, root).display().to_string(),
78    ))
79}
80
81fn push_markdown_primary_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
82    markdown_section(out, &results.unused_files, "Unused files", |file| {
83        vec![format!(
84            "- `{}`",
85            markdown_relative_path(&file.file.path, root)
86        )]
87    });
88
89    markdown_grouped_section(
90        out,
91        &results.unused_exports,
92        "Unused exports",
93        root,
94        |e| e.export.path.as_path(),
95        |e: &UnusedExportFinding| format_export(&e.export),
96    );
97
98    markdown_grouped_section(
99        out,
100        &results.unused_types,
101        "Unused type exports",
102        root,
103        |e| e.export.path.as_path(),
104        |e: &UnusedTypeFinding| format_export(&e.export),
105    );
106
107    markdown_grouped_section(
108        out,
109        &results.private_type_leaks,
110        "Private type leaks",
111        root,
112        |e| e.leak.path.as_path(),
113        format_private_type_leak,
114    );
115
116    push_markdown_dependency_sections(out, results, root);
117    push_markdown_member_sections(out, results, root);
118}
119
120fn push_markdown_import_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
121    markdown_grouped_section(
122        out,
123        &results.unresolved_imports,
124        "Unresolved imports",
125        root,
126        |i| i.import.path.as_path(),
127        |i| {
128            format!(
129                ":{} `{}`",
130                i.import.line,
131                escape_backticks(&i.import.specifier)
132            )
133        },
134    );
135
136    markdown_section(
137        out,
138        &results.unlisted_dependencies,
139        "Unlisted dependencies",
140        |dep| vec![format!("- `{}`", escape_backticks(&dep.dep.package_name))],
141    );
142
143    markdown_section(
144        out,
145        &results.duplicate_exports,
146        "Duplicate exports",
147        |dup| {
148            let locations: Vec<String> = dup
149                .export
150                .locations
151                .iter()
152                .map(|loc| format!("`{}`", markdown_relative_path(&loc.path, root)))
153                .collect();
154            vec![format!(
155                "- `{}` in {}",
156                escape_backticks(&dup.export.export_name),
157                locations.join(", ")
158            )]
159        },
160    );
161}
162
163fn push_markdown_dependency_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
164    markdown_section(
165        out,
166        &results.unused_dependencies,
167        "Unused dependencies",
168        |dep| {
169            format_dependency(
170                &dep.dep.package_name,
171                &dep.dep.path,
172                &dep.dep.used_in_workspaces,
173                root,
174            )
175        },
176    );
177    markdown_section(
178        out,
179        &results.unused_dev_dependencies,
180        "Unused devDependencies",
181        |dep| {
182            format_dependency(
183                &dep.dep.package_name,
184                &dep.dep.path,
185                &dep.dep.used_in_workspaces,
186                root,
187            )
188        },
189    );
190    markdown_section(
191        out,
192        &results.unused_optional_dependencies,
193        "Unused optionalDependencies",
194        |dep| {
195            format_dependency(
196                &dep.dep.package_name,
197                &dep.dep.path,
198                &dep.dep.used_in_workspaces,
199                root,
200            )
201        },
202    );
203}
204
205fn push_markdown_member_sections(out: &mut String, results: &AnalysisResults, root: &Path) {
206    markdown_grouped_section(
207        out,
208        &results.unused_enum_members,
209        "Unused enum members",
210        root,
211        |m| m.member.path.as_path(),
212        |m: &UnusedEnumMemberFinding| format_member(&m.member),
213    );
214    markdown_grouped_section(
215        out,
216        &results.unused_class_members,
217        "Unused class members",
218        root,
219        |m| m.member.path.as_path(),
220        |m: &UnusedClassMemberFinding| format_member(&m.member),
221    );
222    markdown_grouped_section(
223        out,
224        &results.unused_store_members,
225        "Unused store members",
226        root,
227        |m| m.member.path.as_path(),
228        |m: &UnusedStoreMemberFinding| format_member(&m.member),
229    );
230}
231
232fn push_markdown_dependency_detail_sections(
233    out: &mut String,
234    results: &AnalysisResults,
235    root: &Path,
236) {
237    markdown_section(
238        out,
239        &results.type_only_dependencies,
240        "Type-only dependencies (consider moving to devDependencies)",
241        |dep| format_dependency(&dep.dep.package_name, &dep.dep.path, &[], root),
242    );
243    markdown_section(
244        out,
245        &results.test_only_dependencies,
246        "Test-only production dependencies (consider moving to devDependencies)",
247        |dep| format_dependency(&dep.dep.package_name, &dep.dep.path, &[], root),
248    );
249}
250
251fn push_markdown_graph_sections(
252    out: &mut String,
253    results: &AnalysisResults,
254    rel: &dyn Fn(&Path) -> String,
255) {
256    push_markdown_structure_sections(out, results, rel);
257    push_markdown_framework_sections(out, results, rel);
258    push_markdown_component_sections(out, results, rel);
259    push_markdown_suppression_sections(out, results, rel);
260}
261
262fn push_markdown_structure_sections(
263    out: &mut String,
264    results: &AnalysisResults,
265    rel: &dyn Fn(&Path) -> String,
266) {
267    markdown_section(
268        out,
269        &results.circular_dependencies,
270        "Circular dependencies",
271        |cycle| format_markdown_circular_dependency(cycle, rel),
272    );
273    markdown_section(
274        out,
275        &results.re_export_cycles,
276        "Re-export cycles",
277        |cycle| format_markdown_re_export_cycle(cycle, rel),
278    );
279    markdown_section(
280        out,
281        &results.boundary_violations,
282        "Boundary violations",
283        |v| format_markdown_boundary_violation(v, rel),
284    );
285    markdown_section(
286        out,
287        &results.boundary_coverage_violations,
288        "Boundary coverage",
289        |v| format_markdown_boundary_coverage(v, rel),
290    );
291    markdown_section(
292        out,
293        &results.boundary_call_violations,
294        "Boundary calls",
295        |v| format_markdown_boundary_call(v, rel),
296    );
297    markdown_section(out, &results.policy_violations, "Policy violations", |v| {
298        format_markdown_policy_violation(v, rel)
299    });
300}
301
302fn push_markdown_framework_sections(
303    out: &mut String,
304    results: &AnalysisResults,
305    rel: &dyn Fn(&Path) -> String,
306) {
307    markdown_section(
308        out,
309        &results.invalid_client_exports,
310        "Invalid client exports",
311        |e| format_markdown_invalid_client_export(e, rel),
312    );
313    markdown_section(
314        out,
315        &results.mixed_client_server_barrels,
316        "Mixed client/server barrels",
317        |b| format_markdown_mixed_client_server_barrel(b, rel),
318    );
319    markdown_section(
320        out,
321        &results.misplaced_directives,
322        "Misplaced directives",
323        |d| format_markdown_misplaced_directive(d, rel),
324    );
325    markdown_section(out, &results.route_collisions, "Route collisions", |c| {
326        format_markdown_route_collision(c, rel)
327    });
328    markdown_section(
329        out,
330        &results.dynamic_segment_name_conflicts,
331        "Dynamic segment conflicts",
332        |c| format_markdown_dynamic_segment_name_conflict(c, rel),
333    );
334    markdown_section(
335        out,
336        &results.unprovided_injects,
337        "Unprovided injects",
338        |i| format_markdown_unprovided_inject(i, rel),
339    );
340}
341
342fn push_markdown_component_sections(
343    out: &mut String,
344    results: &AnalysisResults,
345    rel: &dyn Fn(&Path) -> String,
346) {
347    markdown_section(
348        out,
349        &results.unrendered_components,
350        "Unrendered components",
351        |c| format_markdown_unrendered_component(c, rel),
352    );
353    markdown_section(
354        out,
355        &results.unused_component_props,
356        "Unused component props",
357        |p| format_markdown_unused_component_prop(p, rel),
358    );
359    markdown_section(
360        out,
361        &results.unused_component_emits,
362        "Unused component emits",
363        |e| format_markdown_unused_component_emit(e, rel),
364    );
365    markdown_section(
366        out,
367        &results.unused_component_inputs,
368        "Unused component inputs",
369        |i| format_markdown_unused_component_input(i, rel),
370    );
371    markdown_section(
372        out,
373        &results.unused_component_outputs,
374        "Unused component outputs",
375        |o| format_markdown_unused_component_output(o, rel),
376    );
377    markdown_section(
378        out,
379        &results.unused_svelte_events,
380        "Unused Svelte events",
381        |e| format_markdown_unused_svelte_event(e, rel),
382    );
383    markdown_section(
384        out,
385        &results.unused_server_actions,
386        "Unused server actions",
387        |a| format_markdown_unused_server_action(a, rel),
388    );
389    markdown_section(
390        out,
391        &results.unused_load_data_keys,
392        "Unused load data keys",
393        |k| format_markdown_unused_load_data_key(k, rel),
394    );
395}
396
397fn push_markdown_suppression_sections(
398    out: &mut String,
399    results: &AnalysisResults,
400    rel: &dyn Fn(&Path) -> String,
401) {
402    markdown_section(
403        out,
404        &results.stale_suppressions,
405        "Stale suppressions",
406        |s| {
407            vec![format!(
408                "- `{}`:{} `{}` ({})",
409                rel(&s.path),
410                s.line,
411                escape_backticks(&s.description()),
412                escape_backticks(&s.explanation()),
413            )]
414        },
415    );
416}
417
418fn format_markdown_circular_dependency(
419    cycle: &fallow_types::output_dead_code::CircularDependencyFinding,
420    rel: &dyn Fn(&Path) -> String,
421) -> Vec<String> {
422    let chain: Vec<String> = cycle.cycle.files.iter().map(|p| rel(p)).collect();
423    let mut display_chain = chain.clone();
424    if let Some(first) = chain.first() {
425        display_chain.push(first.clone());
426    }
427    let cross_pkg_tag = if cycle.cycle.is_cross_package {
428        " *(cross-package)*"
429    } else {
430        ""
431    };
432    vec![format!(
433        "- {}{}",
434        display_chain
435            .iter()
436            .map(|s| format!("`{s}`"))
437            .collect::<Vec<_>>()
438            .join(" \u{2192} "),
439        cross_pkg_tag
440    )]
441}
442
443fn format_markdown_re_export_cycle(
444    cycle: &fallow_types::output_dead_code::ReExportCycleFinding,
445    rel: &dyn Fn(&Path) -> String,
446) -> Vec<String> {
447    let chain: Vec<String> = cycle.cycle.files.iter().map(|p| rel(p)).collect();
448    let kind_tag = match cycle.cycle.kind {
449        fallow_types::results::ReExportCycleKind::SelfLoop => " *(self-loop)*",
450        fallow_types::results::ReExportCycleKind::MultiNode => "",
451    };
452    vec![format!(
453        "- {}{}",
454        chain
455            .iter()
456            .map(|s| format!("`{s}`"))
457            .collect::<Vec<_>>()
458            .join(" <-> "),
459        kind_tag
460    )]
461}
462
463fn format_markdown_boundary_violation(
464    v: &fallow_types::output_dead_code::BoundaryViolationFinding,
465    rel: &dyn Fn(&Path) -> String,
466) -> Vec<String> {
467    vec![format!(
468        "- `{}`:{}  \u{2192} `{}` ({} \u{2192} {})",
469        rel(&v.violation.from_path),
470        v.violation.line,
471        rel(&v.violation.to_path),
472        v.violation.from_zone,
473        v.violation.to_zone,
474    )]
475}
476
477fn format_markdown_boundary_coverage(
478    v: &fallow_types::output_dead_code::BoundaryCoverageViolationFinding,
479    rel: &dyn Fn(&Path) -> String,
480) -> Vec<String> {
481    vec![format!(
482        "- `{}`:{} no matching boundary zone",
483        rel(&v.violation.path),
484        v.violation.line,
485    )]
486}
487
488fn format_markdown_boundary_call(
489    v: &fallow_types::output_dead_code::BoundaryCallViolationFinding,
490    rel: &dyn Fn(&Path) -> String,
491) -> Vec<String> {
492    vec![format!(
493        "- `{}`:{} `{}` forbidden in zone `{}` (pattern `{}`)",
494        rel(&v.violation.path),
495        v.violation.line,
496        v.violation.callee,
497        v.violation.zone,
498        v.violation.pattern,
499    )]
500}
501
502fn format_markdown_policy_violation(
503    v: &fallow_types::output_dead_code::PolicyViolationFinding,
504    rel: &dyn Fn(&Path) -> String,
505) -> Vec<String> {
506    vec![format!(
507        "- `{}`:{} `{}` banned by `{}/{}`{}",
508        rel(&v.violation.path),
509        v.violation.line,
510        v.violation.matched,
511        v.violation.pack,
512        v.violation.rule_id,
513        v.violation
514            .message
515            .as_deref()
516            .map(|m| format!(" ({m})"))
517            .unwrap_or_default(),
518    )]
519}
520
521fn format_markdown_invalid_client_export(
522    e: &fallow_types::output_dead_code::InvalidClientExportFinding,
523    rel: &dyn Fn(&Path) -> String,
524) -> Vec<String> {
525    vec![format!(
526        "- `{}`:{} `{}` (from `\"{}\"`)",
527        rel(&e.export.path),
528        e.export.line,
529        e.export.export_name,
530        e.export.directive,
531    )]
532}
533
534fn format_markdown_mixed_client_server_barrel(
535    b: &fallow_types::output_dead_code::MixedClientServerBarrelFinding,
536    rel: &dyn Fn(&Path) -> String,
537) -> Vec<String> {
538    vec![format!(
539        "- `{}`:{} re-exports client `{}` and server-only `{}`",
540        rel(&b.barrel.path),
541        b.barrel.line,
542        b.barrel.client_origin,
543        b.barrel.server_origin,
544    )]
545}
546
547fn format_markdown_misplaced_directive(
548    d: &fallow_types::output_dead_code::MisplacedDirectiveFinding,
549    rel: &dyn Fn(&Path) -> String,
550) -> Vec<String> {
551    vec![format!(
552        "- `{}`:{} `\"{}\"` is not in the leading position and is ignored",
553        rel(&d.directive_site.path),
554        d.directive_site.line,
555        d.directive_site.directive,
556    )]
557}
558
559fn format_markdown_unprovided_inject(
560    i: &fallow_types::output_dead_code::UnprovidedInjectFinding,
561    rel: &dyn Fn(&Path) -> String,
562) -> Vec<String> {
563    vec![format!(
564        "- `{}`:{} `{}` has no matching provide(`{}`) in this project; at runtime it returns undefined",
565        rel(&i.inject.path),
566        i.inject.line,
567        escape_backticks(&i.inject.key_name),
568        escape_backticks(&i.inject.key_name),
569    )]
570}
571
572fn format_markdown_unrendered_component(
573    c: &fallow_types::output_dead_code::UnrenderedComponentFinding,
574    rel: &dyn Fn(&Path) -> String,
575) -> Vec<String> {
576    // Lit: `component_name` is the registered TAG, so render it as a custom
577    // element `<x-foo>` (mirrors the human formatter's `framework == "lit"`
578    // branch so the two human-facing surfaces stay consistent).
579    if c.component.framework == "lit" {
580        return vec![format!(
581            "- `{}`:{} `<{}>` is a registered custom element but rendered in no template (render it or remove it)",
582            rel(&c.component.path),
583            c.component.line,
584            escape_backticks(&c.component.component_name),
585        )];
586    }
587    vec![format!(
588        "- `{}`:{} `{}` is reachable but rendered nowhere in this project (render it somewhere or remove it)",
589        rel(&c.component.path),
590        c.component.line,
591        escape_backticks(&c.component.component_name),
592    )]
593}
594
595fn format_markdown_unused_component_prop(
596    p: &fallow_types::output_dead_code::UnusedComponentPropFinding,
597    rel: &dyn Fn(&Path) -> String,
598) -> Vec<String> {
599    vec![format!(
600        "- `{}`:{} `{}` is declared but referenced nowhere in this component (remove it or use it)",
601        rel(&p.prop.path),
602        p.prop.line,
603        escape_backticks(&p.prop.prop_name),
604    )]
605}
606
607fn format_markdown_unused_component_emit(
608    e: &fallow_types::output_dead_code::UnusedComponentEmitFinding,
609    rel: &dyn Fn(&Path) -> String,
610) -> Vec<String> {
611    vec![format!(
612        "- `{}`:{} `{}` is declared but emitted nowhere in this component (remove it or emit it)",
613        rel(&e.emit.path),
614        e.emit.line,
615        escape_backticks(&e.emit.emit_name),
616    )]
617}
618
619fn format_markdown_unused_svelte_event(
620    e: &fallow_types::output_dead_code::UnusedSvelteEventFinding,
621    rel: &dyn Fn(&Path) -> String,
622) -> Vec<String> {
623    vec![format!(
624        "- `{}`:{} `{}` is dispatched but listened to nowhere in the project (remove it or listen for it)",
625        rel(&e.event.path),
626        e.event.line,
627        escape_backticks(&e.event.event_name),
628    )]
629}
630
631fn format_markdown_unused_component_input(
632    i: &fallow_types::output_dead_code::UnusedComponentInputFinding,
633    rel: &dyn Fn(&Path) -> String,
634) -> Vec<String> {
635    vec![format!(
636        "- `{}`:{} `{}` is declared but referenced nowhere in this component (remove it or use it)",
637        rel(&i.input.path),
638        i.input.line,
639        escape_backticks(&i.input.input_name),
640    )]
641}
642
643fn format_markdown_unused_component_output(
644    o: &fallow_types::output_dead_code::UnusedComponentOutputFinding,
645    rel: &dyn Fn(&Path) -> String,
646) -> Vec<String> {
647    vec![format!(
648        "- `{}`:{} `{}` is declared but emitted nowhere in this component (remove it or emit it)",
649        rel(&o.output.path),
650        o.output.line,
651        escape_backticks(&o.output.output_name),
652    )]
653}
654
655fn format_markdown_unused_server_action(
656    a: &fallow_types::output_dead_code::UnusedServerActionFinding,
657    rel: &dyn Fn(&Path) -> String,
658) -> Vec<String> {
659    vec![format!(
660        "- `{}`:{} `{}` is exported from a \"use server\" file but no code in this project references it",
661        rel(&a.action.path),
662        a.action.line,
663        escape_backticks(&a.action.action_name),
664    )]
665}
666
667fn format_markdown_unused_load_data_key(
668    k: &fallow_types::output_dead_code::UnusedLoadDataKeyFinding,
669    rel: &dyn Fn(&Path) -> String,
670) -> Vec<String> {
671    vec![format!(
672        "- `{}`:{} `{}` is returned from load() but no consumer reads it",
673        rel(&k.key.path),
674        k.key.line,
675        escape_backticks(&k.key.key_name),
676    )]
677}
678
679fn format_markdown_route_collision(
680    c: &fallow_types::output_dead_code::RouteCollisionFinding,
681    rel: &dyn Fn(&Path) -> String,
682) -> Vec<String> {
683    vec![format!(
684        "- `{}` resolves to `{}` (shared with {} other route file(s))",
685        rel(&c.collision.path),
686        c.collision.url,
687        c.collision.conflicting_paths.len(),
688    )]
689}
690
691fn format_markdown_dynamic_segment_name_conflict(
692    c: &fallow_types::output_dead_code::DynamicSegmentNameConflictFinding,
693    rel: &dyn Fn(&Path) -> String,
694) -> Vec<String> {
695    vec![format!(
696        "- `{}` crashes at runtime: different slug names ({}) at the same dynamic path `{}`; \
697         `next build` passes but the route fails on its first request (rename to one consistent slug)",
698        rel(&c.conflict.path),
699        c.conflict.conflicting_segments.join(" vs "),
700        c.conflict.position,
701    )]
702}
703
704fn push_markdown_catalog_sections(
705    out: &mut String,
706    results: &AnalysisResults,
707    rel: &dyn Fn(&Path) -> String,
708) {
709    markdown_section(
710        out,
711        &results.unused_catalog_entries,
712        "Unused catalog entries",
713        |entry| format_unused_catalog_entry(entry, rel),
714    );
715    markdown_section(
716        out,
717        &results.empty_catalog_groups,
718        "Empty catalog groups",
719        |group| {
720            vec![format!(
721                "- `{}` `{}`:{}",
722                escape_backticks(&group.group.catalog_name),
723                rel(&group.group.path),
724                group.group.line,
725            )]
726        },
727    );
728    markdown_section(
729        out,
730        &results.unresolved_catalog_references,
731        "Unresolved catalog references",
732        |finding| format_unresolved_catalog_reference(finding, rel),
733    );
734    markdown_section(
735        out,
736        &results.unused_dependency_overrides,
737        "Unused dependency overrides",
738        |finding| format_unused_dependency_override(finding, rel),
739    );
740    markdown_section(
741        out,
742        &results.misconfigured_dependency_overrides,
743        "Misconfigured dependency overrides",
744        |finding| {
745            vec![format!(
746                "- `{}` -> `{}` (`{}`) `{}`:{} ({})",
747                escape_backticks(&finding.entry.raw_key),
748                escape_backticks(&finding.entry.raw_value),
749                finding.entry.source.as_label(),
750                rel(&finding.entry.path),
751                finding.entry.line,
752                finding.entry.reason.describe(),
753            )]
754        },
755    );
756}
757
758fn format_unused_catalog_entry(
759    entry: &UnusedCatalogEntryFinding,
760    rel: &dyn Fn(&Path) -> String,
761) -> Vec<String> {
762    let mut row = format!(
763        "- `{}` (`{}`) `{}`:{}",
764        escape_backticks(&entry.entry.entry_name),
765        escape_backticks(&entry.entry.catalog_name),
766        rel(&entry.entry.path),
767        entry.entry.line,
768    );
769    if !entry.entry.hardcoded_consumers.is_empty() {
770        let consumers = entry
771            .entry
772            .hardcoded_consumers
773            .iter()
774            .map(|p| format!("`{}`", rel(p)))
775            .collect::<Vec<_>>()
776            .join(", ");
777        let _ = write!(row, " (hardcoded in {consumers})");
778    }
779    vec![row]
780}
781
782fn format_unresolved_catalog_reference(
783    finding: &UnresolvedCatalogReferenceFinding,
784    rel: &dyn Fn(&Path) -> String,
785) -> Vec<String> {
786    let mut row = format!(
787        "- `{}` (`{}`) `{}`:{}",
788        escape_backticks(&finding.reference.entry_name),
789        escape_backticks(&finding.reference.catalog_name),
790        rel(&finding.reference.path),
791        finding.reference.line,
792    );
793    if !finding.reference.available_in_catalogs.is_empty() {
794        let alts = finding
795            .reference
796            .available_in_catalogs
797            .iter()
798            .map(|c| format!("`{}`", escape_backticks(c)))
799            .collect::<Vec<_>>()
800            .join(", ");
801        let _ = write!(row, " (available in: {alts})");
802    }
803    vec![row]
804}
805
806fn format_unused_dependency_override(
807    finding: &UnusedDependencyOverrideFinding,
808    rel: &dyn Fn(&Path) -> String,
809) -> Vec<String> {
810    let mut row = format!(
811        "- `{}` -> `{}` (`{}`) `{}`:{}",
812        escape_backticks(&finding.entry.raw_key),
813        escape_backticks(&finding.entry.version_range),
814        finding.entry.source.as_label(),
815        rel(&finding.entry.path),
816        finding.entry.line,
817    );
818    if let Some(hint) = &finding.entry.hint {
819        let _ = write!(row, " (hint: {})", escape_backticks(hint));
820    }
821    vec![row]
822}
823
824/// Build grouped markdown output: each group gets a heading and issue sections.
825#[must_use]
826pub fn build_grouped_markdown(groups: &[ResultGroup], root: &Path) -> String {
827    let total: usize = groups.iter().map(|g| g.results.total_issues()).sum();
828    let mut out = String::new();
829
830    if total == 0 {
831        out.push_str("## Fallow: no issues found\n");
832        return out;
833    }
834
835    let _ = writeln!(
836        out,
837        "## Fallow: {total} issue{} found (grouped)\n",
838        plural(total)
839    );
840
841    for group in groups {
842        let count = group.results.total_issues();
843        if count == 0 {
844            continue;
845        }
846        let _ = writeln!(
847            out,
848            "## {} ({count} issue{})\n",
849            escape_backticks(&group.key),
850            plural(count)
851        );
852        if let Some(ref owners) = group.owners
853            && !owners.is_empty()
854        {
855            let joined = owners
856                .iter()
857                .map(|owner| escape_backticks(owner))
858                .collect::<Vec<_>>()
859                .join(" ");
860            let _ = writeln!(out, "Owners: {joined}\n");
861        }
862        let body = build_markdown(&group.results, root);
863        let sections = body
864            .strip_prefix("## Fallow: no issues found\n")
865            .or_else(|| body.find("\n\n").map(|pos| &body[pos + 2..]))
866            .unwrap_or(&body);
867        out.push_str(sections);
868    }
869
870    out
871}
872
873fn format_export(e: &UnusedExport) -> String {
874    let re = if e.is_re_export { " (re-export)" } else { "" };
875    format!(":{} `{}`{re}", e.line, escape_backticks(&e.export_name))
876}
877
878fn format_private_type_leak(
879    entry: &fallow_types::output_dead_code::PrivateTypeLeakFinding,
880) -> String {
881    let e = &entry.leak;
882    format!(
883        ":{} `{}` references private type `{}`",
884        e.line,
885        escape_backticks(&e.export_name),
886        escape_backticks(&e.type_name)
887    )
888}
889
890fn format_member(m: &UnusedMember) -> String {
891    format!(
892        ":{} `{}.{}`",
893        m.line,
894        escape_backticks(&m.parent_name),
895        escape_backticks(&m.member_name)
896    )
897}
898
899fn format_dependency(
900    dep_name: &str,
901    pkg_path: &Path,
902    used_in_workspaces: &[std::path::PathBuf],
903    root: &Path,
904) -> Vec<String> {
905    let name = escape_backticks(dep_name);
906    let pkg_label = relative_path(pkg_path, root).display().to_string();
907    let workspace_context = if used_in_workspaces.is_empty() {
908        String::new()
909    } else {
910        let workspaces = used_in_workspaces
911            .iter()
912            .map(|path| escape_backticks(&relative_path(path, root).display().to_string()))
913            .collect::<Vec<_>>()
914            .join(", ");
915        format!("; imported in {workspaces}")
916    };
917    if pkg_label == "package.json" && workspace_context.is_empty() {
918        vec![format!("- `{name}`")]
919    } else {
920        let label = if pkg_label == "package.json" {
921            workspace_context.trim_start_matches("; ").to_string()
922        } else {
923            format!("{}{workspace_context}", escape_backticks(&pkg_label))
924        };
925        vec![format!("- `{name}` ({label})")]
926    }
927}
928
929/// Emit a markdown section with a header and per-item lines. Skipped if empty.
930fn markdown_section<T>(
931    out: &mut String,
932    items: &[T],
933    title: &str,
934    format_lines: impl Fn(&T) -> Vec<String>,
935) {
936    if items.is_empty() {
937        return;
938    }
939    let _ = write!(out, "### {title} ({})\n\n", items.len());
940    for item in items {
941        for line in format_lines(item) {
942            out.push_str(&line);
943            out.push('\n');
944        }
945    }
946    out.push('\n');
947}
948
949fn markdown_grouped_section<'a, T>(
950    out: &mut String,
951    items: &'a [T],
952    title: &str,
953    root: &Path,
954    get_path: impl Fn(&'a T) -> &'a Path,
955    format_detail: impl Fn(&T) -> String,
956) {
957    if items.is_empty() {
958        return;
959    }
960    let _ = write!(out, "### {title} ({})\n\n", items.len());
961
962    let mut indices: Vec<usize> = (0..items.len()).collect();
963    indices.sort_by(|&a, &b| get_path(&items[a]).cmp(get_path(&items[b])));
964
965    let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
966    let mut last_file = String::new();
967    for &i in &indices {
968        let item = &items[i];
969        let file_str = rel(get_path(item));
970        if file_str != last_file {
971            let _ = writeln!(out, "- `{file_str}`");
972            last_file = file_str;
973        }
974        let _ = writeln!(out, "  - {}", format_detail(item));
975    }
976    out.push('\n');
977}
978
979/// Build markdown output for duplication results.
980#[must_use]
981pub fn build_duplication_markdown(report: &DuplicationReport, root: &Path) -> String {
982    let mut out = String::new();
983
984    if report.clone_groups.is_empty() {
985        out.push_str("## Fallow: no code duplication found\n");
986        return out;
987    }
988
989    let stats = &report.stats;
990    let _ = write!(
991        out,
992        "## Fallow: {} clone group{} found ({:.1}% duplication)\n\n",
993        stats.clone_groups,
994        plural(stats.clone_groups),
995        stats.duplication_percentage,
996    );
997
998    write_duplication_groups(&mut out, report, root);
999    write_duplication_families(&mut out, report, root);
1000
1001    let _ = writeln!(
1002        out,
1003        "**Summary:** {} duplicated lines ({:.1}%) across {} file{}",
1004        stats.duplicated_lines,
1005        stats.duplication_percentage,
1006        stats.files_with_clones,
1007        plural(stats.files_with_clones),
1008    );
1009
1010    out
1011}
1012
1013/// Write the clone-groups subsection of the duplication markdown.
1014fn write_duplication_groups(out: &mut String, report: &DuplicationReport, root: &Path) {
1015    let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
1016    out.push_str("### Duplicates\n\n");
1017    for (i, group) in report.clone_groups.iter().enumerate() {
1018        let instance_count = group.instances.len();
1019        let _ = write!(
1020            out,
1021            "**Clone group {}** ({} lines, {instance_count} instance{})\n\n",
1022            i + 1,
1023            group.line_count,
1024            plural(instance_count)
1025        );
1026        for instance in &group.instances {
1027            let relative = rel(&instance.file);
1028            let _ = writeln!(
1029                out,
1030                "- `{relative}:{}-{}`",
1031                instance.start_line, instance.end_line
1032            );
1033        }
1034        out.push('\n');
1035    }
1036}
1037
1038/// Write the clone-families subsection of the duplication markdown.
1039fn write_duplication_families(out: &mut String, report: &DuplicationReport, root: &Path) {
1040    if report.clone_families.is_empty() {
1041        return;
1042    }
1043    let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
1044    out.push_str("### Clone Families\n\n");
1045    for (i, family) in report.clone_families.iter().enumerate() {
1046        let file_names: Vec<_> = family.files.iter().map(|f| rel(f)).collect();
1047        let _ = write!(
1048            out,
1049            "**Family {}** ({} group{}, {} lines across {})\n\n",
1050            i + 1,
1051            family.groups.len(),
1052            plural(family.groups.len()),
1053            family.total_duplicated_lines,
1054            file_names
1055                .iter()
1056                .map(|s| format!("`{s}`"))
1057                .collect::<Vec<_>>()
1058                .join(", "),
1059        );
1060        for suggestion in &family.suggestions {
1061            let savings = if suggestion.estimated_savings > 0 {
1062                format!(" (~{} lines saved)", suggestion.estimated_savings)
1063            } else {
1064                String::new()
1065            };
1066            let _ = writeln!(out, "- {}{savings}", suggestion.description);
1067        }
1068        out.push('\n');
1069    }
1070}
1071
1072/// Build markdown output for health (complexity) results.
1073#[must_use]
1074pub fn build_health_markdown(report: &fallow_output::HealthReport, root: &Path) -> String {
1075    let mut out = String::new();
1076
1077    if let Some(ref hs) = report.health_score {
1078        let _ = writeln!(out, "## Health Score: {:.0} ({})\n", hs.score, hs.grade);
1079    }
1080
1081    write_trend_section(&mut out, report);
1082    write_vital_signs_section(&mut out, report);
1083
1084    if report.findings.is_empty()
1085        && report.file_scores.is_empty()
1086        && report.coverage_gaps.is_none()
1087        && report.hotspots.is_empty()
1088        && report.targets.is_empty()
1089        && report.runtime_coverage.is_none()
1090        && report.coverage_intelligence.is_none()
1091        && report.threshold_overrides.is_empty()
1092        && report.css_analytics.is_none()
1093    {
1094        if report.vital_signs.is_none() {
1095            let _ = write!(
1096                out,
1097                "## Fallow: no functions exceed complexity thresholds\n\n\
1098                 **{}** functions analyzed (max cyclomatic: {}, max cognitive: {}, max CRAP: {:.1})\n",
1099                report.summary.functions_analyzed,
1100                report.summary.max_cyclomatic_threshold,
1101                report.summary.max_cognitive_threshold,
1102                report.summary.max_crap_threshold,
1103            );
1104        }
1105        return out;
1106    }
1107
1108    write_findings_section(&mut out, report, root);
1109    write_threshold_overrides_section(&mut out, report, root);
1110    write_runtime_coverage_section(&mut out, report, root);
1111    write_coverage_intelligence_section(&mut out, report, root);
1112    write_coverage_gaps_section(&mut out, report, root);
1113    write_file_scores_section(&mut out, report, root);
1114    write_hotspots_section(&mut out, report, root);
1115    write_targets_section(&mut out, report, root);
1116    write_css_analytics_section(&mut out, report);
1117    write_metric_legend(&mut out, report);
1118
1119    out
1120}
1121
1122/// Render the opt-in `## CSS Health` markdown section (present only with
1123/// `--css`): a summary of structural metrics, value sprawl, and candidate counts
1124/// plus a bounded list of the most actionable located candidates.
1125fn write_css_analytics_section(out: &mut String, report: &fallow_output::HealthReport) {
1126    let Some(ref css) = report.css_analytics else {
1127        return;
1128    };
1129    let s = &css.summary;
1130    if !out.is_empty() && !out.ends_with("\n\n") {
1131        out.push('\n');
1132    }
1133    out.push_str("## CSS Health\n\n");
1134    let important_pct = if s.total_declarations > 0 {
1135        f64::from(s.important_declarations) / f64::from(s.total_declarations) * 100.0
1136    } else {
1137        0.0
1138    };
1139    let _ = writeln!(
1140        out,
1141        "- Stylesheets: {} | Rules: {} | !important: {important_pct:.1}% | Empty rules: {} | Max nesting: {}",
1142        s.files_analyzed, s.total_rules, s.empty_rules, s.max_nesting_depth,
1143    );
1144    let _ = writeln!(
1145        out,
1146        "- Value sprawl: {} colors | {} font sizes | {} z-index | {} shadows | {} radii | {} line-heights",
1147        s.unique_colors,
1148        s.unique_font_sizes,
1149        s.unique_z_indexes,
1150        s.unique_box_shadows,
1151        s.unique_border_radii,
1152        s.unique_line_heights,
1153    );
1154    let _ = writeln!(
1155        out,
1156        "- 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",
1157        s.keyframes_unreferenced,
1158        s.keyframes_undefined,
1159        s.duplicate_declaration_blocks,
1160        s.scoped_unused_classes,
1161        s.tailwind_arbitrary_values,
1162        s.unused_property_registrations,
1163        s.unused_layers,
1164        s.unresolved_class_references,
1165        s.unreferenced_css_classes,
1166        s.unused_font_faces,
1167        s.unused_theme_tokens,
1168    );
1169    write_css_candidate_details(out, css);
1170    out.push('\n');
1171}
1172
1173fn write_css_candidate_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
1174    write_css_keyframe_details(out, css);
1175    write_css_tailwind_details(out, css);
1176    write_css_class_candidate_details(out, css);
1177    write_css_font_candidate_details(out, css);
1178    write_css_font_size_mix_details(out, css);
1179}
1180
1181fn write_css_keyframe_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
1182    if !css.undefined_keyframes.is_empty() {
1183        let named: Vec<String> = css
1184            .undefined_keyframes
1185            .iter()
1186            .take(5)
1187            .map(|kf| format!("`{}` ({})", kf.name, kf.path))
1188            .collect();
1189        let _ = writeln!(
1190            out,
1191            "- Undefined @keyframes (candidates; likely typo or CSS-in-JS): {}",
1192            named.join(", "),
1193        );
1194    }
1195}
1196
1197fn write_css_tailwind_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
1198    if !css.tailwind_arbitrary_values.is_empty() {
1199        let named: Vec<String> = css
1200            .tailwind_arbitrary_values
1201            .iter()
1202            .take(5)
1203            .map(|a| format!("`{}` ({}x)", a.value, a.count))
1204            .collect();
1205        let _ = writeln!(out, "- Top Tailwind arbitrary values: {}", named.join(", "));
1206    }
1207}
1208
1209fn write_css_class_candidate_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
1210    if !css.unresolved_class_references.is_empty() {
1211        let named: Vec<String> = css
1212            .unresolved_class_references
1213            .iter()
1214            .take(5)
1215            .map(|u| {
1216                format!(
1217                    "`{}` -> `{}` ({}:{})",
1218                    u.class, u.suggestion, u.path, u.line
1219                )
1220            })
1221            .collect();
1222        let _ = writeln!(
1223            out,
1224            "- Likely class typos (candidates; verify, may be CSS-in-JS or external): {}",
1225            named.join(", "),
1226        );
1227    }
1228    if !css.unreferenced_css_classes.is_empty() {
1229        let named: Vec<String> = css
1230            .unreferenced_css_classes
1231            .iter()
1232            .take(5)
1233            .map(|u| format!("`.{}` ({}:{})", u.class, u.path, u.line))
1234            .collect();
1235        let _ = writeln!(
1236            out,
1237            "- Unreferenced global classes (candidates; verify no email / server / CMS / Markdown applies them): {}",
1238            named.join(", "),
1239        );
1240    }
1241}
1242
1243fn write_css_font_candidate_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
1244    if !css.unused_font_faces.is_empty() {
1245        let named: Vec<String> = css
1246            .unused_font_faces
1247            .iter()
1248            .take(5)
1249            .map(|u| format!("`{}` ({})", u.family, u.path))
1250            .collect();
1251        let _ = writeln!(
1252            out,
1253            "- Unused @font-face (dead web-font; candidates, may be set from JS/inline): {}",
1254            named.join(", "),
1255        );
1256    }
1257    if !css.unused_theme_tokens.is_empty() {
1258        let named: Vec<String> = css
1259            .unused_theme_tokens
1260            .iter()
1261            .take(5)
1262            .map(|u| format!("`{}` ({}:{})", u.token, u.path, u.line))
1263            .collect();
1264        let _ = writeln!(
1265            out,
1266            "- Unused @theme tokens (dead Tailwind v4 design tokens; candidates, may be consumed by a plugin or downstream repo): {}",
1267            named.join(", "),
1268        );
1269    }
1270}
1271
1272fn write_css_font_size_mix_details(out: &mut String, css: &fallow_output::CssAnalyticsReport) {
1273    if let Some(mix) = &css.font_size_unit_mix {
1274        let breakdown: Vec<String> = mix
1275            .notations
1276            .iter()
1277            .map(|n| format!("{} {}", n.count, n.notation))
1278            .collect();
1279        let _ = writeln!(
1280            out,
1281            "- Font sizes mix {} units (candidate, standardize unless intentional): {}",
1282            mix.notations.len(),
1283            breakdown.join(", "),
1284        );
1285    }
1286}
1287
1288fn write_coverage_intelligence_section(
1289    out: &mut String,
1290    report: &fallow_output::HealthReport,
1291    root: &Path,
1292) {
1293    let Some(ref intelligence) = report.coverage_intelligence else {
1294        return;
1295    };
1296    if !out.is_empty() && !out.ends_with("\n\n") {
1297        out.push('\n');
1298    }
1299    let _ = writeln!(
1300        out,
1301        "## Coverage Intelligence\n\n- Verdict: {}\n- Findings: {}\n- Ambiguous matches skipped: {}\n",
1302        intelligence.verdict,
1303        intelligence.summary.findings,
1304        intelligence.summary.skipped_ambiguous_matches,
1305    );
1306    if intelligence.findings.is_empty() {
1307        if intelligence.summary.skipped_ambiguous_matches > 0 {
1308            let match_phrase = if intelligence.summary.skipped_ambiguous_matches == 1 {
1309                "evidence match was"
1310            } else {
1311                "evidence matches were"
1312            };
1313            let _ = writeln!(
1314                out,
1315                "No actionable findings were emitted because {} ambiguous {match_phrase} skipped.\n",
1316                intelligence.summary.skipped_ambiguous_matches,
1317            );
1318        }
1319        return;
1320    }
1321    out.push_str("| ID | Path | Identity | Verdict | Recommendation | Confidence | Signals |\n");
1322    out.push_str("|:---|:-----|:---------|:--------|:---------------|:-----------|:--------|\n");
1323    for finding in &intelligence.findings {
1324        write_coverage_intelligence_row(out, finding, root);
1325    }
1326    out.push('\n');
1327}
1328
1329/// Write one coverage-intelligence finding row.
1330fn write_coverage_intelligence_row(
1331    out: &mut String,
1332    finding: &fallow_output::CoverageIntelligenceFinding,
1333    root: &Path,
1334) {
1335    let path = escape_backticks(&normalize_uri(
1336        &relative_path(&finding.path, root).display().to_string(),
1337    ));
1338    let identity = finding
1339        .identity
1340        .as_deref()
1341        .map_or_else(|| "-".to_owned(), escape_backticks);
1342    let signals = finding
1343        .signals
1344        .iter()
1345        .map(ToString::to_string)
1346        .collect::<Vec<_>>()
1347        .join(", ");
1348    let _ = writeln!(
1349        out,
1350        "| `{}` | `{}`:{} | `{}` | {} | {} | {} | {} |",
1351        escape_backticks(&finding.id),
1352        path,
1353        finding.line,
1354        identity,
1355        finding.verdict,
1356        finding.recommendation,
1357        finding.confidence,
1358        signals,
1359    );
1360}
1361
1362fn write_runtime_coverage_section(
1363    out: &mut String,
1364    report: &fallow_output::HealthReport,
1365    root: &Path,
1366) {
1367    let Some(ref production) = report.runtime_coverage else {
1368        return;
1369    };
1370    if !out.is_empty() && !out.ends_with("\n\n") {
1371        out.push('\n');
1372    }
1373    write_runtime_coverage_summary(out, production);
1374    write_runtime_coverage_findings(out, production, root);
1375    write_runtime_coverage_hot_paths(out, production, root);
1376}
1377
1378/// Write the runtime-coverage summary header and capture-quality lines.
1379fn write_runtime_coverage_summary(
1380    out: &mut String,
1381    production: &fallow_output::RuntimeCoverageReport,
1382) {
1383    let _ = writeln!(
1384        out,
1385        "## 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",
1386        production.verdict,
1387        production.summary.functions_tracked,
1388        production.summary.functions_hit,
1389        production.summary.functions_unhit,
1390        production.summary.functions_untracked,
1391        production.summary.coverage_percent,
1392        production.summary.trace_count,
1393        production.summary.period_days,
1394        production.summary.deployments_seen,
1395    );
1396    if let Some(watermark) = production.watermark {
1397        let _ = writeln!(out, "- Watermark: {watermark}\n");
1398    }
1399    if let Some(ref quality) = production.summary.capture_quality
1400        && quality.lazy_parse_warning
1401    {
1402        let window = format_window(quality.window_seconds);
1403        let _ = writeln!(
1404            out,
1405            "- Capture quality: short window ({} from {} instance(s), {:.1}% of functions untracked); lazy-parsed scripts may not appear.\n",
1406            window, quality.instances_observed, quality.untracked_ratio_percent,
1407        );
1408    }
1409}
1410
1411/// Write the runtime-coverage per-finding table.
1412fn write_runtime_coverage_findings(
1413    out: &mut String,
1414    production: &fallow_output::RuntimeCoverageReport,
1415    root: &Path,
1416) {
1417    if production.findings.is_empty() {
1418        return;
1419    }
1420    out.push_str("| ID | Path | Function | Verdict | Invocations | Confidence |\n");
1421    out.push_str("|:---|:-----|:---------|:--------|------------:|:-----------|\n");
1422    for finding in &production.findings {
1423        let invocations = finding
1424            .invocations
1425            .map_or_else(|| "-".to_owned(), |hits| hits.to_string());
1426        let _ = writeln!(
1427            out,
1428            "| `{}` | `{}`:{} | `{}` | {} | {} | {} |",
1429            escape_backticks(&finding.id),
1430            escape_backticks(&normalize_uri(
1431                &relative_path(&finding.path, root).display().to_string(),
1432            )),
1433            finding.line,
1434            escape_backticks(&finding.function),
1435            finding.verdict,
1436            invocations,
1437            finding.confidence,
1438        );
1439    }
1440    out.push('\n');
1441}
1442
1443/// Write the runtime-coverage hot-paths table.
1444fn write_runtime_coverage_hot_paths(
1445    out: &mut String,
1446    production: &fallow_output::RuntimeCoverageReport,
1447    root: &Path,
1448) {
1449    if production.hot_paths.is_empty() {
1450        return;
1451    }
1452    out.push_str("| ID | Hot path | Function | Invocations | Percentile |\n");
1453    out.push_str("|:---|:---------|:---------|------------:|-----------:|\n");
1454    for entry in &production.hot_paths {
1455        let _ = writeln!(
1456            out,
1457            "| `{}` | `{}`:{} | `{}` | {} | {} |",
1458            escape_backticks(&entry.id),
1459            escape_backticks(&normalize_uri(
1460                &relative_path(&entry.path, root).display().to_string(),
1461            )),
1462            entry.line,
1463            escape_backticks(&entry.function),
1464            entry.invocations,
1465            entry.percentile,
1466        );
1467    }
1468    out.push('\n');
1469}
1470
1471/// Write the trend comparison table to the output.
1472fn write_trend_section(out: &mut String, report: &fallow_output::HealthReport) {
1473    let Some(ref trend) = report.health_trend else {
1474        return;
1475    };
1476    let sha_str = trend
1477        .compared_to
1478        .git_sha
1479        .as_deref()
1480        .map_or(String::new(), |sha| format!(" ({sha})"));
1481    let _ = writeln!(
1482        out,
1483        "## Trend (vs {}{})\n",
1484        trend
1485            .compared_to
1486            .timestamp
1487            .get(..10)
1488            .unwrap_or(&trend.compared_to.timestamp),
1489        sha_str,
1490    );
1491    out.push_str("| Metric | Previous | Current | Delta | Direction |\n");
1492    out.push_str("|:-------|:---------|:--------|:------|:----------|\n");
1493    for m in &trend.metrics {
1494        write_trend_metric_row(out, m);
1495    }
1496    let md_sha = trend
1497        .compared_to
1498        .git_sha
1499        .as_deref()
1500        .map_or(String::new(), |sha| format!(" ({sha})"));
1501    let _ = writeln!(
1502        out,
1503        "\n*vs {}{} · {} {} available*\n",
1504        trend
1505            .compared_to
1506            .timestamp
1507            .get(..10)
1508            .unwrap_or(&trend.compared_to.timestamp),
1509        md_sha,
1510        trend.snapshots_loaded,
1511        if trend.snapshots_loaded == 1 {
1512            "snapshot"
1513        } else {
1514            "snapshots"
1515        },
1516    );
1517}
1518
1519/// Write one trend metric row with unit-aware value and delta formatting.
1520fn write_trend_metric_row(out: &mut String, m: &fallow_output::TrendMetric) {
1521    let fmt_val = |v: f64| -> String {
1522        if m.unit == "%" {
1523            format!("{v:.1}%")
1524        } else if (v - v.round()).abs() < 0.05 {
1525            format!("{v:.0}")
1526        } else {
1527            format!("{v:.1}")
1528        }
1529    };
1530    let prev = fmt_val(m.previous);
1531    let cur = fmt_val(m.current);
1532    let delta = if m.unit == "%" {
1533        format!("{:+.1}%", m.delta)
1534    } else if (m.delta - m.delta.round()).abs() < 0.05 {
1535        format!("{:+.0}", m.delta)
1536    } else {
1537        format!("{:+.1}", m.delta)
1538    };
1539    let _ = writeln!(
1540        out,
1541        "| {} | {} | {} | {} | {} {} |",
1542        m.label,
1543        prev,
1544        cur,
1545        delta,
1546        m.direction.arrow(),
1547        m.direction.label(),
1548    );
1549}
1550
1551/// Write the vital signs summary table to the output.
1552fn write_vital_signs_section(out: &mut String, report: &fallow_output::HealthReport) {
1553    let Some(ref vs) = report.vital_signs else {
1554        return;
1555    };
1556    out.push_str("## Vital Signs\n\n");
1557    out.push_str("| Metric | Value |\n");
1558    out.push_str("|:-------|------:|\n");
1559    if vs.total_loc > 0 {
1560        let _ = writeln!(out, "| Total LOC | {} |", vs.total_loc);
1561    }
1562    let _ = writeln!(out, "| Avg Cyclomatic | {:.1} |", vs.avg_cyclomatic);
1563    let _ = writeln!(out, "| P90 Cyclomatic | {} |", vs.p90_cyclomatic);
1564    if let Some(v) = vs.dead_file_pct {
1565        let _ = writeln!(out, "| Dead Files | {v:.1}% |");
1566    }
1567    if let Some(v) = vs.dead_export_pct {
1568        let _ = writeln!(out, "| Dead Exports | {v:.1}% |");
1569    }
1570    if let Some(v) = vs.maintainability_avg {
1571        let _ = writeln!(out, "| Maintainability (avg) | {v:.1} |");
1572    }
1573    if let Some(v) = vs.hotspot_count {
1574        let label = report.hotspot_summary.as_ref().map_or_else(
1575            || "Hotspots".to_string(),
1576            |summary| format!("Hotspots (since {})", summary.since),
1577        );
1578        let _ = writeln!(out, "| {label} | {v} |");
1579    }
1580    if let Some(v) = vs.circular_dep_count {
1581        let _ = writeln!(out, "| Circular Deps | {v} |");
1582    }
1583    if let Some(v) = vs.unused_dep_count {
1584        let _ = writeln!(out, "| Unused Deps | {v} |");
1585    }
1586    out.push('\n');
1587}
1588
1589/// Write the complexity findings table to the output.
1590fn write_findings_section(out: &mut String, report: &fallow_output::HealthReport, root: &Path) {
1591    if report.findings.is_empty() {
1592        return;
1593    }
1594
1595    let has_synthetic = report
1596        .findings
1597        .iter()
1598        .any(|finding| matches!(finding.name.as_str(), "<template>" | "<component>"));
1599    write_findings_heading(out, report, has_synthetic);
1600    write_findings_table_header(out, has_synthetic);
1601
1602    for finding in &report.findings {
1603        write_findings_row(out, finding, report, root);
1604    }
1605
1606    let s = &report.summary;
1607    let _ = write!(
1608        out,
1609        "\n**{files}** files, **{funcs}** functions analyzed \
1610         (thresholds: cyclomatic > {cyc}, cognitive > {cog}, CRAP >= {crap:.1})\n",
1611        files = s.files_analyzed,
1612        funcs = s.functions_analyzed,
1613        cyc = s.max_cyclomatic_threshold,
1614        cog = s.max_cognitive_threshold,
1615        crap = s.max_crap_threshold,
1616    );
1617}
1618
1619/// Write the heading line for the complexity findings section.
1620fn write_findings_heading(
1621    out: &mut String,
1622    report: &fallow_output::HealthReport,
1623    has_synthetic: bool,
1624) {
1625    let count = report.summary.functions_above_threshold;
1626    let shown = report.findings.len();
1627    let subject = if has_synthetic {
1628        "high complexity finding"
1629    } else {
1630        "high complexity function"
1631    };
1632    if shown < count {
1633        let _ = write!(
1634            out,
1635            "## Fallow: {count} {subject}{} ({shown} shown)\n\n",
1636            plural(count),
1637        );
1638    } else {
1639        let _ = write!(out, "## Fallow: {count} {subject}{}\n\n", plural(count));
1640    }
1641}
1642
1643/// Write the table header row for the complexity findings section.
1644fn write_findings_table_header(out: &mut String, has_synthetic: bool) {
1645    let name_header = if has_synthetic { "Entry" } else { "Function" };
1646    let _ = writeln!(
1647        out,
1648        "| File | {name_header} | Severity | Cyclomatic | Cognitive | CRAP | Lines |"
1649    );
1650    out.push_str("|:-----|:---------|:---------|:-----------|:----------|:-----|:------|\n");
1651}
1652
1653/// Write one complexity finding row, including threshold-breach markers.
1654fn write_findings_row(
1655    out: &mut String,
1656    finding: &fallow_output::HealthFinding,
1657    report: &fallow_output::HealthReport,
1658    root: &Path,
1659) {
1660    let file_str = escape_backticks(&normalize_uri(
1661        &relative_path(&finding.path, root).display().to_string(),
1662    ));
1663    let thresholds =
1664        finding
1665            .effective_thresholds
1666            .unwrap_or(fallow_output::HealthEffectiveThresholds {
1667                max_cyclomatic: report.summary.max_cyclomatic_threshold,
1668                max_cognitive: report.summary.max_cognitive_threshold,
1669                max_crap: report.summary.max_crap_threshold,
1670            });
1671    let cyc_marker = if finding.cyclomatic > thresholds.max_cyclomatic {
1672        " **!**"
1673    } else {
1674        ""
1675    };
1676    let cog_marker = if finding.cognitive > thresholds.max_cognitive {
1677        " **!**"
1678    } else {
1679        ""
1680    };
1681    let severity_label = match finding.severity {
1682        fallow_output::FindingSeverity::Critical => "critical",
1683        fallow_output::FindingSeverity::High => "high",
1684        fallow_output::FindingSeverity::Moderate => "moderate",
1685    };
1686    let crap_cell = match finding.crap {
1687        Some(crap) => {
1688            let marker = if crap >= thresholds.max_crap {
1689                " **!**"
1690            } else {
1691                ""
1692            };
1693            format!("{crap:.1}{marker}")
1694        }
1695        None => "-".to_string(),
1696    };
1697    let _ = writeln!(
1698        out,
1699        "| `{file_str}:{line}` | `{name}` | {severity_label} | {cyc}{cyc_marker} | {cog}{cog_marker} | {crap_cell} | {lines} |",
1700        line = finding.line,
1701        name = escape_backticks(display_complexity_entry_name(&finding.name).as_ref()),
1702        cyc = finding.cyclomatic,
1703        cog = finding.cognitive,
1704        lines = finding.line_count,
1705    );
1706}
1707
1708fn write_threshold_overrides_section(
1709    out: &mut String,
1710    report: &fallow_output::HealthReport,
1711    root: &Path,
1712) {
1713    if report.threshold_overrides.is_empty() {
1714        return;
1715    }
1716    if !out.is_empty() && !out.ends_with("\n\n") {
1717        out.push('\n');
1718    }
1719    out.push_str("## Health Threshold Overrides\n\n");
1720    out.push_str("| Override | Status | Target | Metrics |\n");
1721    out.push_str("|---------:|:-------|:-------|:--------|\n");
1722    for entry in &report.threshold_overrides {
1723        let status = match entry.status {
1724            fallow_output::ThresholdOverrideStatus::Active => "active",
1725            fallow_output::ThresholdOverrideStatus::Stale => "stale",
1726            fallow_output::ThresholdOverrideStatus::NoMatch => "no_match",
1727        };
1728        let target = entry.path.as_ref().map_or_else(
1729            || "<no matching file or function>".to_string(),
1730            |path| {
1731                let display = escape_backticks(&normalize_uri(
1732                    &relative_path(path, root).display().to_string(),
1733                ));
1734                entry.function.as_ref().map_or_else(
1735                    || display.clone(),
1736                    |name| format!("{display}:{}", escape_backticks(name)),
1737                )
1738            },
1739        );
1740        let metrics = entry.metrics.map_or_else(
1741            || "-".to_string(),
1742            |metrics| {
1743                let crap = metrics
1744                    .crap
1745                    .map_or(String::new(), |value| format!(", CRAP {value:.1}"));
1746                format!(
1747                    "cyclomatic {}, cognitive {}{}",
1748                    metrics.cyclomatic, metrics.cognitive, crap
1749                )
1750            },
1751        );
1752        let _ = writeln!(
1753            out,
1754            "| {} | {} | `{}` | {} |",
1755            entry.override_index, status, target, metrics
1756        );
1757    }
1758    out.push('\n');
1759}
1760
1761/// Write the file health scores table to the output.
1762fn write_file_scores_section(out: &mut String, report: &fallow_output::HealthReport, root: &Path) {
1763    if report.file_scores.is_empty() {
1764        return;
1765    }
1766
1767    let rel = |p: &Path| {
1768        escape_backticks(&normalize_uri(
1769            &relative_path(p, root).display().to_string(),
1770        ))
1771    };
1772
1773    out.push('\n');
1774    let _ = writeln!(
1775        out,
1776        "### File Health Scores ({} files)\n",
1777        report.file_scores.len(),
1778    );
1779    out.push_str("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density | Risk |\n");
1780    out.push_str("|:-----|:---------------|:-------|:--------|:----------|:--------|:-----|\n");
1781
1782    for score in &report.file_scores {
1783        let file_str = rel(&score.path);
1784        let _ = writeln!(
1785            out,
1786            "| `{file_str}` | {mi:.1} | {fi} | {fan_out} | {dead:.0}% | {density:.2} | {crap:.1} |",
1787            mi = score.maintainability_index,
1788            fi = score.fan_in,
1789            fan_out = score.fan_out,
1790            dead = score.dead_code_ratio * 100.0,
1791            density = score.complexity_density,
1792            crap = score.crap_max,
1793        );
1794    }
1795
1796    if let Some(avg) = report.summary.average_maintainability {
1797        let _ = write!(out, "\n**Average maintainability index:** {avg:.1}/100\n");
1798    }
1799}
1800
1801fn write_coverage_gaps_section(
1802    out: &mut String,
1803    report: &fallow_output::HealthReport,
1804    root: &Path,
1805) {
1806    let Some(ref gaps) = report.coverage_gaps else {
1807        return;
1808    };
1809
1810    out.push('\n');
1811    let _ = writeln!(out, "### Coverage Gaps\n");
1812    let _ = writeln!(
1813        out,
1814        "*{} untested files · {} untested exports · {:.1}% file coverage*\n",
1815        gaps.summary.untested_files, gaps.summary.untested_exports, gaps.summary.file_coverage_pct,
1816    );
1817
1818    if gaps.files.is_empty() && gaps.exports.is_empty() {
1819        out.push_str("_No coverage gaps found in scope._\n");
1820        return;
1821    }
1822
1823    if !gaps.files.is_empty() {
1824        out.push_str("#### Files\n");
1825        for item in &gaps.files {
1826            let file_str = escape_backticks(&normalize_uri(
1827                &relative_path(&item.file.path, root).display().to_string(),
1828            ));
1829            let _ = writeln!(
1830                out,
1831                "- `{file_str}` ({count} value export{})",
1832                if item.file.value_export_count == 1 {
1833                    ""
1834                } else {
1835                    "s"
1836                },
1837                count = item.file.value_export_count,
1838            );
1839        }
1840        out.push('\n');
1841    }
1842
1843    if !gaps.exports.is_empty() {
1844        out.push_str("#### Exports\n");
1845        for item in &gaps.exports {
1846            let file_str = escape_backticks(&normalize_uri(
1847                &relative_path(&item.export.path, root).display().to_string(),
1848            ));
1849            let _ = writeln!(
1850                out,
1851                "- `{file_str}`:{} `{}`",
1852                item.export.line, item.export.export_name
1853            );
1854        }
1855    }
1856}
1857
1858/// Write the hotspots table to the output.
1859/// Render the four ownership table cells (bus, top contributor, declared
1860/// owner, notes) for the markdown hotspots table. Cells fall back to an
1861/// en-dash (U+2013) when ownership data is missing for an entry.
1862fn ownership_md_cells(
1863    ownership: Option<&fallow_output::OwnershipMetrics>,
1864) -> (String, String, String, String) {
1865    let Some(o) = ownership else {
1866        let dash = "\u{2013}".to_string();
1867        return (dash.clone(), dash.clone(), dash.clone(), dash);
1868    };
1869    let bus = o.bus_factor.to_string();
1870    let top = format!(
1871        "`{}` ({:.0}%)",
1872        o.top_contributor.identifier,
1873        o.top_contributor.share * 100.0,
1874    );
1875    let owner = o
1876        .declared_owner
1877        .as_deref()
1878        .map_or_else(|| "\u{2013}".to_string(), str::to_string);
1879    let mut notes: Vec<&str> = Vec::new();
1880    if o.unowned == Some(true) {
1881        notes.push("**unowned**");
1882    }
1883    if o.ownership_state == fallow_output::OwnershipState::DeclaredInactive {
1884        notes.push("declared owner inactive");
1885    }
1886    if o.drift {
1887        notes.push("drift");
1888    }
1889    let notes_str = if notes.is_empty() {
1890        "\u{2013}".to_string()
1891    } else {
1892        notes.join(", ")
1893    };
1894    (bus, top, owner, notes_str)
1895}
1896
1897fn write_hotspots_section(out: &mut String, report: &fallow_output::HealthReport, root: &Path) {
1898    if report.hotspots.is_empty() {
1899        return;
1900    }
1901
1902    out.push('\n');
1903    let header = report.hotspot_summary.as_ref().map_or_else(
1904        || format!("### Hotspots ({} files)\n", report.hotspots.len()),
1905        |summary| {
1906            format!(
1907                "### Hotspots ({} files, since {})\n",
1908                report.hotspots.len(),
1909                summary.since,
1910            )
1911        },
1912    );
1913    let _ = writeln!(out, "{header}");
1914    let any_ownership = report.hotspots.iter().any(|e| e.ownership.is_some());
1915    write_hotspots_table_header(out, any_ownership);
1916
1917    for entry in &report.hotspots {
1918        write_hotspots_row(out, entry, any_ownership, root);
1919    }
1920
1921    if let Some(ref summary) = report.hotspot_summary
1922        && summary.files_excluded > 0
1923    {
1924        let _ = write!(
1925            out,
1926            "\n*{} file{} excluded (< {} commits)*\n",
1927            summary.files_excluded,
1928            plural(summary.files_excluded),
1929            summary.min_commits,
1930        );
1931    }
1932}
1933
1934/// Write the hotspots table header, widening with ownership columns when present.
1935fn write_hotspots_table_header(out: &mut String, any_ownership: bool) {
1936    if any_ownership {
1937        out.push_str(
1938            "| File | Score | Commits | Churn | Density | Fan-in | Trend | Bus | Top | Owner | Notes |\n"
1939        );
1940        out.push_str(
1941            "|:-----|:------|:--------|:------|:--------|:-------|:------|:----|:----|:------|:------|\n"
1942        );
1943    } else {
1944        out.push_str("| File | Score | Commits | Churn | Density | Fan-in | Trend |\n");
1945        out.push_str("|:-----|:------|:--------|:------|:--------|:-------|:------|\n");
1946    }
1947}
1948
1949/// Write one hotspot row, including ownership cells when the table is widened.
1950fn write_hotspots_row(
1951    out: &mut String,
1952    entry: &fallow_output::HotspotFinding,
1953    any_ownership: bool,
1954    root: &Path,
1955) {
1956    let file_str = escape_backticks(&normalize_uri(
1957        &relative_path(&entry.path, root).display().to_string(),
1958    ));
1959    if any_ownership {
1960        let (bus, top, owner, notes) = ownership_md_cells(entry.ownership.as_ref());
1961        let _ = writeln!(
1962            out,
1963            "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} | {bus} | {top} | {owner} | {notes} |",
1964            score = entry.score,
1965            commits = entry.commits,
1966            churn = entry.lines_added + entry.lines_deleted,
1967            density = entry.complexity_density,
1968            fi = entry.fan_in,
1969            trend = entry.trend,
1970        );
1971    } else {
1972        let _ = writeln!(
1973            out,
1974            "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} |",
1975            score = entry.score,
1976            commits = entry.commits,
1977            churn = entry.lines_added + entry.lines_deleted,
1978            density = entry.complexity_density,
1979            fi = entry.fan_in,
1980            trend = entry.trend,
1981        );
1982    }
1983}
1984
1985/// Write the refactoring targets table to the output.
1986fn write_targets_section(out: &mut String, report: &fallow_output::HealthReport, root: &Path) {
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: &fallow_output::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}