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