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