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
349pub(super) fn print_health_markdown(report: &crate::health_types::HealthReport, root: &Path) {
352 println!("{}", build_health_markdown(report, root));
353}
354
355pub fn build_health_markdown(report: &crate::health_types::HealthReport, root: &Path) -> String {
357 let rel = |p: &Path| {
358 escape_backticks(&normalize_uri(
359 &relative_path(p, root).display().to_string(),
360 ))
361 };
362
363 let mut out = String::new();
364
365 if report.findings.is_empty() && report.file_scores.is_empty() && report.hotspots.is_empty() {
366 let _ = write!(
367 out,
368 "## Fallow: no functions exceed complexity thresholds\n\n\
369 **{}** functions analyzed (max cyclomatic: {}, max cognitive: {})\n",
370 report.summary.functions_analyzed,
371 report.summary.max_cyclomatic_threshold,
372 report.summary.max_cognitive_threshold,
373 );
374 return out;
375 }
376
377 if !report.findings.is_empty() {
378 let count = report.summary.functions_above_threshold;
379 let shown = report.findings.len();
380 if shown < count {
381 let _ = write!(
382 out,
383 "## Fallow: {count} high complexity function{} ({shown} shown)\n\n",
384 if count == 1 { "" } else { "s" },
385 );
386 } else {
387 let _ = write!(
388 out,
389 "## Fallow: {count} high complexity function{}\n\n",
390 if count == 1 { "" } else { "s" },
391 );
392 }
393
394 out.push_str("| File | Function | Cyclomatic | Cognitive | Lines |\n");
395 out.push_str("|:-----|:---------|:-----------|:----------|:------|\n");
396
397 for finding in &report.findings {
398 let file_str = rel(&finding.path);
399 let cyc_marker = if finding.cyclomatic > report.summary.max_cyclomatic_threshold {
400 " **!**"
401 } else {
402 ""
403 };
404 let cog_marker = if finding.cognitive > report.summary.max_cognitive_threshold {
405 " **!**"
406 } else {
407 ""
408 };
409 let _ = writeln!(
410 out,
411 "| `{file_str}:{line}` | `{name}` | {cyc}{cyc_marker} | {cog}{cog_marker} | {lines} |",
412 line = finding.line,
413 name = escape_backticks(&finding.name),
414 cyc = finding.cyclomatic,
415 cog = finding.cognitive,
416 lines = finding.line_count,
417 );
418 }
419
420 let s = &report.summary;
421 let _ = write!(
422 out,
423 "\n**{files}** files, **{funcs}** functions analyzed \
424 (thresholds: cyclomatic > {cyc}, cognitive > {cog})\n",
425 files = s.files_analyzed,
426 funcs = s.functions_analyzed,
427 cyc = s.max_cyclomatic_threshold,
428 cog = s.max_cognitive_threshold,
429 );
430 }
431
432 if !report.file_scores.is_empty() {
434 out.push('\n');
435 let _ = writeln!(
436 out,
437 "### File Health Scores ({} files)\n",
438 report.file_scores.len(),
439 );
440 out.push_str("| File | MI | Fan-in | Fan-out | Dead Code | Density |\n");
441 out.push_str("|:-----|:---|:-------|:--------|:----------|:--------|\n");
442
443 for score in &report.file_scores {
444 let file_str = rel(&score.path);
445 let _ = writeln!(
446 out,
447 "| `{file_str}` | {mi:.1} | {fi} | {fan_out} | {dead:.0}% | {density:.2} |",
448 mi = score.maintainability_index,
449 fi = score.fan_in,
450 fan_out = score.fan_out,
451 dead = score.dead_code_ratio * 100.0,
452 density = score.complexity_density,
453 );
454 }
455
456 if let Some(avg) = report.summary.average_maintainability {
457 let _ = write!(out, "\n**Average maintainability index:** {avg:.1}/100\n");
458 }
459 }
460
461 if !report.hotspots.is_empty() {
463 out.push('\n');
464 let header = if let Some(ref summary) = report.hotspot_summary {
465 format!(
466 "### Hotspots ({} files, since {})\n",
467 report.hotspots.len(),
468 summary.since,
469 )
470 } else {
471 format!("### Hotspots ({} files)\n", report.hotspots.len())
472 };
473 let _ = writeln!(out, "{header}");
474 out.push_str("| File | Score | Commits | Churn | Density | Fan-in | Trend |\n");
475 out.push_str("|:-----|:------|:--------|:------|:--------|:-------|:------|\n");
476
477 for entry in &report.hotspots {
478 let file_str = rel(&entry.path);
479 let _ = writeln!(
480 out,
481 "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} |",
482 score = entry.score,
483 commits = entry.commits,
484 churn = entry.lines_added + entry.lines_deleted,
485 density = entry.complexity_density,
486 fi = entry.fan_in,
487 trend = entry.trend,
488 );
489 }
490
491 if let Some(ref summary) = report.hotspot_summary
492 && summary.files_excluded > 0
493 {
494 let _ = write!(
495 out,
496 "\n*{} file{} excluded (< {} commits)*\n",
497 summary.files_excluded,
498 if summary.files_excluded == 1 { "" } else { "s" },
499 summary.min_commits,
500 );
501 }
502 }
503
504 out
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use fallow_core::duplicates::{
511 CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
512 RefactoringKind, RefactoringSuggestion,
513 };
514 use fallow_core::extract::MemberKind;
515 use fallow_core::results::*;
516 use std::path::PathBuf;
517
518 fn sample_results(root: &Path) -> AnalysisResults {
520 let mut r = AnalysisResults::default();
521
522 r.unused_files.push(UnusedFile {
523 path: root.join("src/dead.ts"),
524 });
525 r.unused_exports.push(UnusedExport {
526 path: root.join("src/utils.ts"),
527 export_name: "helperFn".to_string(),
528 is_type_only: false,
529 line: 10,
530 col: 4,
531 span_start: 120,
532 is_re_export: false,
533 });
534 r.unused_types.push(UnusedExport {
535 path: root.join("src/types.ts"),
536 export_name: "OldType".to_string(),
537 is_type_only: true,
538 line: 5,
539 col: 0,
540 span_start: 60,
541 is_re_export: false,
542 });
543 r.unused_dependencies.push(UnusedDependency {
544 package_name: "lodash".to_string(),
545 location: DependencyLocation::Dependencies,
546 path: root.join("package.json"),
547 line: 5,
548 });
549 r.unused_dev_dependencies.push(UnusedDependency {
550 package_name: "jest".to_string(),
551 location: DependencyLocation::DevDependencies,
552 path: root.join("package.json"),
553 line: 5,
554 });
555 r.unused_enum_members.push(UnusedMember {
556 path: root.join("src/enums.ts"),
557 parent_name: "Status".to_string(),
558 member_name: "Deprecated".to_string(),
559 kind: MemberKind::EnumMember,
560 line: 8,
561 col: 2,
562 });
563 r.unused_class_members.push(UnusedMember {
564 path: root.join("src/service.ts"),
565 parent_name: "UserService".to_string(),
566 member_name: "legacyMethod".to_string(),
567 kind: MemberKind::ClassMethod,
568 line: 42,
569 col: 4,
570 });
571 r.unresolved_imports.push(UnresolvedImport {
572 path: root.join("src/app.ts"),
573 specifier: "./missing-module".to_string(),
574 line: 3,
575 col: 0,
576 });
577 r.unlisted_dependencies.push(UnlistedDependency {
578 package_name: "chalk".to_string(),
579 imported_from: vec![ImportSite {
580 path: root.join("src/cli.ts"),
581 line: 2,
582 col: 0,
583 }],
584 });
585 r.duplicate_exports.push(DuplicateExport {
586 export_name: "Config".to_string(),
587 locations: vec![
588 DuplicateLocation {
589 path: root.join("src/config.ts"),
590 line: 15,
591 col: 0,
592 },
593 DuplicateLocation {
594 path: root.join("src/types.ts"),
595 line: 30,
596 col: 0,
597 },
598 ],
599 });
600 r.type_only_dependencies.push(TypeOnlyDependency {
601 package_name: "zod".to_string(),
602 path: root.join("package.json"),
603 line: 8,
604 });
605 r.circular_dependencies.push(CircularDependency {
606 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
607 length: 2,
608 line: 3,
609 col: 0,
610 });
611
612 r
613 }
614
615 #[test]
616 fn markdown_empty_results_no_issues() {
617 let root = PathBuf::from("/project");
618 let results = AnalysisResults::default();
619 let md = build_markdown(&results, &root);
620 assert_eq!(md, "## Fallow: no issues found\n");
621 }
622
623 #[test]
624 fn markdown_contains_header_with_count() {
625 let root = PathBuf::from("/project");
626 let results = sample_results(&root);
627 let md = build_markdown(&results, &root);
628 assert!(md.starts_with(&format!(
629 "## Fallow: {} issues found\n",
630 results.total_issues()
631 )));
632 }
633
634 #[test]
635 fn markdown_contains_all_sections() {
636 let root = PathBuf::from("/project");
637 let results = sample_results(&root);
638 let md = build_markdown(&results, &root);
639
640 assert!(md.contains("### Unused files (1)"));
641 assert!(md.contains("### Unused exports (1)"));
642 assert!(md.contains("### Unused type exports (1)"));
643 assert!(md.contains("### Unused dependencies (1)"));
644 assert!(md.contains("### Unused devDependencies (1)"));
645 assert!(md.contains("### Unused enum members (1)"));
646 assert!(md.contains("### Unused class members (1)"));
647 assert!(md.contains("### Unresolved imports (1)"));
648 assert!(md.contains("### Unlisted dependencies (1)"));
649 assert!(md.contains("### Duplicate exports (1)"));
650 assert!(md.contains("### Type-only dependencies"));
651 assert!(md.contains("### Circular dependencies (1)"));
652 }
653
654 #[test]
655 fn markdown_unused_file_format() {
656 let root = PathBuf::from("/project");
657 let mut results = AnalysisResults::default();
658 results.unused_files.push(UnusedFile {
659 path: root.join("src/dead.ts"),
660 });
661 let md = build_markdown(&results, &root);
662 assert!(md.contains("- `src/dead.ts`"));
663 }
664
665 #[test]
666 fn markdown_unused_export_grouped_by_file() {
667 let root = PathBuf::from("/project");
668 let mut results = AnalysisResults::default();
669 results.unused_exports.push(UnusedExport {
670 path: root.join("src/utils.ts"),
671 export_name: "helperFn".to_string(),
672 is_type_only: false,
673 line: 10,
674 col: 4,
675 span_start: 120,
676 is_re_export: false,
677 });
678 let md = build_markdown(&results, &root);
679 assert!(md.contains("- `src/utils.ts`"));
680 assert!(md.contains(":10 `helperFn`"));
681 }
682
683 #[test]
684 fn markdown_re_export_tagged() {
685 let root = PathBuf::from("/project");
686 let mut results = AnalysisResults::default();
687 results.unused_exports.push(UnusedExport {
688 path: root.join("src/index.ts"),
689 export_name: "reExported".to_string(),
690 is_type_only: false,
691 line: 1,
692 col: 0,
693 span_start: 0,
694 is_re_export: true,
695 });
696 let md = build_markdown(&results, &root);
697 assert!(md.contains("(re-export)"));
698 }
699
700 #[test]
701 fn markdown_unused_dep_format() {
702 let root = PathBuf::from("/project");
703 let mut results = AnalysisResults::default();
704 results.unused_dependencies.push(UnusedDependency {
705 package_name: "lodash".to_string(),
706 location: DependencyLocation::Dependencies,
707 path: root.join("package.json"),
708 line: 5,
709 });
710 let md = build_markdown(&results, &root);
711 assert!(md.contains("- `lodash`"));
712 }
713
714 #[test]
715 fn markdown_circular_dep_format() {
716 let root = PathBuf::from("/project");
717 let mut results = AnalysisResults::default();
718 results.circular_dependencies.push(CircularDependency {
719 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
720 length: 2,
721 line: 3,
722 col: 0,
723 });
724 let md = build_markdown(&results, &root);
725 assert!(md.contains("`src/a.ts`"));
726 assert!(md.contains("`src/b.ts`"));
727 assert!(md.contains("\u{2192}"));
728 }
729
730 #[test]
731 fn markdown_strips_root_prefix() {
732 let root = PathBuf::from("/project");
733 let mut results = AnalysisResults::default();
734 results.unused_files.push(UnusedFile {
735 path: PathBuf::from("/project/src/deep/nested/file.ts"),
736 });
737 let md = build_markdown(&results, &root);
738 assert!(md.contains("`src/deep/nested/file.ts`"));
739 assert!(!md.contains("/project/"));
740 }
741
742 #[test]
743 fn markdown_single_issue_no_plural() {
744 let root = PathBuf::from("/project");
745 let mut results = AnalysisResults::default();
746 results.unused_files.push(UnusedFile {
747 path: root.join("src/dead.ts"),
748 });
749 let md = build_markdown(&results, &root);
750 assert!(md.starts_with("## Fallow: 1 issue found\n"));
751 }
752
753 #[test]
754 fn markdown_type_only_dep_format() {
755 let root = PathBuf::from("/project");
756 let mut results = AnalysisResults::default();
757 results.type_only_dependencies.push(TypeOnlyDependency {
758 package_name: "zod".to_string(),
759 path: root.join("package.json"),
760 line: 8,
761 });
762 let md = build_markdown(&results, &root);
763 assert!(md.contains("### Type-only dependencies"));
764 assert!(md.contains("- `zod`"));
765 }
766
767 #[test]
768 fn markdown_escapes_backticks_in_export_names() {
769 let root = PathBuf::from("/project");
770 let mut results = AnalysisResults::default();
771 results.unused_exports.push(UnusedExport {
772 path: root.join("src/utils.ts"),
773 export_name: "foo`bar".to_string(),
774 is_type_only: false,
775 line: 1,
776 col: 0,
777 span_start: 0,
778 is_re_export: false,
779 });
780 let md = build_markdown(&results, &root);
781 assert!(md.contains("foo\\`bar"));
782 assert!(!md.contains("foo`bar`"));
783 }
784
785 #[test]
786 fn markdown_escapes_backticks_in_package_names() {
787 let root = PathBuf::from("/project");
788 let mut results = AnalysisResults::default();
789 results.unused_dependencies.push(UnusedDependency {
790 package_name: "pkg`name".to_string(),
791 location: DependencyLocation::Dependencies,
792 path: root.join("package.json"),
793 line: 5,
794 });
795 let md = build_markdown(&results, &root);
796 assert!(md.contains("pkg\\`name"));
797 }
798
799 #[test]
802 fn duplication_markdown_empty() {
803 let report = DuplicationReport::default();
804 let root = PathBuf::from("/project");
805 let md = build_duplication_markdown(&report, &root);
806 assert_eq!(md, "## Fallow: no code duplication found\n");
807 }
808
809 #[test]
810 fn duplication_markdown_contains_groups() {
811 let root = PathBuf::from("/project");
812 let report = DuplicationReport {
813 clone_groups: vec![CloneGroup {
814 instances: vec![
815 CloneInstance {
816 file: root.join("src/a.ts"),
817 start_line: 1,
818 end_line: 10,
819 start_col: 0,
820 end_col: 0,
821 fragment: String::new(),
822 },
823 CloneInstance {
824 file: root.join("src/b.ts"),
825 start_line: 5,
826 end_line: 14,
827 start_col: 0,
828 end_col: 0,
829 fragment: String::new(),
830 },
831 ],
832 token_count: 50,
833 line_count: 10,
834 }],
835 clone_families: vec![],
836 stats: DuplicationStats {
837 total_files: 10,
838 files_with_clones: 2,
839 total_lines: 500,
840 duplicated_lines: 20,
841 total_tokens: 2500,
842 duplicated_tokens: 100,
843 clone_groups: 1,
844 clone_instances: 2,
845 duplication_percentage: 4.0,
846 },
847 };
848 let md = build_duplication_markdown(&report, &root);
849 assert!(md.contains("**Clone group 1**"));
850 assert!(md.contains("`src/a.ts:1-10`"));
851 assert!(md.contains("`src/b.ts:5-14`"));
852 assert!(md.contains("4.0% duplication"));
853 }
854
855 #[test]
856 fn duplication_markdown_contains_families() {
857 let root = PathBuf::from("/project");
858 let report = DuplicationReport {
859 clone_groups: vec![CloneGroup {
860 instances: vec![CloneInstance {
861 file: root.join("src/a.ts"),
862 start_line: 1,
863 end_line: 5,
864 start_col: 0,
865 end_col: 0,
866 fragment: String::new(),
867 }],
868 token_count: 30,
869 line_count: 5,
870 }],
871 clone_families: vec![CloneFamily {
872 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
873 groups: vec![],
874 total_duplicated_lines: 20,
875 total_duplicated_tokens: 100,
876 suggestions: vec![RefactoringSuggestion {
877 kind: RefactoringKind::ExtractFunction,
878 description: "Extract shared utility function".to_string(),
879 estimated_savings: 15,
880 }],
881 }],
882 stats: DuplicationStats {
883 clone_groups: 1,
884 clone_instances: 1,
885 duplication_percentage: 2.0,
886 ..Default::default()
887 },
888 };
889 let md = build_duplication_markdown(&report, &root);
890 assert!(md.contains("### Clone Families"));
891 assert!(md.contains("**Family 1**"));
892 assert!(md.contains("Extract shared utility function"));
893 assert!(md.contains("~15 lines saved"));
894 }
895
896 #[test]
899 fn health_markdown_empty_no_findings() {
900 let root = PathBuf::from("/project");
901 let report = crate::health_types::HealthReport {
902 findings: vec![],
903 summary: crate::health_types::HealthSummary {
904 files_analyzed: 10,
905 functions_analyzed: 50,
906 functions_above_threshold: 0,
907 max_cyclomatic_threshold: 20,
908 max_cognitive_threshold: 15,
909 files_scored: None,
910 average_maintainability: None,
911 },
912 file_scores: vec![],
913 hotspots: vec![],
914 hotspot_summary: None,
915 };
916 let md = build_health_markdown(&report, &root);
917 assert!(md.contains("no functions exceed complexity thresholds"));
918 assert!(md.contains("**50** functions analyzed"));
919 }
920
921 #[test]
922 fn health_markdown_table_format() {
923 let root = PathBuf::from("/project");
924 let report = crate::health_types::HealthReport {
925 findings: vec![crate::health_types::HealthFinding {
926 path: root.join("src/utils.ts"),
927 name: "parseExpression".to_string(),
928 line: 42,
929 col: 0,
930 cyclomatic: 25,
931 cognitive: 30,
932 line_count: 80,
933 exceeded: crate::health_types::ExceededThreshold::Both,
934 }],
935 summary: crate::health_types::HealthSummary {
936 files_analyzed: 10,
937 functions_analyzed: 50,
938 functions_above_threshold: 1,
939 max_cyclomatic_threshold: 20,
940 max_cognitive_threshold: 15,
941 files_scored: None,
942 average_maintainability: None,
943 },
944 file_scores: vec![],
945 hotspots: vec![],
946 hotspot_summary: None,
947 };
948 let md = build_health_markdown(&report, &root);
949 assert!(md.contains("## Fallow: 1 high complexity function\n"));
950 assert!(md.contains("| File | Function |"));
951 assert!(md.contains("`src/utils.ts:42`"));
952 assert!(md.contains("`parseExpression`"));
953 assert!(md.contains("25 **!**"));
954 assert!(md.contains("30 **!**"));
955 assert!(md.contains("| 80 |"));
956 }
957
958 #[test]
959 fn health_markdown_no_marker_when_below_threshold() {
960 let root = PathBuf::from("/project");
961 let report = crate::health_types::HealthReport {
962 findings: vec![crate::health_types::HealthFinding {
963 path: root.join("src/utils.ts"),
964 name: "helper".to_string(),
965 line: 10,
966 col: 0,
967 cyclomatic: 15,
968 cognitive: 20,
969 line_count: 30,
970 exceeded: crate::health_types::ExceededThreshold::Cognitive,
971 }],
972 summary: crate::health_types::HealthSummary {
973 files_analyzed: 5,
974 functions_analyzed: 20,
975 functions_above_threshold: 1,
976 max_cyclomatic_threshold: 20,
977 max_cognitive_threshold: 15,
978 files_scored: None,
979 average_maintainability: None,
980 },
981 file_scores: vec![],
982 hotspots: vec![],
983 hotspot_summary: None,
984 };
985 let md = build_health_markdown(&report, &root);
986 assert!(md.contains("| 15 |"));
988 assert!(md.contains("20 **!**"));
990 }
991}