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| format_dependency(&dep.package_name, &dep.path, root),
71 );
72
73 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 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 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 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 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 markdown_section(
121 &mut out,
122 &results.unlisted_dependencies,
123 "Unlisted dependencies",
124 |dep| vec![format!("- `{}`", escape_backticks(&dep.package_name))],
125 );
126
127 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 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 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
204fn 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
224fn 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
255pub(super) fn print_duplication_markdown(report: &DuplicationReport, root: &Path) {
258 println!("{}", build_duplication_markdown(report, root));
259}
260
261pub 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 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 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 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 #[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}