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
9fn 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
18pub 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 markdown_section(&mut out, &results.unused_files, "Unused files", |file| {
42 vec![format!("- `{}`", rel(&file.path))]
43 });
44
45 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 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 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 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 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 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 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 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 markdown_section(
148 &mut out,
149 &results.unlisted_dependencies,
150 "Unlisted dependencies",
151 |dep| vec![format!("- `{}`", escape_backticks(&dep.package_name))],
152 );
153
154 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 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 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
229fn 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
249fn 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
280pub(super) fn print_duplication_markdown(report: &DuplicationReport, root: &Path) {
283 println!("{}", build_duplication_markdown(report, root));
284}
285
286pub 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 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 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 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 #[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}