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