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, plural, 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!(out, "## Fallow: {total} issue{} found\n\n", plural(total));
35
36 markdown_section(&mut out, &results.unused_files, "Unused files", |file| {
38 vec![format!("- `{}`", rel(&file.path))]
39 });
40
41 markdown_grouped_section(
43 &mut out,
44 &results.unused_exports,
45 "Unused exports",
46 root,
47 |e| e.path.as_path(),
48 format_export,
49 );
50
51 markdown_grouped_section(
53 &mut out,
54 &results.unused_types,
55 "Unused type exports",
56 root,
57 |e| e.path.as_path(),
58 format_export,
59 );
60
61 markdown_section(
63 &mut out,
64 &results.unused_dependencies,
65 "Unused dependencies",
66 |dep| format_dependency(&dep.package_name, &dep.path, root),
67 );
68
69 markdown_section(
71 &mut out,
72 &results.unused_dev_dependencies,
73 "Unused devDependencies",
74 |dep| format_dependency(&dep.package_name, &dep.path, root),
75 );
76
77 markdown_section(
79 &mut out,
80 &results.unused_optional_dependencies,
81 "Unused optionalDependencies",
82 |dep| format_dependency(&dep.package_name, &dep.path, root),
83 );
84
85 markdown_grouped_section(
87 &mut out,
88 &results.unused_enum_members,
89 "Unused enum members",
90 root,
91 |m| m.path.as_path(),
92 format_member,
93 );
94
95 markdown_grouped_section(
97 &mut out,
98 &results.unused_class_members,
99 "Unused class members",
100 root,
101 |m| m.path.as_path(),
102 format_member,
103 );
104
105 markdown_grouped_section(
107 &mut out,
108 &results.unresolved_imports,
109 "Unresolved imports",
110 root,
111 |i| i.path.as_path(),
112 |i| format!(":{} `{}`", i.line, escape_backticks(&i.specifier)),
113 );
114
115 markdown_section(
117 &mut out,
118 &results.unlisted_dependencies,
119 "Unlisted dependencies",
120 |dep| vec![format!("- `{}`", escape_backticks(&dep.package_name))],
121 );
122
123 markdown_section(
125 &mut out,
126 &results.duplicate_exports,
127 "Duplicate exports",
128 |dup| {
129 let locations: Vec<String> = dup
130 .locations
131 .iter()
132 .map(|loc| format!("`{}`", rel(&loc.path)))
133 .collect();
134 vec![format!(
135 "- `{}` in {}",
136 escape_backticks(&dup.export_name),
137 locations.join(", ")
138 )]
139 },
140 );
141
142 markdown_section(
144 &mut out,
145 &results.type_only_dependencies,
146 "Type-only dependencies (consider moving to devDependencies)",
147 |dep| format_dependency(&dep.package_name, &dep.path, root),
148 );
149
150 markdown_section(
152 &mut out,
153 &results.circular_dependencies,
154 "Circular dependencies",
155 |cycle| {
156 let chain: Vec<String> = cycle.files.iter().map(|p| rel(p)).collect();
157 let mut display_chain = chain.clone();
158 if let Some(first) = chain.first() {
159 display_chain.push(first.clone());
160 }
161 vec![format!(
162 "- {}",
163 display_chain
164 .iter()
165 .map(|s| format!("`{s}`"))
166 .collect::<Vec<_>>()
167 .join(" \u{2192} ")
168 )]
169 },
170 );
171
172 out
173}
174
175fn format_export(e: &UnusedExport) -> String {
176 let re = if e.is_re_export { " (re-export)" } else { "" };
177 format!(":{} `{}`{re}", e.line, escape_backticks(&e.export_name))
178}
179
180fn format_member(m: &UnusedMember) -> String {
181 format!(
182 ":{} `{}.{}`",
183 m.line,
184 escape_backticks(&m.parent_name),
185 escape_backticks(&m.member_name)
186 )
187}
188
189fn format_dependency(dep_name: &str, pkg_path: &Path, root: &Path) -> Vec<String> {
190 let name = escape_backticks(dep_name);
191 let pkg_label = relative_path(pkg_path, root).display().to_string();
192 if pkg_label == "package.json" {
193 vec![format!("- `{name}`")]
194 } else {
195 let label = escape_backticks(&pkg_label);
196 vec![format!("- `{name}` ({label})")]
197 }
198}
199
200fn markdown_section<T>(
202 out: &mut String,
203 items: &[T],
204 title: &str,
205 format_lines: impl Fn(&T) -> Vec<String>,
206) {
207 if items.is_empty() {
208 return;
209 }
210 let _ = write!(out, "### {title} ({})\n\n", items.len());
211 for item in items {
212 for line in format_lines(item) {
213 out.push_str(&line);
214 out.push('\n');
215 }
216 }
217 out.push('\n');
218}
219
220fn markdown_grouped_section<'a, T>(
222 out: &mut String,
223 items: &'a [T],
224 title: &str,
225 root: &Path,
226 get_path: impl Fn(&'a T) -> &'a Path,
227 format_detail: impl Fn(&T) -> String,
228) {
229 if items.is_empty() {
230 return;
231 }
232 let _ = write!(out, "### {title} ({})\n\n", items.len());
233
234 let mut indices: Vec<usize> = (0..items.len()).collect();
235 indices.sort_by(|&a, &b| get_path(&items[a]).cmp(get_path(&items[b])));
236
237 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
238 let mut last_file = String::new();
239 for &i in &indices {
240 let item = &items[i];
241 let file_str = rel(get_path(item));
242 if file_str != last_file {
243 let _ = writeln!(out, "- `{file_str}`");
244 last_file = file_str;
245 }
246 let _ = writeln!(out, " - {}", format_detail(item));
247 }
248 out.push('\n');
249}
250
251pub(super) fn print_duplication_markdown(report: &DuplicationReport, root: &Path) {
254 println!("{}", build_duplication_markdown(report, root));
255}
256
257pub fn build_duplication_markdown(report: &DuplicationReport, root: &Path) -> String {
259 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
260
261 let mut out = String::new();
262
263 if report.clone_groups.is_empty() {
264 out.push_str("## Fallow: no code duplication found\n");
265 return out;
266 }
267
268 let stats = &report.stats;
269 let _ = write!(
270 out,
271 "## Fallow: {} clone group{} found ({:.1}% duplication)\n\n",
272 stats.clone_groups,
273 plural(stats.clone_groups),
274 stats.duplication_percentage,
275 );
276
277 out.push_str("### Duplicates\n\n");
278 for (i, group) in report.clone_groups.iter().enumerate() {
279 let instance_count = group.instances.len();
280 let _ = write!(
281 out,
282 "**Clone group {}** ({} lines, {instance_count} instance{})\n\n",
283 i + 1,
284 group.line_count,
285 plural(instance_count)
286 );
287 for instance in &group.instances {
288 let relative = rel(&instance.file);
289 let _ = writeln!(
290 out,
291 "- `{relative}:{}-{}`",
292 instance.start_line, instance.end_line
293 );
294 }
295 out.push('\n');
296 }
297
298 if !report.clone_families.is_empty() {
300 out.push_str("### Clone Families\n\n");
301 for (i, family) in report.clone_families.iter().enumerate() {
302 let file_names: Vec<_> = family.files.iter().map(|f| rel(f)).collect();
303 let _ = write!(
304 out,
305 "**Family {}** ({} group{}, {} lines across {})\n\n",
306 i + 1,
307 family.groups.len(),
308 plural(family.groups.len()),
309 family.total_duplicated_lines,
310 file_names
311 .iter()
312 .map(|s| format!("`{s}`"))
313 .collect::<Vec<_>>()
314 .join(", "),
315 );
316 for suggestion in &family.suggestions {
317 let savings = if suggestion.estimated_savings > 0 {
318 format!(" (~{} lines saved)", suggestion.estimated_savings)
319 } else {
320 String::new()
321 };
322 let _ = writeln!(out, "- {}{savings}", suggestion.description);
323 }
324 out.push('\n');
325 }
326 }
327
328 let _ = writeln!(
330 out,
331 "**Summary:** {} duplicated lines ({:.1}%) across {} file{}",
332 stats.duplicated_lines,
333 stats.duplication_percentage,
334 stats.files_with_clones,
335 plural(stats.files_with_clones),
336 );
337
338 out
339}
340
341pub(super) fn print_health_markdown(report: &crate::health_types::HealthReport, root: &Path) {
344 println!("{}", build_health_markdown(report, root));
345}
346
347pub fn build_health_markdown(report: &crate::health_types::HealthReport, root: &Path) -> String {
349 let rel = |p: &Path| {
350 escape_backticks(&normalize_uri(
351 &relative_path(p, root).display().to_string(),
352 ))
353 };
354
355 let mut out = String::new();
356
357 if let Some(ref vs) = report.vital_signs {
359 out.push_str("## Vital Signs\n\n");
360 out.push_str("| Metric | Value |\n");
361 out.push_str("|:-------|------:|\n");
362 let _ = writeln!(out, "| Avg Cyclomatic | {:.1} |", vs.avg_cyclomatic);
363 let _ = writeln!(out, "| P90 Cyclomatic | {} |", vs.p90_cyclomatic);
364 if let Some(v) = vs.dead_file_pct {
365 let _ = writeln!(out, "| Dead Files | {v:.1}% |");
366 }
367 if let Some(v) = vs.dead_export_pct {
368 let _ = writeln!(out, "| Dead Exports | {v:.1}% |");
369 }
370 if let Some(v) = vs.maintainability_avg {
371 let _ = writeln!(out, "| Maintainability (avg) | {v:.1} |");
372 }
373 if let Some(v) = vs.hotspot_count {
374 let _ = writeln!(out, "| Hotspots | {v} |");
375 }
376 if let Some(v) = vs.circular_dep_count {
377 let _ = writeln!(out, "| Circular Deps | {v} |");
378 }
379 if let Some(v) = vs.unused_dep_count {
380 let _ = writeln!(out, "| Unused Deps | {v} |");
381 }
382 out.push('\n');
383 }
384
385 if report.findings.is_empty()
386 && report.file_scores.is_empty()
387 && report.hotspots.is_empty()
388 && report.targets.is_empty()
389 {
390 if report.vital_signs.is_none() {
391 let _ = write!(
392 out,
393 "## Fallow: no functions exceed complexity thresholds\n\n\
394 **{}** functions analyzed (max cyclomatic: {}, max cognitive: {})\n",
395 report.summary.functions_analyzed,
396 report.summary.max_cyclomatic_threshold,
397 report.summary.max_cognitive_threshold,
398 );
399 }
400 return out;
401 }
402
403 if !report.findings.is_empty() {
404 let count = report.summary.functions_above_threshold;
405 let shown = report.findings.len();
406 if shown < count {
407 let _ = write!(
408 out,
409 "## Fallow: {count} high complexity function{} ({shown} shown)\n\n",
410 plural(count),
411 );
412 } else {
413 let _ = write!(
414 out,
415 "## Fallow: {count} high complexity function{}\n\n",
416 plural(count),
417 );
418 }
419
420 out.push_str("| File | Function | Cyclomatic | Cognitive | Lines |\n");
421 out.push_str("|:-----|:---------|:-----------|:----------|:------|\n");
422
423 for finding in &report.findings {
424 let file_str = rel(&finding.path);
425 let cyc_marker = if finding.cyclomatic > report.summary.max_cyclomatic_threshold {
426 " **!**"
427 } else {
428 ""
429 };
430 let cog_marker = if finding.cognitive > report.summary.max_cognitive_threshold {
431 " **!**"
432 } else {
433 ""
434 };
435 let _ = writeln!(
436 out,
437 "| `{file_str}:{line}` | `{name}` | {cyc}{cyc_marker} | {cog}{cog_marker} | {lines} |",
438 line = finding.line,
439 name = escape_backticks(&finding.name),
440 cyc = finding.cyclomatic,
441 cog = finding.cognitive,
442 lines = finding.line_count,
443 );
444 }
445
446 let s = &report.summary;
447 let _ = write!(
448 out,
449 "\n**{files}** files, **{funcs}** functions analyzed \
450 (thresholds: cyclomatic > {cyc}, cognitive > {cog})\n",
451 files = s.files_analyzed,
452 funcs = s.functions_analyzed,
453 cyc = s.max_cyclomatic_threshold,
454 cog = s.max_cognitive_threshold,
455 );
456 }
457
458 if !report.file_scores.is_empty() {
460 out.push('\n');
461 let _ = writeln!(
462 out,
463 "### File Health Scores ({} files)\n",
464 report.file_scores.len(),
465 );
466 out.push_str("| File | MI | Fan-in | Fan-out | Dead Code | Density |\n");
467 out.push_str("|:-----|:---|:-------|:--------|:----------|:--------|\n");
468
469 for score in &report.file_scores {
470 let file_str = rel(&score.path);
471 let _ = writeln!(
472 out,
473 "| `{file_str}` | {mi:.1} | {fi} | {fan_out} | {dead:.0}% | {density:.2} |",
474 mi = score.maintainability_index,
475 fi = score.fan_in,
476 fan_out = score.fan_out,
477 dead = score.dead_code_ratio * 100.0,
478 density = score.complexity_density,
479 );
480 }
481
482 if let Some(avg) = report.summary.average_maintainability {
483 let _ = write!(out, "\n**Average maintainability index:** {avg:.1}/100\n");
484 }
485 }
486
487 if !report.hotspots.is_empty() {
489 out.push('\n');
490 let header = if let Some(ref summary) = report.hotspot_summary {
491 format!(
492 "### Hotspots ({} files, since {})\n",
493 report.hotspots.len(),
494 summary.since,
495 )
496 } else {
497 format!("### Hotspots ({} files)\n", report.hotspots.len())
498 };
499 let _ = writeln!(out, "{header}");
500 out.push_str("| File | Score | Commits | Churn | Density | Fan-in | Trend |\n");
501 out.push_str("|:-----|:------|:--------|:------|:--------|:-------|:------|\n");
502
503 for entry in &report.hotspots {
504 let file_str = rel(&entry.path);
505 let _ = writeln!(
506 out,
507 "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} |",
508 score = entry.score,
509 commits = entry.commits,
510 churn = entry.lines_added + entry.lines_deleted,
511 density = entry.complexity_density,
512 fi = entry.fan_in,
513 trend = entry.trend,
514 );
515 }
516
517 if let Some(ref summary) = report.hotspot_summary
518 && summary.files_excluded > 0
519 {
520 let _ = write!(
521 out,
522 "\n*{} file{} excluded (< {} commits)*\n",
523 summary.files_excluded,
524 plural(summary.files_excluded),
525 summary.min_commits,
526 );
527 }
528 }
529
530 if !report.targets.is_empty() {
532 let _ = write!(
533 out,
534 "\n### Refactoring Targets ({})\n\n",
535 report.targets.len()
536 );
537 out.push_str("| Efficiency | Category | Effort / Confidence | File | Recommendation |\n");
538 out.push_str("|:-----------|:---------|:--------------------|:-----|:---------------|\n");
539 for target in &report.targets {
540 let file_str = normalize_uri(&relative_path(&target.path, root).display().to_string());
541 let category = target.category.label();
542 let effort = target.effort.label();
543 let confidence = target.confidence.label();
544 let _ = writeln!(
545 out,
546 "| {:.1} | {category} | {effort} / {confidence} | `{file_str}` | {} |",
547 target.efficiency, target.recommendation,
548 );
549 }
550 }
551
552 let has_scores = !report.file_scores.is_empty();
554 let has_hotspots = !report.hotspots.is_empty();
555 let has_targets = !report.targets.is_empty();
556 if has_scores || has_hotspots || has_targets {
557 out.push_str("\n---\n\n<details><summary>Metric definitions</summary>\n\n");
558 if has_scores {
559 out.push_str("- **MI** — Maintainability Index (0\u{2013}100, higher is better)\n");
560 out.push_str("- **Fan-in** — files that import this file (blast radius)\n");
561 out.push_str("- **Fan-out** — files this file imports (coupling)\n");
562 out.push_str("- **Dead Code** — % of value exports with zero references\n");
563 out.push_str("- **Density** — cyclomatic complexity / lines of code\n");
564 }
565 if has_hotspots {
566 out.push_str(
567 "- **Score** — churn \u{00d7} complexity (0\u{2013}100, higher = riskier)\n",
568 );
569 out.push_str("- **Commits** — commits in the analysis window\n");
570 out.push_str("- **Churn** — total lines added + deleted\n");
571 out.push_str("- **Trend** — accelerating / stable / cooling\n");
572 }
573 if has_targets {
574 out.push_str("- **Efficiency** — priority / effort (higher = better quick-win value, default sort)\n");
575 out.push_str("- **Category** — recommendation type (churn+complexity, high impact, dead code, complexity, coupling, circular dep)\n");
576 out.push_str("- **Effort** — estimated effort (low / medium / high) based on file size, function count, and fan-in\n");
577 out.push_str("- **Confidence** — recommendation reliability (high = deterministic analysis, medium = heuristic, low = git-dependent)\n");
578 }
579 out.push_str("\n[Full metric reference](https://docs.fallow.tools/explanations/metrics)\n\n</details>\n");
580 }
581
582 out
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588 use crate::report::test_helpers::sample_results;
589 use fallow_core::duplicates::{
590 CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
591 RefactoringKind, RefactoringSuggestion,
592 };
593 use fallow_core::results::*;
594 use std::path::PathBuf;
595
596 #[test]
597 fn markdown_empty_results_no_issues() {
598 let root = PathBuf::from("/project");
599 let results = AnalysisResults::default();
600 let md = build_markdown(&results, &root);
601 assert_eq!(md, "## Fallow: no issues found\n");
602 }
603
604 #[test]
605 fn markdown_contains_header_with_count() {
606 let root = PathBuf::from("/project");
607 let results = sample_results(&root);
608 let md = build_markdown(&results, &root);
609 assert!(md.starts_with(&format!(
610 "## Fallow: {} issues found\n",
611 results.total_issues()
612 )));
613 }
614
615 #[test]
616 fn markdown_contains_all_sections() {
617 let root = PathBuf::from("/project");
618 let results = sample_results(&root);
619 let md = build_markdown(&results, &root);
620
621 assert!(md.contains("### Unused files (1)"));
622 assert!(md.contains("### Unused exports (1)"));
623 assert!(md.contains("### Unused type exports (1)"));
624 assert!(md.contains("### Unused dependencies (1)"));
625 assert!(md.contains("### Unused devDependencies (1)"));
626 assert!(md.contains("### Unused enum members (1)"));
627 assert!(md.contains("### Unused class members (1)"));
628 assert!(md.contains("### Unresolved imports (1)"));
629 assert!(md.contains("### Unlisted dependencies (1)"));
630 assert!(md.contains("### Duplicate exports (1)"));
631 assert!(md.contains("### Type-only dependencies"));
632 assert!(md.contains("### Circular dependencies (1)"));
633 }
634
635 #[test]
636 fn markdown_unused_file_format() {
637 let root = PathBuf::from("/project");
638 let mut results = AnalysisResults::default();
639 results.unused_files.push(UnusedFile {
640 path: root.join("src/dead.ts"),
641 });
642 let md = build_markdown(&results, &root);
643 assert!(md.contains("- `src/dead.ts`"));
644 }
645
646 #[test]
647 fn markdown_unused_export_grouped_by_file() {
648 let root = PathBuf::from("/project");
649 let mut results = AnalysisResults::default();
650 results.unused_exports.push(UnusedExport {
651 path: root.join("src/utils.ts"),
652 export_name: "helperFn".to_string(),
653 is_type_only: false,
654 line: 10,
655 col: 4,
656 span_start: 120,
657 is_re_export: false,
658 });
659 let md = build_markdown(&results, &root);
660 assert!(md.contains("- `src/utils.ts`"));
661 assert!(md.contains(":10 `helperFn`"));
662 }
663
664 #[test]
665 fn markdown_re_export_tagged() {
666 let root = PathBuf::from("/project");
667 let mut results = AnalysisResults::default();
668 results.unused_exports.push(UnusedExport {
669 path: root.join("src/index.ts"),
670 export_name: "reExported".to_string(),
671 is_type_only: false,
672 line: 1,
673 col: 0,
674 span_start: 0,
675 is_re_export: true,
676 });
677 let md = build_markdown(&results, &root);
678 assert!(md.contains("(re-export)"));
679 }
680
681 #[test]
682 fn markdown_unused_dep_format() {
683 let root = PathBuf::from("/project");
684 let mut results = AnalysisResults::default();
685 results.unused_dependencies.push(UnusedDependency {
686 package_name: "lodash".to_string(),
687 location: DependencyLocation::Dependencies,
688 path: root.join("package.json"),
689 line: 5,
690 });
691 let md = build_markdown(&results, &root);
692 assert!(md.contains("- `lodash`"));
693 }
694
695 #[test]
696 fn markdown_circular_dep_format() {
697 let root = PathBuf::from("/project");
698 let mut results = AnalysisResults::default();
699 results.circular_dependencies.push(CircularDependency {
700 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
701 length: 2,
702 line: 3,
703 col: 0,
704 });
705 let md = build_markdown(&results, &root);
706 assert!(md.contains("`src/a.ts`"));
707 assert!(md.contains("`src/b.ts`"));
708 assert!(md.contains("\u{2192}"));
709 }
710
711 #[test]
712 fn markdown_strips_root_prefix() {
713 let root = PathBuf::from("/project");
714 let mut results = AnalysisResults::default();
715 results.unused_files.push(UnusedFile {
716 path: PathBuf::from("/project/src/deep/nested/file.ts"),
717 });
718 let md = build_markdown(&results, &root);
719 assert!(md.contains("`src/deep/nested/file.ts`"));
720 assert!(!md.contains("/project/"));
721 }
722
723 #[test]
724 fn markdown_single_issue_no_plural() {
725 let root = PathBuf::from("/project");
726 let mut results = AnalysisResults::default();
727 results.unused_files.push(UnusedFile {
728 path: root.join("src/dead.ts"),
729 });
730 let md = build_markdown(&results, &root);
731 assert!(md.starts_with("## Fallow: 1 issue found\n"));
732 }
733
734 #[test]
735 fn markdown_type_only_dep_format() {
736 let root = PathBuf::from("/project");
737 let mut results = AnalysisResults::default();
738 results.type_only_dependencies.push(TypeOnlyDependency {
739 package_name: "zod".to_string(),
740 path: root.join("package.json"),
741 line: 8,
742 });
743 let md = build_markdown(&results, &root);
744 assert!(md.contains("### Type-only dependencies"));
745 assert!(md.contains("- `zod`"));
746 }
747
748 #[test]
749 fn markdown_escapes_backticks_in_export_names() {
750 let root = PathBuf::from("/project");
751 let mut results = AnalysisResults::default();
752 results.unused_exports.push(UnusedExport {
753 path: root.join("src/utils.ts"),
754 export_name: "foo`bar".to_string(),
755 is_type_only: false,
756 line: 1,
757 col: 0,
758 span_start: 0,
759 is_re_export: false,
760 });
761 let md = build_markdown(&results, &root);
762 assert!(md.contains("foo\\`bar"));
763 assert!(!md.contains("foo`bar`"));
764 }
765
766 #[test]
767 fn markdown_escapes_backticks_in_package_names() {
768 let root = PathBuf::from("/project");
769 let mut results = AnalysisResults::default();
770 results.unused_dependencies.push(UnusedDependency {
771 package_name: "pkg`name".to_string(),
772 location: DependencyLocation::Dependencies,
773 path: root.join("package.json"),
774 line: 5,
775 });
776 let md = build_markdown(&results, &root);
777 assert!(md.contains("pkg\\`name"));
778 }
779
780 #[test]
783 fn duplication_markdown_empty() {
784 let report = DuplicationReport::default();
785 let root = PathBuf::from("/project");
786 let md = build_duplication_markdown(&report, &root);
787 assert_eq!(md, "## Fallow: no code duplication found\n");
788 }
789
790 #[test]
791 fn duplication_markdown_contains_groups() {
792 let root = PathBuf::from("/project");
793 let report = DuplicationReport {
794 clone_groups: vec![CloneGroup {
795 instances: vec![
796 CloneInstance {
797 file: root.join("src/a.ts"),
798 start_line: 1,
799 end_line: 10,
800 start_col: 0,
801 end_col: 0,
802 fragment: String::new(),
803 },
804 CloneInstance {
805 file: root.join("src/b.ts"),
806 start_line: 5,
807 end_line: 14,
808 start_col: 0,
809 end_col: 0,
810 fragment: String::new(),
811 },
812 ],
813 token_count: 50,
814 line_count: 10,
815 }],
816 clone_families: vec![],
817 stats: DuplicationStats {
818 total_files: 10,
819 files_with_clones: 2,
820 total_lines: 500,
821 duplicated_lines: 20,
822 total_tokens: 2500,
823 duplicated_tokens: 100,
824 clone_groups: 1,
825 clone_instances: 2,
826 duplication_percentage: 4.0,
827 },
828 };
829 let md = build_duplication_markdown(&report, &root);
830 assert!(md.contains("**Clone group 1**"));
831 assert!(md.contains("`src/a.ts:1-10`"));
832 assert!(md.contains("`src/b.ts:5-14`"));
833 assert!(md.contains("4.0% duplication"));
834 }
835
836 #[test]
837 fn duplication_markdown_contains_families() {
838 let root = PathBuf::from("/project");
839 let report = DuplicationReport {
840 clone_groups: vec![CloneGroup {
841 instances: vec![CloneInstance {
842 file: root.join("src/a.ts"),
843 start_line: 1,
844 end_line: 5,
845 start_col: 0,
846 end_col: 0,
847 fragment: String::new(),
848 }],
849 token_count: 30,
850 line_count: 5,
851 }],
852 clone_families: vec![CloneFamily {
853 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
854 groups: vec![],
855 total_duplicated_lines: 20,
856 total_duplicated_tokens: 100,
857 suggestions: vec![RefactoringSuggestion {
858 kind: RefactoringKind::ExtractFunction,
859 description: "Extract shared utility function".to_string(),
860 estimated_savings: 15,
861 }],
862 }],
863 stats: DuplicationStats {
864 clone_groups: 1,
865 clone_instances: 1,
866 duplication_percentage: 2.0,
867 ..Default::default()
868 },
869 };
870 let md = build_duplication_markdown(&report, &root);
871 assert!(md.contains("### Clone Families"));
872 assert!(md.contains("**Family 1**"));
873 assert!(md.contains("Extract shared utility function"));
874 assert!(md.contains("~15 lines saved"));
875 }
876
877 #[test]
880 fn health_markdown_empty_no_findings() {
881 let root = PathBuf::from("/project");
882 let report = crate::health_types::HealthReport {
883 findings: vec![],
884 summary: crate::health_types::HealthSummary {
885 files_analyzed: 10,
886 functions_analyzed: 50,
887 functions_above_threshold: 0,
888 max_cyclomatic_threshold: 20,
889 max_cognitive_threshold: 15,
890 files_scored: None,
891 average_maintainability: None,
892 },
893 vital_signs: None,
894 file_scores: vec![],
895 hotspots: vec![],
896 hotspot_summary: None,
897 targets: vec![],
898 target_thresholds: None,
899 };
900 let md = build_health_markdown(&report, &root);
901 assert!(md.contains("no functions exceed complexity thresholds"));
902 assert!(md.contains("**50** functions analyzed"));
903 }
904
905 #[test]
906 fn health_markdown_table_format() {
907 let root = PathBuf::from("/project");
908 let report = crate::health_types::HealthReport {
909 findings: vec![crate::health_types::HealthFinding {
910 path: root.join("src/utils.ts"),
911 name: "parseExpression".to_string(),
912 line: 42,
913 col: 0,
914 cyclomatic: 25,
915 cognitive: 30,
916 line_count: 80,
917 exceeded: crate::health_types::ExceededThreshold::Both,
918 }],
919 summary: crate::health_types::HealthSummary {
920 files_analyzed: 10,
921 functions_analyzed: 50,
922 functions_above_threshold: 1,
923 max_cyclomatic_threshold: 20,
924 max_cognitive_threshold: 15,
925 files_scored: None,
926 average_maintainability: None,
927 },
928 vital_signs: None,
929 file_scores: vec![],
930 hotspots: vec![],
931 hotspot_summary: None,
932 targets: vec![],
933 target_thresholds: None,
934 };
935 let md = build_health_markdown(&report, &root);
936 assert!(md.contains("## Fallow: 1 high complexity function\n"));
937 assert!(md.contains("| File | Function |"));
938 assert!(md.contains("`src/utils.ts:42`"));
939 assert!(md.contains("`parseExpression`"));
940 assert!(md.contains("25 **!**"));
941 assert!(md.contains("30 **!**"));
942 assert!(md.contains("| 80 |"));
943 }
944
945 #[test]
946 fn health_markdown_no_marker_when_below_threshold() {
947 let root = PathBuf::from("/project");
948 let report = crate::health_types::HealthReport {
949 findings: vec![crate::health_types::HealthFinding {
950 path: root.join("src/utils.ts"),
951 name: "helper".to_string(),
952 line: 10,
953 col: 0,
954 cyclomatic: 15,
955 cognitive: 20,
956 line_count: 30,
957 exceeded: crate::health_types::ExceededThreshold::Cognitive,
958 }],
959 summary: crate::health_types::HealthSummary {
960 files_analyzed: 5,
961 functions_analyzed: 20,
962 functions_above_threshold: 1,
963 max_cyclomatic_threshold: 20,
964 max_cognitive_threshold: 15,
965 files_scored: None,
966 average_maintainability: None,
967 },
968 vital_signs: None,
969 file_scores: vec![],
970 hotspots: vec![],
971 hotspot_summary: None,
972 targets: vec![],
973 target_thresholds: None,
974 };
975 let md = build_health_markdown(&report, &root);
976 assert!(md.contains("| 15 |"));
978 assert!(md.contains("20 **!**"));
980 }
981
982 #[test]
983 fn health_markdown_with_targets() {
984 use crate::health_types::*;
985
986 let root = PathBuf::from("/project");
987 let report = HealthReport {
988 findings: vec![],
989 summary: HealthSummary {
990 files_analyzed: 10,
991 functions_analyzed: 50,
992 functions_above_threshold: 0,
993 max_cyclomatic_threshold: 20,
994 max_cognitive_threshold: 15,
995 files_scored: None,
996 average_maintainability: None,
997 },
998 vital_signs: None,
999 file_scores: vec![],
1000 hotspots: vec![],
1001 hotspot_summary: None,
1002 targets: vec![
1003 RefactoringTarget {
1004 path: PathBuf::from("/project/src/complex.ts"),
1005 priority: 82.5,
1006 efficiency: 27.5,
1007 recommendation: "Split high-impact file".into(),
1008 category: RecommendationCategory::SplitHighImpact,
1009 effort: crate::health_types::EffortEstimate::High,
1010 confidence: crate::health_types::Confidence::Medium,
1011 factors: vec![ContributingFactor {
1012 metric: "fan_in",
1013 value: 25.0,
1014 threshold: 10.0,
1015 detail: "25 files depend on this".into(),
1016 }],
1017 evidence: None,
1018 },
1019 RefactoringTarget {
1020 path: PathBuf::from("/project/src/legacy.ts"),
1021 priority: 45.0,
1022 efficiency: 45.0,
1023 recommendation: "Remove 5 unused exports".into(),
1024 category: RecommendationCategory::RemoveDeadCode,
1025 effort: crate::health_types::EffortEstimate::Low,
1026 confidence: crate::health_types::Confidence::High,
1027 factors: vec![],
1028 evidence: None,
1029 },
1030 ],
1031 target_thresholds: None,
1032 };
1033 let md = build_health_markdown(&report, &root);
1034
1035 assert!(
1037 md.contains("Refactoring Targets"),
1038 "should contain targets heading"
1039 );
1040 assert!(
1041 md.contains("src/complex.ts"),
1042 "should contain target file path"
1043 );
1044 assert!(md.contains("27.5"), "should contain efficiency score");
1045 assert!(
1046 md.contains("Split high-impact file"),
1047 "should contain recommendation"
1048 );
1049 assert!(md.contains("src/legacy.ts"), "should contain second target");
1050 }
1051
1052 #[test]
1055 fn markdown_dep_in_workspace_shows_package_label() {
1056 let root = PathBuf::from("/project");
1057 let mut results = AnalysisResults::default();
1058 results.unused_dependencies.push(UnusedDependency {
1059 package_name: "lodash".to_string(),
1060 location: DependencyLocation::Dependencies,
1061 path: root.join("packages/core/package.json"),
1062 line: 5,
1063 });
1064 let md = build_markdown(&results, &root);
1065 assert!(md.contains("(packages/core/package.json)"));
1067 }
1068
1069 #[test]
1070 fn markdown_dep_at_root_no_extra_label() {
1071 let root = PathBuf::from("/project");
1072 let mut results = AnalysisResults::default();
1073 results.unused_dependencies.push(UnusedDependency {
1074 package_name: "lodash".to_string(),
1075 location: DependencyLocation::Dependencies,
1076 path: root.join("package.json"),
1077 line: 5,
1078 });
1079 let md = build_markdown(&results, &root);
1080 assert!(md.contains("- `lodash`"));
1081 assert!(!md.contains("(package.json)"));
1082 }
1083
1084 #[test]
1087 fn markdown_exports_grouped_by_file() {
1088 let root = PathBuf::from("/project");
1089 let mut results = AnalysisResults::default();
1090 results.unused_exports.push(UnusedExport {
1091 path: root.join("src/utils.ts"),
1092 export_name: "alpha".to_string(),
1093 is_type_only: false,
1094 line: 5,
1095 col: 0,
1096 span_start: 0,
1097 is_re_export: false,
1098 });
1099 results.unused_exports.push(UnusedExport {
1100 path: root.join("src/utils.ts"),
1101 export_name: "beta".to_string(),
1102 is_type_only: false,
1103 line: 10,
1104 col: 0,
1105 span_start: 0,
1106 is_re_export: false,
1107 });
1108 results.unused_exports.push(UnusedExport {
1109 path: root.join("src/other.ts"),
1110 export_name: "gamma".to_string(),
1111 is_type_only: false,
1112 line: 1,
1113 col: 0,
1114 span_start: 0,
1115 is_re_export: false,
1116 });
1117 let md = build_markdown(&results, &root);
1118 let utils_count = md.matches("- `src/utils.ts`").count();
1120 assert_eq!(utils_count, 1, "file header should appear once per file");
1121 assert!(md.contains(":5 `alpha`"));
1123 assert!(md.contains(":10 `beta`"));
1124 }
1125
1126 #[test]
1129 fn markdown_multiple_issues_plural() {
1130 let root = PathBuf::from("/project");
1131 let mut results = AnalysisResults::default();
1132 results.unused_files.push(UnusedFile {
1133 path: root.join("src/a.ts"),
1134 });
1135 results.unused_files.push(UnusedFile {
1136 path: root.join("src/b.ts"),
1137 });
1138 let md = build_markdown(&results, &root);
1139 assert!(md.starts_with("## Fallow: 2 issues found\n"));
1140 }
1141
1142 #[test]
1145 fn duplication_markdown_zero_savings_no_suffix() {
1146 let root = PathBuf::from("/project");
1147 let report = DuplicationReport {
1148 clone_groups: vec![CloneGroup {
1149 instances: vec![CloneInstance {
1150 file: root.join("src/a.ts"),
1151 start_line: 1,
1152 end_line: 5,
1153 start_col: 0,
1154 end_col: 0,
1155 fragment: String::new(),
1156 }],
1157 token_count: 30,
1158 line_count: 5,
1159 }],
1160 clone_families: vec![CloneFamily {
1161 files: vec![root.join("src/a.ts")],
1162 groups: vec![],
1163 total_duplicated_lines: 5,
1164 total_duplicated_tokens: 30,
1165 suggestions: vec![RefactoringSuggestion {
1166 kind: RefactoringKind::ExtractFunction,
1167 description: "Extract function".to_string(),
1168 estimated_savings: 0,
1169 }],
1170 }],
1171 stats: DuplicationStats {
1172 clone_groups: 1,
1173 clone_instances: 1,
1174 duplication_percentage: 1.0,
1175 ..Default::default()
1176 },
1177 };
1178 let md = build_duplication_markdown(&report, &root);
1179 assert!(md.contains("Extract function"));
1180 assert!(!md.contains("lines saved"));
1181 }
1182
1183 #[test]
1186 fn health_markdown_vital_signs_table() {
1187 let root = PathBuf::from("/project");
1188 let report = crate::health_types::HealthReport {
1189 findings: vec![],
1190 summary: crate::health_types::HealthSummary {
1191 files_analyzed: 10,
1192 functions_analyzed: 50,
1193 functions_above_threshold: 0,
1194 max_cyclomatic_threshold: 20,
1195 max_cognitive_threshold: 15,
1196 files_scored: None,
1197 average_maintainability: None,
1198 },
1199 vital_signs: Some(crate::health_types::VitalSigns {
1200 avg_cyclomatic: 3.5,
1201 p90_cyclomatic: 12,
1202 dead_file_pct: Some(5.0),
1203 dead_export_pct: Some(10.2),
1204 duplication_pct: None,
1205 maintainability_avg: Some(72.3),
1206 hotspot_count: Some(3),
1207 circular_dep_count: Some(1),
1208 unused_dep_count: Some(2),
1209 }),
1210 file_scores: vec![],
1211 hotspots: vec![],
1212 hotspot_summary: None,
1213 targets: vec![],
1214 target_thresholds: None,
1215 };
1216 let md = build_health_markdown(&report, &root);
1217 assert!(md.contains("## Vital Signs"));
1218 assert!(md.contains("| Metric | Value |"));
1219 assert!(md.contains("| Avg Cyclomatic | 3.5 |"));
1220 assert!(md.contains("| P90 Cyclomatic | 12 |"));
1221 assert!(md.contains("| Dead Files | 5.0% |"));
1222 assert!(md.contains("| Dead Exports | 10.2% |"));
1223 assert!(md.contains("| Maintainability (avg) | 72.3 |"));
1224 assert!(md.contains("| Hotspots | 3 |"));
1225 assert!(md.contains("| Circular Deps | 1 |"));
1226 assert!(md.contains("| Unused Deps | 2 |"));
1227 }
1228
1229 #[test]
1232 fn health_markdown_file_scores_table() {
1233 let root = PathBuf::from("/project");
1234 let report = crate::health_types::HealthReport {
1235 findings: vec![crate::health_types::HealthFinding {
1236 path: root.join("src/dummy.ts"),
1237 name: "fn".to_string(),
1238 line: 1,
1239 col: 0,
1240 cyclomatic: 25,
1241 cognitive: 20,
1242 line_count: 50,
1243 exceeded: crate::health_types::ExceededThreshold::Both,
1244 }],
1245 summary: crate::health_types::HealthSummary {
1246 files_analyzed: 5,
1247 functions_analyzed: 10,
1248 functions_above_threshold: 1,
1249 max_cyclomatic_threshold: 20,
1250 max_cognitive_threshold: 15,
1251 files_scored: Some(1),
1252 average_maintainability: Some(65.0),
1253 },
1254 vital_signs: None,
1255 file_scores: vec![crate::health_types::FileHealthScore {
1256 path: root.join("src/utils.ts"),
1257 fan_in: 5,
1258 fan_out: 3,
1259 dead_code_ratio: 0.25,
1260 complexity_density: 0.8,
1261 maintainability_index: 72.5,
1262 total_cyclomatic: 40,
1263 total_cognitive: 30,
1264 function_count: 10,
1265 lines: 200,
1266 }],
1267 hotspots: vec![],
1268 hotspot_summary: None,
1269 targets: vec![],
1270 target_thresholds: None,
1271 };
1272 let md = build_health_markdown(&report, &root);
1273 assert!(md.contains("### File Health Scores (1 files)"));
1274 assert!(md.contains("| File | MI | Fan-in | Fan-out | Dead Code | Density |"));
1275 assert!(md.contains("| `src/utils.ts` | 72.5 | 5 | 3 | 25% | 0.80 |"));
1276 assert!(md.contains("**Average maintainability index:** 65.0/100"));
1277 }
1278
1279 #[test]
1282 fn health_markdown_hotspots_table() {
1283 let root = PathBuf::from("/project");
1284 let report = crate::health_types::HealthReport {
1285 findings: vec![crate::health_types::HealthFinding {
1286 path: root.join("src/dummy.ts"),
1287 name: "fn".to_string(),
1288 line: 1,
1289 col: 0,
1290 cyclomatic: 25,
1291 cognitive: 20,
1292 line_count: 50,
1293 exceeded: crate::health_types::ExceededThreshold::Both,
1294 }],
1295 summary: crate::health_types::HealthSummary {
1296 files_analyzed: 5,
1297 functions_analyzed: 10,
1298 functions_above_threshold: 1,
1299 max_cyclomatic_threshold: 20,
1300 max_cognitive_threshold: 15,
1301 files_scored: None,
1302 average_maintainability: None,
1303 },
1304 vital_signs: None,
1305 file_scores: vec![],
1306 hotspots: vec![crate::health_types::HotspotEntry {
1307 path: root.join("src/hot.ts"),
1308 score: 85.0,
1309 commits: 42,
1310 weighted_commits: 35.0,
1311 lines_added: 500,
1312 lines_deleted: 200,
1313 complexity_density: 1.2,
1314 fan_in: 10,
1315 trend: fallow_core::churn::ChurnTrend::Accelerating,
1316 }],
1317 hotspot_summary: Some(crate::health_types::HotspotSummary {
1318 since: "6 months".to_string(),
1319 min_commits: 3,
1320 files_analyzed: 50,
1321 files_excluded: 5,
1322 shallow_clone: false,
1323 }),
1324 targets: vec![],
1325 target_thresholds: None,
1326 };
1327 let md = build_health_markdown(&report, &root);
1328 assert!(md.contains("### Hotspots (1 files, since 6 months)"));
1329 assert!(md.contains("| `src/hot.ts` | 85.0 | 42 | 700 | 1.20 | 10 | accelerating |"));
1330 assert!(md.contains("*5 files excluded (< 3 commits)*"));
1331 }
1332
1333 #[test]
1336 fn health_markdown_metric_legend_with_scores() {
1337 let root = PathBuf::from("/project");
1338 let report = crate::health_types::HealthReport {
1339 findings: vec![crate::health_types::HealthFinding {
1340 path: root.join("src/x.ts"),
1341 name: "f".to_string(),
1342 line: 1,
1343 col: 0,
1344 cyclomatic: 25,
1345 cognitive: 20,
1346 line_count: 10,
1347 exceeded: crate::health_types::ExceededThreshold::Both,
1348 }],
1349 summary: crate::health_types::HealthSummary {
1350 files_analyzed: 1,
1351 functions_analyzed: 1,
1352 functions_above_threshold: 1,
1353 max_cyclomatic_threshold: 20,
1354 max_cognitive_threshold: 15,
1355 files_scored: Some(1),
1356 average_maintainability: Some(70.0),
1357 },
1358 vital_signs: None,
1359 file_scores: vec![crate::health_types::FileHealthScore {
1360 path: root.join("src/x.ts"),
1361 fan_in: 1,
1362 fan_out: 1,
1363 dead_code_ratio: 0.0,
1364 complexity_density: 0.5,
1365 maintainability_index: 80.0,
1366 total_cyclomatic: 10,
1367 total_cognitive: 8,
1368 function_count: 2,
1369 lines: 50,
1370 }],
1371 hotspots: vec![],
1372 hotspot_summary: None,
1373 targets: vec![],
1374 target_thresholds: None,
1375 };
1376 let md = build_health_markdown(&report, &root);
1377 assert!(md.contains("<details><summary>Metric definitions</summary>"));
1378 assert!(md.contains("**MI** \u{2014} Maintainability Index"));
1379 assert!(md.contains("**Fan-in**"));
1380 assert!(md.contains("Full metric reference"));
1381 }
1382
1383 #[test]
1386 fn health_markdown_truncated_findings_shown_count() {
1387 let root = PathBuf::from("/project");
1388 let report = crate::health_types::HealthReport {
1389 findings: vec![crate::health_types::HealthFinding {
1390 path: root.join("src/x.ts"),
1391 name: "f".to_string(),
1392 line: 1,
1393 col: 0,
1394 cyclomatic: 25,
1395 cognitive: 20,
1396 line_count: 10,
1397 exceeded: crate::health_types::ExceededThreshold::Both,
1398 }],
1399 summary: crate::health_types::HealthSummary {
1400 files_analyzed: 10,
1401 functions_analyzed: 50,
1402 functions_above_threshold: 5, max_cyclomatic_threshold: 20,
1404 max_cognitive_threshold: 15,
1405 files_scored: None,
1406 average_maintainability: None,
1407 },
1408 vital_signs: None,
1409 file_scores: vec![],
1410 hotspots: vec![],
1411 hotspot_summary: None,
1412 targets: vec![],
1413 target_thresholds: None,
1414 };
1415 let md = build_health_markdown(&report, &root);
1416 assert!(md.contains("5 high complexity functions (1 shown)"));
1417 }
1418
1419 #[test]
1422 fn escape_backticks_handles_multiple() {
1423 assert_eq!(escape_backticks("a`b`c"), "a\\`b\\`c");
1424 }
1425
1426 #[test]
1427 fn escape_backticks_no_backticks_unchanged() {
1428 assert_eq!(escape_backticks("hello"), "hello");
1429 }
1430
1431 #[test]
1434 fn markdown_unresolved_import_grouped_by_file() {
1435 let root = PathBuf::from("/project");
1436 let mut results = AnalysisResults::default();
1437 results.unresolved_imports.push(UnresolvedImport {
1438 path: root.join("src/app.ts"),
1439 specifier: "./missing".to_string(),
1440 line: 3,
1441 col: 0,
1442 specifier_col: 0,
1443 });
1444 let md = build_markdown(&results, &root);
1445 assert!(md.contains("### Unresolved imports (1)"));
1446 assert!(md.contains("- `src/app.ts`"));
1447 assert!(md.contains(":3 `./missing`"));
1448 }
1449
1450 #[test]
1453 fn markdown_unused_optional_dep() {
1454 let root = PathBuf::from("/project");
1455 let mut results = AnalysisResults::default();
1456 results.unused_optional_dependencies.push(UnusedDependency {
1457 package_name: "fsevents".to_string(),
1458 location: DependencyLocation::OptionalDependencies,
1459 path: root.join("package.json"),
1460 line: 12,
1461 });
1462 let md = build_markdown(&results, &root);
1463 assert!(md.contains("### Unused optionalDependencies (1)"));
1464 assert!(md.contains("- `fsevents`"));
1465 }
1466
1467 #[test]
1470 fn health_markdown_hotspots_no_excluded_message() {
1471 let root = PathBuf::from("/project");
1472 let report = crate::health_types::HealthReport {
1473 findings: vec![crate::health_types::HealthFinding {
1474 path: root.join("src/x.ts"),
1475 name: "f".to_string(),
1476 line: 1,
1477 col: 0,
1478 cyclomatic: 25,
1479 cognitive: 20,
1480 line_count: 10,
1481 exceeded: crate::health_types::ExceededThreshold::Both,
1482 }],
1483 summary: crate::health_types::HealthSummary {
1484 files_analyzed: 5,
1485 functions_analyzed: 10,
1486 functions_above_threshold: 1,
1487 max_cyclomatic_threshold: 20,
1488 max_cognitive_threshold: 15,
1489 files_scored: None,
1490 average_maintainability: None,
1491 },
1492 vital_signs: None,
1493 file_scores: vec![],
1494 hotspots: vec![crate::health_types::HotspotEntry {
1495 path: root.join("src/hot.ts"),
1496 score: 50.0,
1497 commits: 10,
1498 weighted_commits: 8.0,
1499 lines_added: 100,
1500 lines_deleted: 50,
1501 complexity_density: 0.5,
1502 fan_in: 3,
1503 trend: fallow_core::churn::ChurnTrend::Stable,
1504 }],
1505 hotspot_summary: Some(crate::health_types::HotspotSummary {
1506 since: "6 months".to_string(),
1507 min_commits: 3,
1508 files_analyzed: 50,
1509 files_excluded: 0,
1510 shallow_clone: false,
1511 }),
1512 targets: vec![],
1513 target_thresholds: None,
1514 };
1515 let md = build_health_markdown(&report, &root);
1516 assert!(!md.contains("files excluded"));
1517 }
1518
1519 #[test]
1522 fn duplication_markdown_single_group_no_plural() {
1523 let root = PathBuf::from("/project");
1524 let report = DuplicationReport {
1525 clone_groups: vec![CloneGroup {
1526 instances: vec![CloneInstance {
1527 file: root.join("src/a.ts"),
1528 start_line: 1,
1529 end_line: 5,
1530 start_col: 0,
1531 end_col: 0,
1532 fragment: String::new(),
1533 }],
1534 token_count: 30,
1535 line_count: 5,
1536 }],
1537 clone_families: vec![],
1538 stats: DuplicationStats {
1539 clone_groups: 1,
1540 clone_instances: 1,
1541 duplication_percentage: 2.0,
1542 ..Default::default()
1543 },
1544 };
1545 let md = build_duplication_markdown(&report, &root);
1546 assert!(md.contains("1 clone group found"));
1547 assert!(!md.contains("1 clone groups found"));
1548 }
1549}