Skip to main content

fallow_cli/report/
markdown.rs

1use std::fmt::Write;
2use std::path::Path;
3
4use fallow_core::duplicates::DuplicationReport;
5use fallow_core::results::{AnalysisResults, UnusedExport, UnusedMember};
6
7use super::{normalize_uri, relative_path};
8
9/// Escape backticks in user-controlled strings to prevent breaking markdown code spans.
10fn escape_backticks(s: &str) -> String {
11    s.replace('`', "\\`")
12}
13
14pub(super) fn print_markdown(results: &AnalysisResults, root: &Path) {
15    println!("{}", build_markdown(results, root));
16}
17
18/// Build markdown output for analysis results.
19pub fn build_markdown(results: &AnalysisResults, root: &Path) -> String {
20    let rel = |p: &Path| {
21        escape_backticks(&normalize_uri(
22            &relative_path(p, root).display().to_string(),
23        ))
24    };
25
26    let total = results.total_issues();
27    let mut out = String::new();
28
29    if total == 0 {
30        out.push_str("## Fallow: no issues found\n");
31        return out;
32    }
33
34    let _ = write!(
35        out,
36        "## Fallow: {total} issue{} found\n\n",
37        if total == 1 { "" } else { "s" }
38    );
39
40    // ── Unused files ──
41    markdown_section(&mut out, &results.unused_files, "Unused files", |file| {
42        vec![format!("- `{}`", rel(&file.path))]
43    });
44
45    // ── Unused exports ──
46    markdown_grouped_section(
47        &mut out,
48        &results.unused_exports,
49        "Unused exports",
50        root,
51        |e| e.path.as_path(),
52        format_export,
53    );
54
55    // ── Unused types ──
56    markdown_grouped_section(
57        &mut out,
58        &results.unused_types,
59        "Unused type exports",
60        root,
61        |e| e.path.as_path(),
62        format_export,
63    );
64
65    // ── Unused dependencies ──
66    markdown_section(
67        &mut out,
68        &results.unused_dependencies,
69        "Unused dependencies",
70        |dep| {
71            let name = escape_backticks(&dep.package_name);
72            let pkg_label = relative_path(&dep.path, root).display().to_string();
73            if pkg_label == "package.json" {
74                vec![format!("- `{name}`")]
75            } else {
76                let label = escape_backticks(&pkg_label);
77                vec![format!("- `{name}` ({label})")]
78            }
79        },
80    );
81
82    // ── Unused devDependencies ──
83    markdown_section(
84        &mut out,
85        &results.unused_dev_dependencies,
86        "Unused devDependencies",
87        |dep| {
88            let name = escape_backticks(&dep.package_name);
89            let pkg_label = relative_path(&dep.path, root).display().to_string();
90            if pkg_label == "package.json" {
91                vec![format!("- `{name}`")]
92            } else {
93                let label = escape_backticks(&pkg_label);
94                vec![format!("- `{name}` ({label})")]
95            }
96        },
97    );
98
99    // ── Unused optionalDependencies ──
100    markdown_section(
101        &mut out,
102        &results.unused_optional_dependencies,
103        "Unused optionalDependencies",
104        |dep| {
105            let name = escape_backticks(&dep.package_name);
106            let pkg_label = relative_path(&dep.path, root).display().to_string();
107            if pkg_label == "package.json" {
108                vec![format!("- `{name}`")]
109            } else {
110                let label = escape_backticks(&pkg_label);
111                vec![format!("- `{name}` ({label})")]
112            }
113        },
114    );
115
116    // ── Unused enum members ──
117    markdown_grouped_section(
118        &mut out,
119        &results.unused_enum_members,
120        "Unused enum members",
121        root,
122        |m| m.path.as_path(),
123        format_member,
124    );
125
126    // ── Unused class members ──
127    markdown_grouped_section(
128        &mut out,
129        &results.unused_class_members,
130        "Unused class members",
131        root,
132        |m| m.path.as_path(),
133        format_member,
134    );
135
136    // ── Unresolved imports ──
137    markdown_grouped_section(
138        &mut out,
139        &results.unresolved_imports,
140        "Unresolved imports",
141        root,
142        |i| i.path.as_path(),
143        |i| format!(":{} `{}`", i.line, escape_backticks(&i.specifier)),
144    );
145
146    // ── Unlisted dependencies ──
147    markdown_section(
148        &mut out,
149        &results.unlisted_dependencies,
150        "Unlisted dependencies",
151        |dep| vec![format!("- `{}`", escape_backticks(&dep.package_name))],
152    );
153
154    // ── Duplicate exports ──
155    markdown_section(
156        &mut out,
157        &results.duplicate_exports,
158        "Duplicate exports",
159        |dup| {
160            let locations: Vec<String> = dup
161                .locations
162                .iter()
163                .map(|p| format!("`{}`", rel(p)))
164                .collect();
165            vec![format!(
166                "- `{}` in {}",
167                escape_backticks(&dup.export_name),
168                locations.join(", ")
169            )]
170        },
171    );
172
173    // ── Type-only dependencies ──
174    markdown_section(
175        &mut out,
176        &results.type_only_dependencies,
177        "Type-only dependencies (consider moving to devDependencies)",
178        |dep| {
179            let name = escape_backticks(&dep.package_name);
180            let pkg_label = relative_path(&dep.path, root).display().to_string();
181            if pkg_label == "package.json" {
182                vec![format!("- `{name}`")]
183            } else {
184                let label = escape_backticks(&pkg_label);
185                vec![format!("- `{name}` ({label})")]
186            }
187        },
188    );
189
190    // ── Circular dependencies ──
191    markdown_section(
192        &mut out,
193        &results.circular_dependencies,
194        "Circular dependencies",
195        |cycle| {
196            let chain: Vec<String> = cycle.files.iter().map(|p| rel(p)).collect();
197            let mut display_chain = chain.clone();
198            if let Some(first) = chain.first() {
199                display_chain.push(first.clone());
200            }
201            vec![format!(
202                "- {}",
203                display_chain
204                    .iter()
205                    .map(|s| format!("`{s}`"))
206                    .collect::<Vec<_>>()
207                    .join(" \u{2192} ")
208            )]
209        },
210    );
211
212    out
213}
214
215fn format_export(e: &UnusedExport) -> String {
216    let re = if e.is_re_export { " (re-export)" } else { "" };
217    format!(":{} `{}`{re}", e.line, escape_backticks(&e.export_name))
218}
219
220fn format_member(m: &UnusedMember) -> String {
221    format!(
222        ":{} `{}.{}`",
223        m.line,
224        escape_backticks(&m.parent_name),
225        escape_backticks(&m.member_name)
226    )
227}
228
229/// Emit a markdown section with a header and per-item lines. Skipped if empty.
230fn markdown_section<T>(
231    out: &mut String,
232    items: &[T],
233    title: &str,
234    format_lines: impl Fn(&T) -> Vec<String>,
235) {
236    if items.is_empty() {
237        return;
238    }
239    let _ = write!(out, "### {title} ({})\n\n", items.len());
240    for item in items {
241        for line in format_lines(item) {
242            out.push_str(&line);
243            out.push('\n');
244        }
245    }
246    out.push('\n');
247}
248
249/// Emit a markdown section whose items are grouped by file path.
250fn markdown_grouped_section<'a, T>(
251    out: &mut String,
252    items: &'a [T],
253    title: &str,
254    root: &Path,
255    get_path: impl Fn(&'a T) -> &'a Path,
256    format_detail: impl Fn(&T) -> String,
257) {
258    if items.is_empty() {
259        return;
260    }
261    let _ = write!(out, "### {title} ({})\n\n", items.len());
262
263    let mut indices: Vec<usize> = (0..items.len()).collect();
264    indices.sort_by(|&a, &b| get_path(&items[a]).cmp(get_path(&items[b])));
265
266    let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
267    let mut last_file = String::new();
268    for &i in &indices {
269        let item = &items[i];
270        let file_str = rel(get_path(item));
271        if file_str != last_file {
272            let _ = writeln!(out, "- `{file_str}`");
273            last_file = file_str;
274        }
275        let _ = writeln!(out, "  - {}", format_detail(item));
276    }
277    out.push('\n');
278}
279
280// ── Duplication markdown output ──────────────────────────────────
281
282pub(super) fn print_duplication_markdown(report: &DuplicationReport, root: &Path) {
283    println!("{}", build_duplication_markdown(report, root));
284}
285
286/// Build markdown output for duplication results.
287pub fn build_duplication_markdown(report: &DuplicationReport, root: &Path) -> String {
288    let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
289
290    let mut out = String::new();
291
292    if report.clone_groups.is_empty() {
293        out.push_str("## Fallow: no code duplication found\n");
294        return out;
295    }
296
297    let stats = &report.stats;
298    let _ = write!(
299        out,
300        "## Fallow: {} clone group{} found ({:.1}% duplication)\n\n",
301        stats.clone_groups,
302        if stats.clone_groups == 1 { "" } else { "s" },
303        stats.duplication_percentage,
304    );
305
306    out.push_str("### Duplicates\n\n");
307    for (i, group) in report.clone_groups.iter().enumerate() {
308        let instance_count = group.instances.len();
309        let _ = write!(
310            out,
311            "**Clone group {}** ({} lines, {instance_count} instance{})\n\n",
312            i + 1,
313            group.line_count,
314            if instance_count == 1 { "" } else { "s" }
315        );
316        for instance in &group.instances {
317            let relative = rel(&instance.file);
318            let _ = writeln!(
319                out,
320                "- `{relative}:{}-{}`",
321                instance.start_line, instance.end_line
322            );
323        }
324        out.push('\n');
325    }
326
327    // Clone families
328    if !report.clone_families.is_empty() {
329        out.push_str("### Clone Families\n\n");
330        for (i, family) in report.clone_families.iter().enumerate() {
331            let file_names: Vec<_> = family.files.iter().map(|f| rel(f)).collect();
332            let _ = write!(
333                out,
334                "**Family {}** ({} group{}, {} lines across {})\n\n",
335                i + 1,
336                family.groups.len(),
337                if family.groups.len() == 1 { "" } else { "s" },
338                family.total_duplicated_lines,
339                file_names
340                    .iter()
341                    .map(|s| format!("`{s}`"))
342                    .collect::<Vec<_>>()
343                    .join(", "),
344            );
345            for suggestion in &family.suggestions {
346                let savings = if suggestion.estimated_savings > 0 {
347                    format!(" (~{} lines saved)", suggestion.estimated_savings)
348                } else {
349                    String::new()
350                };
351                let _ = writeln!(out, "- {}{savings}", suggestion.description);
352            }
353            out.push('\n');
354        }
355    }
356
357    // Summary line
358    let _ = writeln!(
359        out,
360        "**Summary:** {} duplicated lines ({:.1}%) across {} file{}",
361        stats.duplicated_lines,
362        stats.duplication_percentage,
363        stats.files_with_clones,
364        if stats.files_with_clones == 1 {
365            ""
366        } else {
367            "s"
368        },
369    );
370
371    out
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use fallow_core::duplicates::{
378        CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
379        RefactoringKind, RefactoringSuggestion,
380    };
381    use fallow_core::extract::MemberKind;
382    use fallow_core::results::*;
383    use std::path::PathBuf;
384
385    /// Helper: build an `AnalysisResults` populated with one issue of every type.
386    fn sample_results(root: &Path) -> AnalysisResults {
387        let mut r = AnalysisResults::default();
388
389        r.unused_files.push(UnusedFile {
390            path: root.join("src/dead.ts"),
391        });
392        r.unused_exports.push(UnusedExport {
393            path: root.join("src/utils.ts"),
394            export_name: "helperFn".to_string(),
395            is_type_only: false,
396            line: 10,
397            col: 4,
398            span_start: 120,
399            is_re_export: false,
400        });
401        r.unused_types.push(UnusedExport {
402            path: root.join("src/types.ts"),
403            export_name: "OldType".to_string(),
404            is_type_only: true,
405            line: 5,
406            col: 0,
407            span_start: 60,
408            is_re_export: false,
409        });
410        r.unused_dependencies.push(UnusedDependency {
411            package_name: "lodash".to_string(),
412            location: DependencyLocation::Dependencies,
413            path: root.join("package.json"),
414        });
415        r.unused_dev_dependencies.push(UnusedDependency {
416            package_name: "jest".to_string(),
417            location: DependencyLocation::DevDependencies,
418            path: root.join("package.json"),
419        });
420        r.unused_enum_members.push(UnusedMember {
421            path: root.join("src/enums.ts"),
422            parent_name: "Status".to_string(),
423            member_name: "Deprecated".to_string(),
424            kind: MemberKind::EnumMember,
425            line: 8,
426            col: 2,
427        });
428        r.unused_class_members.push(UnusedMember {
429            path: root.join("src/service.ts"),
430            parent_name: "UserService".to_string(),
431            member_name: "legacyMethod".to_string(),
432            kind: MemberKind::ClassMethod,
433            line: 42,
434            col: 4,
435        });
436        r.unresolved_imports.push(UnresolvedImport {
437            path: root.join("src/app.ts"),
438            specifier: "./missing-module".to_string(),
439            line: 3,
440            col: 0,
441        });
442        r.unlisted_dependencies.push(UnlistedDependency {
443            package_name: "chalk".to_string(),
444            imported_from: vec![root.join("src/cli.ts")],
445        });
446        r.duplicate_exports.push(DuplicateExport {
447            export_name: "Config".to_string(),
448            locations: vec![root.join("src/config.ts"), root.join("src/types.ts")],
449        });
450        r.type_only_dependencies.push(TypeOnlyDependency {
451            package_name: "zod".to_string(),
452            path: root.join("package.json"),
453        });
454        r.circular_dependencies.push(CircularDependency {
455            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
456            length: 2,
457        });
458
459        r
460    }
461
462    #[test]
463    fn markdown_empty_results_no_issues() {
464        let root = PathBuf::from("/project");
465        let results = AnalysisResults::default();
466        let md = build_markdown(&results, &root);
467        assert_eq!(md, "## Fallow: no issues found\n");
468    }
469
470    #[test]
471    fn markdown_contains_header_with_count() {
472        let root = PathBuf::from("/project");
473        let results = sample_results(&root);
474        let md = build_markdown(&results, &root);
475        assert!(md.starts_with(&format!(
476            "## Fallow: {} issues found\n",
477            results.total_issues()
478        )));
479    }
480
481    #[test]
482    fn markdown_contains_all_sections() {
483        let root = PathBuf::from("/project");
484        let results = sample_results(&root);
485        let md = build_markdown(&results, &root);
486
487        assert!(md.contains("### Unused files (1)"));
488        assert!(md.contains("### Unused exports (1)"));
489        assert!(md.contains("### Unused type exports (1)"));
490        assert!(md.contains("### Unused dependencies (1)"));
491        assert!(md.contains("### Unused devDependencies (1)"));
492        assert!(md.contains("### Unused enum members (1)"));
493        assert!(md.contains("### Unused class members (1)"));
494        assert!(md.contains("### Unresolved imports (1)"));
495        assert!(md.contains("### Unlisted dependencies (1)"));
496        assert!(md.contains("### Duplicate exports (1)"));
497        assert!(md.contains("### Type-only dependencies"));
498        assert!(md.contains("### Circular dependencies (1)"));
499    }
500
501    #[test]
502    fn markdown_unused_file_format() {
503        let root = PathBuf::from("/project");
504        let mut results = AnalysisResults::default();
505        results.unused_files.push(UnusedFile {
506            path: root.join("src/dead.ts"),
507        });
508        let md = build_markdown(&results, &root);
509        assert!(md.contains("- `src/dead.ts`"));
510    }
511
512    #[test]
513    fn markdown_unused_export_grouped_by_file() {
514        let root = PathBuf::from("/project");
515        let mut results = AnalysisResults::default();
516        results.unused_exports.push(UnusedExport {
517            path: root.join("src/utils.ts"),
518            export_name: "helperFn".to_string(),
519            is_type_only: false,
520            line: 10,
521            col: 4,
522            span_start: 120,
523            is_re_export: false,
524        });
525        let md = build_markdown(&results, &root);
526        assert!(md.contains("- `src/utils.ts`"));
527        assert!(md.contains(":10 `helperFn`"));
528    }
529
530    #[test]
531    fn markdown_re_export_tagged() {
532        let root = PathBuf::from("/project");
533        let mut results = AnalysisResults::default();
534        results.unused_exports.push(UnusedExport {
535            path: root.join("src/index.ts"),
536            export_name: "reExported".to_string(),
537            is_type_only: false,
538            line: 1,
539            col: 0,
540            span_start: 0,
541            is_re_export: true,
542        });
543        let md = build_markdown(&results, &root);
544        assert!(md.contains("(re-export)"));
545    }
546
547    #[test]
548    fn markdown_unused_dep_format() {
549        let root = PathBuf::from("/project");
550        let mut results = AnalysisResults::default();
551        results.unused_dependencies.push(UnusedDependency {
552            package_name: "lodash".to_string(),
553            location: DependencyLocation::Dependencies,
554            path: root.join("package.json"),
555        });
556        let md = build_markdown(&results, &root);
557        assert!(md.contains("- `lodash`"));
558    }
559
560    #[test]
561    fn markdown_circular_dep_format() {
562        let root = PathBuf::from("/project");
563        let mut results = AnalysisResults::default();
564        results.circular_dependencies.push(CircularDependency {
565            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
566            length: 2,
567        });
568        let md = build_markdown(&results, &root);
569        assert!(md.contains("`src/a.ts`"));
570        assert!(md.contains("`src/b.ts`"));
571        assert!(md.contains("\u{2192}"));
572    }
573
574    #[test]
575    fn markdown_strips_root_prefix() {
576        let root = PathBuf::from("/project");
577        let mut results = AnalysisResults::default();
578        results.unused_files.push(UnusedFile {
579            path: PathBuf::from("/project/src/deep/nested/file.ts"),
580        });
581        let md = build_markdown(&results, &root);
582        assert!(md.contains("`src/deep/nested/file.ts`"));
583        assert!(!md.contains("/project/"));
584    }
585
586    #[test]
587    fn markdown_single_issue_no_plural() {
588        let root = PathBuf::from("/project");
589        let mut results = AnalysisResults::default();
590        results.unused_files.push(UnusedFile {
591            path: root.join("src/dead.ts"),
592        });
593        let md = build_markdown(&results, &root);
594        assert!(md.starts_with("## Fallow: 1 issue found\n"));
595    }
596
597    #[test]
598    fn markdown_type_only_dep_format() {
599        let root = PathBuf::from("/project");
600        let mut results = AnalysisResults::default();
601        results.type_only_dependencies.push(TypeOnlyDependency {
602            package_name: "zod".to_string(),
603            path: root.join("package.json"),
604        });
605        let md = build_markdown(&results, &root);
606        assert!(md.contains("### Type-only dependencies"));
607        assert!(md.contains("- `zod`"));
608    }
609
610    #[test]
611    fn markdown_escapes_backticks_in_export_names() {
612        let root = PathBuf::from("/project");
613        let mut results = AnalysisResults::default();
614        results.unused_exports.push(UnusedExport {
615            path: root.join("src/utils.ts"),
616            export_name: "foo`bar".to_string(),
617            is_type_only: false,
618            line: 1,
619            col: 0,
620            span_start: 0,
621            is_re_export: false,
622        });
623        let md = build_markdown(&results, &root);
624        assert!(md.contains("foo\\`bar"));
625        assert!(!md.contains("foo`bar`"));
626    }
627
628    #[test]
629    fn markdown_escapes_backticks_in_package_names() {
630        let root = PathBuf::from("/project");
631        let mut results = AnalysisResults::default();
632        results.unused_dependencies.push(UnusedDependency {
633            package_name: "pkg`name".to_string(),
634            location: DependencyLocation::Dependencies,
635            path: root.join("package.json"),
636        });
637        let md = build_markdown(&results, &root);
638        assert!(md.contains("pkg\\`name"));
639    }
640
641    // ── Duplication markdown ──
642
643    #[test]
644    fn duplication_markdown_empty() {
645        let report = DuplicationReport::default();
646        let root = PathBuf::from("/project");
647        let md = build_duplication_markdown(&report, &root);
648        assert_eq!(md, "## Fallow: no code duplication found\n");
649    }
650
651    #[test]
652    fn duplication_markdown_contains_groups() {
653        let root = PathBuf::from("/project");
654        let report = DuplicationReport {
655            clone_groups: vec![CloneGroup {
656                instances: vec![
657                    CloneInstance {
658                        file: root.join("src/a.ts"),
659                        start_line: 1,
660                        end_line: 10,
661                        start_col: 0,
662                        end_col: 0,
663                        fragment: String::new(),
664                    },
665                    CloneInstance {
666                        file: root.join("src/b.ts"),
667                        start_line: 5,
668                        end_line: 14,
669                        start_col: 0,
670                        end_col: 0,
671                        fragment: String::new(),
672                    },
673                ],
674                token_count: 50,
675                line_count: 10,
676            }],
677            clone_families: vec![],
678            stats: DuplicationStats {
679                total_files: 10,
680                files_with_clones: 2,
681                total_lines: 500,
682                duplicated_lines: 20,
683                total_tokens: 2500,
684                duplicated_tokens: 100,
685                clone_groups: 1,
686                clone_instances: 2,
687                duplication_percentage: 4.0,
688            },
689        };
690        let md = build_duplication_markdown(&report, &root);
691        assert!(md.contains("**Clone group 1**"));
692        assert!(md.contains("`src/a.ts:1-10`"));
693        assert!(md.contains("`src/b.ts:5-14`"));
694        assert!(md.contains("4.0% duplication"));
695    }
696
697    #[test]
698    fn duplication_markdown_contains_families() {
699        let root = PathBuf::from("/project");
700        let report = DuplicationReport {
701            clone_groups: vec![CloneGroup {
702                instances: vec![CloneInstance {
703                    file: root.join("src/a.ts"),
704                    start_line: 1,
705                    end_line: 5,
706                    start_col: 0,
707                    end_col: 0,
708                    fragment: String::new(),
709                }],
710                token_count: 30,
711                line_count: 5,
712            }],
713            clone_families: vec![CloneFamily {
714                files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
715                groups: vec![],
716                total_duplicated_lines: 20,
717                total_duplicated_tokens: 100,
718                suggestions: vec![RefactoringSuggestion {
719                    kind: RefactoringKind::ExtractFunction,
720                    description: "Extract shared utility function".to_string(),
721                    estimated_savings: 15,
722                }],
723            }],
724            stats: DuplicationStats {
725                clone_groups: 1,
726                clone_instances: 1,
727                duplication_percentage: 2.0,
728                ..Default::default()
729            },
730        };
731        let md = build_duplication_markdown(&report, &root);
732        assert!(md.contains("### Clone Families"));
733        assert!(md.contains("**Family 1**"));
734        assert!(md.contains("Extract shared utility function"));
735        assert!(md.contains("~15 lines saved"));
736    }
737}