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