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