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