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.coverage_gaps.is_none()
431 && report.hotspots.is_empty()
432 && report.targets.is_empty()
433 {
434 if report.vital_signs.is_none() {
435 let _ = write!(
436 out,
437 "## Fallow: no functions exceed complexity thresholds\n\n\
438 **{}** functions analyzed (max cyclomatic: {}, max cognitive: {})\n",
439 report.summary.functions_analyzed,
440 report.summary.max_cyclomatic_threshold,
441 report.summary.max_cognitive_threshold,
442 );
443 }
444 return out;
445 }
446
447 write_findings_section(&mut out, report, root);
448 write_coverage_gaps_section(&mut out, report, root);
449 write_file_scores_section(&mut out, report, root);
450 write_hotspots_section(&mut out, report, root);
451 write_targets_section(&mut out, report, root);
452 write_metric_legend(&mut out, report);
453
454 out
455}
456
457fn write_trend_section(out: &mut String, report: &crate::health_types::HealthReport) {
459 let Some(ref trend) = report.health_trend else {
460 return;
461 };
462 let sha_str = trend
463 .compared_to
464 .git_sha
465 .as_deref()
466 .map_or(String::new(), |sha| format!(" ({sha})"));
467 let _ = writeln!(
468 out,
469 "## Trend (vs {}{})\n",
470 trend
471 .compared_to
472 .timestamp
473 .get(..10)
474 .unwrap_or(&trend.compared_to.timestamp),
475 sha_str,
476 );
477 out.push_str("| Metric | Previous | Current | Delta | Direction |\n");
478 out.push_str("|:-------|:---------|:--------|:------|:----------|\n");
479 for m in &trend.metrics {
480 let fmt_val = |v: f64| -> String {
481 if m.unit == "%" {
482 format!("{v:.1}%")
483 } else if (v - v.round()).abs() < 0.05 {
484 format!("{v:.0}")
485 } else {
486 format!("{v:.1}")
487 }
488 };
489 let prev = fmt_val(m.previous);
490 let cur = fmt_val(m.current);
491 let delta = if m.unit == "%" {
492 format!("{:+.1}%", m.delta)
493 } else if (m.delta - m.delta.round()).abs() < 0.05 {
494 format!("{:+.0}", m.delta)
495 } else {
496 format!("{:+.1}", m.delta)
497 };
498 let _ = writeln!(
499 out,
500 "| {} | {} | {} | {} | {} {} |",
501 m.label,
502 prev,
503 cur,
504 delta,
505 m.direction.arrow(),
506 m.direction.label(),
507 );
508 }
509 let md_sha = trend
510 .compared_to
511 .git_sha
512 .as_deref()
513 .map_or(String::new(), |sha| format!(" ({sha})"));
514 let _ = writeln!(
515 out,
516 "\n*vs {}{} · {} {} available*\n",
517 trend
518 .compared_to
519 .timestamp
520 .get(..10)
521 .unwrap_or(&trend.compared_to.timestamp),
522 md_sha,
523 trend.snapshots_loaded,
524 if trend.snapshots_loaded == 1 {
525 "snapshot"
526 } else {
527 "snapshots"
528 },
529 );
530}
531
532fn write_vital_signs_section(out: &mut String, report: &crate::health_types::HealthReport) {
534 let Some(ref vs) = report.vital_signs else {
535 return;
536 };
537 out.push_str("## Vital Signs\n\n");
538 out.push_str("| Metric | Value |\n");
539 out.push_str("|:-------|------:|\n");
540 let _ = writeln!(out, "| Avg Cyclomatic | {:.1} |", vs.avg_cyclomatic);
541 let _ = writeln!(out, "| P90 Cyclomatic | {} |", vs.p90_cyclomatic);
542 if let Some(v) = vs.dead_file_pct {
543 let _ = writeln!(out, "| Dead Files | {v:.1}% |");
544 }
545 if let Some(v) = vs.dead_export_pct {
546 let _ = writeln!(out, "| Dead Exports | {v:.1}% |");
547 }
548 if let Some(v) = vs.maintainability_avg {
549 let _ = writeln!(out, "| Maintainability (avg) | {v:.1} |");
550 }
551 if let Some(v) = vs.hotspot_count {
552 let _ = writeln!(out, "| Hotspots | {v} |");
553 }
554 if let Some(v) = vs.circular_dep_count {
555 let _ = writeln!(out, "| Circular Deps | {v} |");
556 }
557 if let Some(v) = vs.unused_dep_count {
558 let _ = writeln!(out, "| Unused Deps | {v} |");
559 }
560 out.push('\n');
561}
562
563fn write_findings_section(
565 out: &mut String,
566 report: &crate::health_types::HealthReport,
567 root: &Path,
568) {
569 if report.findings.is_empty() {
570 return;
571 }
572
573 let rel = |p: &Path| {
574 escape_backticks(&normalize_uri(
575 &relative_path(p, root).display().to_string(),
576 ))
577 };
578
579 let count = report.summary.functions_above_threshold;
580 let shown = report.findings.len();
581 if shown < count {
582 let _ = write!(
583 out,
584 "## Fallow: {count} high complexity function{} ({shown} shown)\n\n",
585 plural(count),
586 );
587 } else {
588 let _ = write!(
589 out,
590 "## Fallow: {count} high complexity function{}\n\n",
591 plural(count),
592 );
593 }
594
595 out.push_str("| File | Function | Cyclomatic | Cognitive | Lines |\n");
596 out.push_str("|:-----|:---------|:-----------|:----------|:------|\n");
597
598 for finding in &report.findings {
599 let file_str = rel(&finding.path);
600 let cyc_marker = if finding.cyclomatic > report.summary.max_cyclomatic_threshold {
601 " **!**"
602 } else {
603 ""
604 };
605 let cog_marker = if finding.cognitive > report.summary.max_cognitive_threshold {
606 " **!**"
607 } else {
608 ""
609 };
610 let _ = writeln!(
611 out,
612 "| `{file_str}:{line}` | `{name}` | {cyc}{cyc_marker} | {cog}{cog_marker} | {lines} |",
613 line = finding.line,
614 name = escape_backticks(&finding.name),
615 cyc = finding.cyclomatic,
616 cog = finding.cognitive,
617 lines = finding.line_count,
618 );
619 }
620
621 let s = &report.summary;
622 let _ = write!(
623 out,
624 "\n**{files}** files, **{funcs}** functions analyzed \
625 (thresholds: cyclomatic > {cyc}, cognitive > {cog})\n",
626 files = s.files_analyzed,
627 funcs = s.functions_analyzed,
628 cyc = s.max_cyclomatic_threshold,
629 cog = s.max_cognitive_threshold,
630 );
631}
632
633fn write_file_scores_section(
635 out: &mut String,
636 report: &crate::health_types::HealthReport,
637 root: &Path,
638) {
639 if report.file_scores.is_empty() {
640 return;
641 }
642
643 let rel = |p: &Path| {
644 escape_backticks(&normalize_uri(
645 &relative_path(p, root).display().to_string(),
646 ))
647 };
648
649 out.push('\n');
650 let _ = writeln!(
651 out,
652 "### File Health Scores ({} files)\n",
653 report.file_scores.len(),
654 );
655 out.push_str("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density | Risk |\n");
656 out.push_str("|:-----|:---------------|:-------|:--------|:----------|:--------|:-----|\n");
657
658 for score in &report.file_scores {
659 let file_str = rel(&score.path);
660 let _ = writeln!(
661 out,
662 "| `{file_str}` | {mi:.1} | {fi} | {fan_out} | {dead:.0}% | {density:.2} | {crap:.1} |",
663 mi = score.maintainability_index,
664 fi = score.fan_in,
665 fan_out = score.fan_out,
666 dead = score.dead_code_ratio * 100.0,
667 density = score.complexity_density,
668 crap = score.crap_max,
669 );
670 }
671
672 if let Some(avg) = report.summary.average_maintainability {
673 let _ = write!(out, "\n**Average maintainability index:** {avg:.1}/100\n");
674 }
675}
676
677fn write_coverage_gaps_section(
678 out: &mut String,
679 report: &crate::health_types::HealthReport,
680 root: &Path,
681) {
682 let Some(ref gaps) = report.coverage_gaps else {
683 return;
684 };
685
686 out.push('\n');
687 let _ = writeln!(out, "### Coverage Gaps\n");
688 let _ = writeln!(
689 out,
690 "*{} untested files · {} untested exports · {:.1}% file coverage*\n",
691 gaps.summary.untested_files, gaps.summary.untested_exports, gaps.summary.file_coverage_pct,
692 );
693
694 if gaps.files.is_empty() && gaps.exports.is_empty() {
695 out.push_str("_No coverage gaps found in scope._\n");
696 return;
697 }
698
699 if !gaps.files.is_empty() {
700 out.push_str("#### Files\n");
701 for item in &gaps.files {
702 let file_str = escape_backticks(&normalize_uri(
703 &relative_path(&item.path, root).display().to_string(),
704 ));
705 let _ = writeln!(
706 out,
707 "- `{file_str}` ({count} value export{})",
708 if item.value_export_count == 1 {
709 ""
710 } else {
711 "s"
712 },
713 count = item.value_export_count,
714 );
715 }
716 out.push('\n');
717 }
718
719 if !gaps.exports.is_empty() {
720 out.push_str("#### Exports\n");
721 for item in &gaps.exports {
722 let file_str = escape_backticks(&normalize_uri(
723 &relative_path(&item.path, root).display().to_string(),
724 ));
725 let _ = writeln!(out, "- `{file_str}`:{} `{}`", item.line, item.export_name);
726 }
727 }
728}
729
730fn write_hotspots_section(
732 out: &mut String,
733 report: &crate::health_types::HealthReport,
734 root: &Path,
735) {
736 if report.hotspots.is_empty() {
737 return;
738 }
739
740 let rel = |p: &Path| {
741 escape_backticks(&normalize_uri(
742 &relative_path(p, root).display().to_string(),
743 ))
744 };
745
746 out.push('\n');
747 let header = report.hotspot_summary.as_ref().map_or_else(
748 || format!("### Hotspots ({} files)\n", report.hotspots.len()),
749 |summary| {
750 format!(
751 "### Hotspots ({} files, since {})\n",
752 report.hotspots.len(),
753 summary.since,
754 )
755 },
756 );
757 let _ = writeln!(out, "{header}");
758 out.push_str("| File | Score | Commits | Churn | Density | Fan-in | Trend |\n");
759 out.push_str("|:-----|:------|:--------|:------|:--------|:-------|:------|\n");
760
761 for entry in &report.hotspots {
762 let file_str = rel(&entry.path);
763 let _ = writeln!(
764 out,
765 "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} |",
766 score = entry.score,
767 commits = entry.commits,
768 churn = entry.lines_added + entry.lines_deleted,
769 density = entry.complexity_density,
770 fi = entry.fan_in,
771 trend = entry.trend,
772 );
773 }
774
775 if let Some(ref summary) = report.hotspot_summary
776 && summary.files_excluded > 0
777 {
778 let _ = write!(
779 out,
780 "\n*{} file{} excluded (< {} commits)*\n",
781 summary.files_excluded,
782 plural(summary.files_excluded),
783 summary.min_commits,
784 );
785 }
786}
787
788fn write_targets_section(
790 out: &mut String,
791 report: &crate::health_types::HealthReport,
792 root: &Path,
793) {
794 if report.targets.is_empty() {
795 return;
796 }
797 let _ = write!(
798 out,
799 "\n### Refactoring Targets ({})\n\n",
800 report.targets.len()
801 );
802 out.push_str("| Efficiency | Category | Effort / Confidence | File | Recommendation |\n");
803 out.push_str("|:-----------|:---------|:--------------------|:-----|:---------------|\n");
804 for target in &report.targets {
805 let file_str = normalize_uri(&relative_path(&target.path, root).display().to_string());
806 let category = target.category.label();
807 let effort = target.effort.label();
808 let confidence = target.confidence.label();
809 let _ = writeln!(
810 out,
811 "| {:.1} | {category} | {effort} / {confidence} | `{file_str}` | {} |",
812 target.efficiency, target.recommendation,
813 );
814 }
815}
816
817fn write_metric_legend(out: &mut String, report: &crate::health_types::HealthReport) {
819 let has_scores = !report.file_scores.is_empty();
820 let has_coverage = report.coverage_gaps.is_some();
821 let has_hotspots = !report.hotspots.is_empty();
822 let has_targets = !report.targets.is_empty();
823 if !has_scores && !has_coverage && !has_hotspots && !has_targets {
824 return;
825 }
826 out.push_str("\n---\n\n<details><summary>Metric definitions</summary>\n\n");
827 if has_scores {
828 out.push_str("- **MI** — Maintainability Index (0\u{2013}100, higher is better)\n");
829 out.push_str("- **Fan-in** — files that import this file (blast radius)\n");
830 out.push_str("- **Fan-out** — files this file imports (coupling)\n");
831 out.push_str("- **Dead Code** — % of value exports with zero references\n");
832 out.push_str("- **Density** — cyclomatic complexity / lines of code\n");
833 }
834 if has_coverage {
835 out.push_str(
836 "- **File coverage** — runtime files also reachable from a discovered test root\n",
837 );
838 out.push_str("- **Untested export** — export with no reference chain from any test-reachable module\n");
839 }
840 if has_hotspots {
841 out.push_str("- **Score** — churn \u{00d7} complexity (0\u{2013}100, higher = riskier)\n");
842 out.push_str("- **Commits** — commits in the analysis window\n");
843 out.push_str("- **Churn** — total lines added + deleted\n");
844 out.push_str("- **Trend** — accelerating / stable / cooling\n");
845 }
846 if has_targets {
847 out.push_str("- **Efficiency** — priority / effort (higher = better quick-win value, default sort)\n");
848 out.push_str("- **Category** — recommendation type (churn+complexity, high impact, dead code, complexity, coupling, circular dep)\n");
849 out.push_str("- **Effort** — estimated effort (low / medium / high) based on file size, function count, and fan-in\n");
850 out.push_str("- **Confidence** — recommendation reliability (high = deterministic analysis, medium = heuristic, low = git-dependent)\n");
851 }
852 out.push_str(
853 "\n[Full metric reference](https://docs.fallow.tools/explanations/metrics)\n\n</details>\n",
854 );
855}
856
857#[cfg(test)]
858mod tests {
859 use super::*;
860 use crate::report::test_helpers::sample_results;
861 use fallow_core::duplicates::{
862 CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
863 RefactoringKind, RefactoringSuggestion,
864 };
865 use fallow_core::results::*;
866 use std::path::PathBuf;
867
868 #[test]
869 fn markdown_empty_results_no_issues() {
870 let root = PathBuf::from("/project");
871 let results = AnalysisResults::default();
872 let md = build_markdown(&results, &root);
873 assert_eq!(md, "## Fallow: no issues found\n");
874 }
875
876 #[test]
877 fn markdown_contains_header_with_count() {
878 let root = PathBuf::from("/project");
879 let results = sample_results(&root);
880 let md = build_markdown(&results, &root);
881 assert!(md.starts_with(&format!(
882 "## Fallow: {} issues found\n",
883 results.total_issues()
884 )));
885 }
886
887 #[test]
888 fn markdown_contains_all_sections() {
889 let root = PathBuf::from("/project");
890 let results = sample_results(&root);
891 let md = build_markdown(&results, &root);
892
893 assert!(md.contains("### Unused files (1)"));
894 assert!(md.contains("### Unused exports (1)"));
895 assert!(md.contains("### Unused type exports (1)"));
896 assert!(md.contains("### Unused dependencies (1)"));
897 assert!(md.contains("### Unused devDependencies (1)"));
898 assert!(md.contains("### Unused enum members (1)"));
899 assert!(md.contains("### Unused class members (1)"));
900 assert!(md.contains("### Unresolved imports (1)"));
901 assert!(md.contains("### Unlisted dependencies (1)"));
902 assert!(md.contains("### Duplicate exports (1)"));
903 assert!(md.contains("### Type-only dependencies"));
904 assert!(md.contains("### Test-only production dependencies"));
905 assert!(md.contains("### Circular dependencies (1)"));
906 }
907
908 #[test]
909 fn markdown_unused_file_format() {
910 let root = PathBuf::from("/project");
911 let mut results = AnalysisResults::default();
912 results.unused_files.push(UnusedFile {
913 path: root.join("src/dead.ts"),
914 });
915 let md = build_markdown(&results, &root);
916 assert!(md.contains("- `src/dead.ts`"));
917 }
918
919 #[test]
920 fn markdown_unused_export_grouped_by_file() {
921 let root = PathBuf::from("/project");
922 let mut results = AnalysisResults::default();
923 results.unused_exports.push(UnusedExport {
924 path: root.join("src/utils.ts"),
925 export_name: "helperFn".to_string(),
926 is_type_only: false,
927 line: 10,
928 col: 4,
929 span_start: 120,
930 is_re_export: false,
931 });
932 let md = build_markdown(&results, &root);
933 assert!(md.contains("- `src/utils.ts`"));
934 assert!(md.contains(":10 `helperFn`"));
935 }
936
937 #[test]
938 fn markdown_re_export_tagged() {
939 let root = PathBuf::from("/project");
940 let mut results = AnalysisResults::default();
941 results.unused_exports.push(UnusedExport {
942 path: root.join("src/index.ts"),
943 export_name: "reExported".to_string(),
944 is_type_only: false,
945 line: 1,
946 col: 0,
947 span_start: 0,
948 is_re_export: true,
949 });
950 let md = build_markdown(&results, &root);
951 assert!(md.contains("(re-export)"));
952 }
953
954 #[test]
955 fn markdown_unused_dep_format() {
956 let root = PathBuf::from("/project");
957 let mut results = AnalysisResults::default();
958 results.unused_dependencies.push(UnusedDependency {
959 package_name: "lodash".to_string(),
960 location: DependencyLocation::Dependencies,
961 path: root.join("package.json"),
962 line: 5,
963 });
964 let md = build_markdown(&results, &root);
965 assert!(md.contains("- `lodash`"));
966 }
967
968 #[test]
969 fn markdown_circular_dep_format() {
970 let root = PathBuf::from("/project");
971 let mut results = AnalysisResults::default();
972 results.circular_dependencies.push(CircularDependency {
973 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
974 length: 2,
975 line: 3,
976 col: 0,
977 is_cross_package: false,
978 });
979 let md = build_markdown(&results, &root);
980 assert!(md.contains("`src/a.ts`"));
981 assert!(md.contains("`src/b.ts`"));
982 assert!(md.contains("\u{2192}"));
983 }
984
985 #[test]
986 fn markdown_strips_root_prefix() {
987 let root = PathBuf::from("/project");
988 let mut results = AnalysisResults::default();
989 results.unused_files.push(UnusedFile {
990 path: PathBuf::from("/project/src/deep/nested/file.ts"),
991 });
992 let md = build_markdown(&results, &root);
993 assert!(md.contains("`src/deep/nested/file.ts`"));
994 assert!(!md.contains("/project/"));
995 }
996
997 #[test]
998 fn markdown_single_issue_no_plural() {
999 let root = PathBuf::from("/project");
1000 let mut results = AnalysisResults::default();
1001 results.unused_files.push(UnusedFile {
1002 path: root.join("src/dead.ts"),
1003 });
1004 let md = build_markdown(&results, &root);
1005 assert!(md.starts_with("## Fallow: 1 issue found\n"));
1006 }
1007
1008 #[test]
1009 fn markdown_type_only_dep_format() {
1010 let root = PathBuf::from("/project");
1011 let mut results = AnalysisResults::default();
1012 results.type_only_dependencies.push(TypeOnlyDependency {
1013 package_name: "zod".to_string(),
1014 path: root.join("package.json"),
1015 line: 8,
1016 });
1017 let md = build_markdown(&results, &root);
1018 assert!(md.contains("### Type-only dependencies"));
1019 assert!(md.contains("- `zod`"));
1020 }
1021
1022 #[test]
1023 fn markdown_escapes_backticks_in_export_names() {
1024 let root = PathBuf::from("/project");
1025 let mut results = AnalysisResults::default();
1026 results.unused_exports.push(UnusedExport {
1027 path: root.join("src/utils.ts"),
1028 export_name: "foo`bar".to_string(),
1029 is_type_only: false,
1030 line: 1,
1031 col: 0,
1032 span_start: 0,
1033 is_re_export: false,
1034 });
1035 let md = build_markdown(&results, &root);
1036 assert!(md.contains("foo\\`bar"));
1037 assert!(!md.contains("foo`bar`"));
1038 }
1039
1040 #[test]
1041 fn markdown_escapes_backticks_in_package_names() {
1042 let root = PathBuf::from("/project");
1043 let mut results = AnalysisResults::default();
1044 results.unused_dependencies.push(UnusedDependency {
1045 package_name: "pkg`name".to_string(),
1046 location: DependencyLocation::Dependencies,
1047 path: root.join("package.json"),
1048 line: 5,
1049 });
1050 let md = build_markdown(&results, &root);
1051 assert!(md.contains("pkg\\`name"));
1052 }
1053
1054 #[test]
1057 fn duplication_markdown_empty() {
1058 let report = DuplicationReport::default();
1059 let root = PathBuf::from("/project");
1060 let md = build_duplication_markdown(&report, &root);
1061 assert_eq!(md, "## Fallow: no code duplication found\n");
1062 }
1063
1064 #[test]
1065 fn duplication_markdown_contains_groups() {
1066 let root = PathBuf::from("/project");
1067 let report = DuplicationReport {
1068 clone_groups: vec![CloneGroup {
1069 instances: vec![
1070 CloneInstance {
1071 file: root.join("src/a.ts"),
1072 start_line: 1,
1073 end_line: 10,
1074 start_col: 0,
1075 end_col: 0,
1076 fragment: String::new(),
1077 },
1078 CloneInstance {
1079 file: root.join("src/b.ts"),
1080 start_line: 5,
1081 end_line: 14,
1082 start_col: 0,
1083 end_col: 0,
1084 fragment: String::new(),
1085 },
1086 ],
1087 token_count: 50,
1088 line_count: 10,
1089 }],
1090 clone_families: vec![],
1091 mirrored_directories: vec![],
1092 stats: DuplicationStats {
1093 total_files: 10,
1094 files_with_clones: 2,
1095 total_lines: 500,
1096 duplicated_lines: 20,
1097 total_tokens: 2500,
1098 duplicated_tokens: 100,
1099 clone_groups: 1,
1100 clone_instances: 2,
1101 duplication_percentage: 4.0,
1102 },
1103 };
1104 let md = build_duplication_markdown(&report, &root);
1105 assert!(md.contains("**Clone group 1**"));
1106 assert!(md.contains("`src/a.ts:1-10`"));
1107 assert!(md.contains("`src/b.ts:5-14`"));
1108 assert!(md.contains("4.0% duplication"));
1109 }
1110
1111 #[test]
1112 fn duplication_markdown_contains_families() {
1113 let root = PathBuf::from("/project");
1114 let report = DuplicationReport {
1115 clone_groups: vec![CloneGroup {
1116 instances: vec![CloneInstance {
1117 file: root.join("src/a.ts"),
1118 start_line: 1,
1119 end_line: 5,
1120 start_col: 0,
1121 end_col: 0,
1122 fragment: String::new(),
1123 }],
1124 token_count: 30,
1125 line_count: 5,
1126 }],
1127 clone_families: vec![CloneFamily {
1128 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1129 groups: vec![],
1130 total_duplicated_lines: 20,
1131 total_duplicated_tokens: 100,
1132 suggestions: vec![RefactoringSuggestion {
1133 kind: RefactoringKind::ExtractFunction,
1134 description: "Extract shared utility function".to_string(),
1135 estimated_savings: 15,
1136 }],
1137 }],
1138 mirrored_directories: vec![],
1139 stats: DuplicationStats {
1140 clone_groups: 1,
1141 clone_instances: 1,
1142 duplication_percentage: 2.0,
1143 ..Default::default()
1144 },
1145 };
1146 let md = build_duplication_markdown(&report, &root);
1147 assert!(md.contains("### Clone Families"));
1148 assert!(md.contains("**Family 1**"));
1149 assert!(md.contains("Extract shared utility function"));
1150 assert!(md.contains("~15 lines saved"));
1151 }
1152
1153 #[test]
1156 fn health_markdown_empty_no_findings() {
1157 let root = PathBuf::from("/project");
1158 let report = crate::health_types::HealthReport {
1159 findings: vec![],
1160 summary: crate::health_types::HealthSummary {
1161 files_analyzed: 10,
1162 functions_analyzed: 50,
1163 functions_above_threshold: 0,
1164 max_cyclomatic_threshold: 20,
1165 max_cognitive_threshold: 15,
1166 files_scored: None,
1167 average_maintainability: None,
1168 coverage_model: None,
1169 istanbul_matched: None,
1170 istanbul_total: None,
1171 },
1172 vital_signs: None,
1173 health_score: None,
1174 file_scores: vec![],
1175 coverage_gaps: None,
1176 hotspots: vec![],
1177 hotspot_summary: None,
1178 large_functions: vec![],
1179 targets: vec![],
1180 target_thresholds: None,
1181 health_trend: None,
1182 };
1183 let md = build_health_markdown(&report, &root);
1184 assert!(md.contains("no functions exceed complexity thresholds"));
1185 assert!(md.contains("**50** functions analyzed"));
1186 }
1187
1188 #[test]
1189 fn health_markdown_table_format() {
1190 let root = PathBuf::from("/project");
1191 let report = crate::health_types::HealthReport {
1192 findings: vec![crate::health_types::HealthFinding {
1193 path: root.join("src/utils.ts"),
1194 name: "parseExpression".to_string(),
1195 line: 42,
1196 col: 0,
1197 cyclomatic: 25,
1198 cognitive: 30,
1199 line_count: 80,
1200 param_count: 0,
1201 exceeded: crate::health_types::ExceededThreshold::Both,
1202 }],
1203 summary: crate::health_types::HealthSummary {
1204 files_analyzed: 10,
1205 functions_analyzed: 50,
1206 functions_above_threshold: 1,
1207 max_cyclomatic_threshold: 20,
1208 max_cognitive_threshold: 15,
1209 files_scored: None,
1210 average_maintainability: None,
1211 coverage_model: None,
1212 istanbul_matched: None,
1213 istanbul_total: None,
1214 },
1215 vital_signs: None,
1216 health_score: None,
1217 file_scores: vec![],
1218 coverage_gaps: None,
1219 hotspots: vec![],
1220 hotspot_summary: None,
1221 large_functions: vec![],
1222 targets: vec![],
1223 target_thresholds: None,
1224 health_trend: None,
1225 };
1226 let md = build_health_markdown(&report, &root);
1227 assert!(md.contains("## Fallow: 1 high complexity function\n"));
1228 assert!(md.contains("| File | Function |"));
1229 assert!(md.contains("`src/utils.ts:42`"));
1230 assert!(md.contains("`parseExpression`"));
1231 assert!(md.contains("25 **!**"));
1232 assert!(md.contains("30 **!**"));
1233 assert!(md.contains("| 80 |"));
1234 }
1235
1236 #[test]
1237 fn health_markdown_no_marker_when_below_threshold() {
1238 let root = PathBuf::from("/project");
1239 let report = crate::health_types::HealthReport {
1240 findings: vec![crate::health_types::HealthFinding {
1241 path: root.join("src/utils.ts"),
1242 name: "helper".to_string(),
1243 line: 10,
1244 col: 0,
1245 cyclomatic: 15,
1246 cognitive: 20,
1247 line_count: 30,
1248 param_count: 0,
1249 exceeded: crate::health_types::ExceededThreshold::Cognitive,
1250 }],
1251 summary: crate::health_types::HealthSummary {
1252 files_analyzed: 5,
1253 functions_analyzed: 20,
1254 functions_above_threshold: 1,
1255 max_cyclomatic_threshold: 20,
1256 max_cognitive_threshold: 15,
1257 files_scored: None,
1258 average_maintainability: None,
1259 coverage_model: None,
1260 istanbul_matched: None,
1261 istanbul_total: None,
1262 },
1263 vital_signs: None,
1264 health_score: None,
1265 file_scores: vec![],
1266 coverage_gaps: None,
1267 hotspots: vec![],
1268 hotspot_summary: None,
1269 large_functions: vec![],
1270 targets: vec![],
1271 target_thresholds: None,
1272 health_trend: None,
1273 };
1274 let md = build_health_markdown(&report, &root);
1275 assert!(md.contains("| 15 |"));
1277 assert!(md.contains("20 **!**"));
1279 }
1280
1281 #[test]
1282 fn health_markdown_with_targets() {
1283 use crate::health_types::*;
1284
1285 let root = PathBuf::from("/project");
1286 let report = HealthReport {
1287 findings: vec![],
1288 summary: HealthSummary {
1289 files_analyzed: 10,
1290 functions_analyzed: 50,
1291 functions_above_threshold: 0,
1292 max_cyclomatic_threshold: 20,
1293 max_cognitive_threshold: 15,
1294 files_scored: None,
1295 average_maintainability: None,
1296 coverage_model: None,
1297 istanbul_matched: None,
1298 istanbul_total: None,
1299 },
1300 vital_signs: None,
1301 health_score: None,
1302 file_scores: vec![],
1303 coverage_gaps: None,
1304 hotspots: vec![],
1305 hotspot_summary: None,
1306 large_functions: vec![],
1307 targets: vec![
1308 RefactoringTarget {
1309 path: PathBuf::from("/project/src/complex.ts"),
1310 priority: 82.5,
1311 efficiency: 27.5,
1312 recommendation: "Split high-impact file".into(),
1313 category: RecommendationCategory::SplitHighImpact,
1314 effort: crate::health_types::EffortEstimate::High,
1315 confidence: crate::health_types::Confidence::Medium,
1316 factors: vec![ContributingFactor {
1317 metric: "fan_in",
1318 value: 25.0,
1319 threshold: 10.0,
1320 detail: "25 files depend on this".into(),
1321 }],
1322 evidence: None,
1323 },
1324 RefactoringTarget {
1325 path: PathBuf::from("/project/src/legacy.ts"),
1326 priority: 45.0,
1327 efficiency: 45.0,
1328 recommendation: "Remove 5 unused exports".into(),
1329 category: RecommendationCategory::RemoveDeadCode,
1330 effort: crate::health_types::EffortEstimate::Low,
1331 confidence: crate::health_types::Confidence::High,
1332 factors: vec![],
1333 evidence: None,
1334 },
1335 ],
1336 target_thresholds: None,
1337 health_trend: None,
1338 };
1339 let md = build_health_markdown(&report, &root);
1340
1341 assert!(
1343 md.contains("Refactoring Targets"),
1344 "should contain targets heading"
1345 );
1346 assert!(
1347 md.contains("src/complex.ts"),
1348 "should contain target file path"
1349 );
1350 assert!(md.contains("27.5"), "should contain efficiency score");
1351 assert!(
1352 md.contains("Split high-impact file"),
1353 "should contain recommendation"
1354 );
1355 assert!(md.contains("src/legacy.ts"), "should contain second target");
1356 }
1357
1358 #[test]
1359 fn health_markdown_with_coverage_gaps() {
1360 use crate::health_types::*;
1361
1362 let root = PathBuf::from("/project");
1363 let report = HealthReport {
1364 findings: vec![],
1365 summary: HealthSummary {
1366 files_analyzed: 10,
1367 functions_analyzed: 50,
1368 functions_above_threshold: 0,
1369 max_cyclomatic_threshold: 20,
1370 max_cognitive_threshold: 15,
1371 files_scored: None,
1372 average_maintainability: None,
1373 coverage_model: None,
1374 istanbul_matched: None,
1375 istanbul_total: None,
1376 },
1377 vital_signs: None,
1378 health_score: None,
1379 file_scores: vec![],
1380 coverage_gaps: Some(CoverageGaps {
1381 summary: CoverageGapSummary {
1382 runtime_files: 2,
1383 covered_files: 0,
1384 file_coverage_pct: 0.0,
1385 untested_files: 1,
1386 untested_exports: 1,
1387 },
1388 files: vec![UntestedFile {
1389 path: root.join("src/app.ts"),
1390 value_export_count: 2,
1391 }],
1392 exports: vec![UntestedExport {
1393 path: root.join("src/app.ts"),
1394 export_name: "loader".into(),
1395 line: 12,
1396 col: 4,
1397 }],
1398 }),
1399 hotspots: vec![],
1400 hotspot_summary: None,
1401 large_functions: vec![],
1402 targets: vec![],
1403 target_thresholds: None,
1404 health_trend: None,
1405 };
1406
1407 let md = build_health_markdown(&report, &root);
1408 assert!(md.contains("### Coverage Gaps"));
1409 assert!(md.contains("*1 untested files"));
1410 assert!(md.contains("`src/app.ts` (2 value exports)"));
1411 assert!(md.contains("`src/app.ts`:12 `loader`"));
1412 }
1413
1414 #[test]
1417 fn markdown_dep_in_workspace_shows_package_label() {
1418 let root = PathBuf::from("/project");
1419 let mut results = AnalysisResults::default();
1420 results.unused_dependencies.push(UnusedDependency {
1421 package_name: "lodash".to_string(),
1422 location: DependencyLocation::Dependencies,
1423 path: root.join("packages/core/package.json"),
1424 line: 5,
1425 });
1426 let md = build_markdown(&results, &root);
1427 assert!(md.contains("(packages/core/package.json)"));
1429 }
1430
1431 #[test]
1432 fn markdown_dep_at_root_no_extra_label() {
1433 let root = PathBuf::from("/project");
1434 let mut results = AnalysisResults::default();
1435 results.unused_dependencies.push(UnusedDependency {
1436 package_name: "lodash".to_string(),
1437 location: DependencyLocation::Dependencies,
1438 path: root.join("package.json"),
1439 line: 5,
1440 });
1441 let md = build_markdown(&results, &root);
1442 assert!(md.contains("- `lodash`"));
1443 assert!(!md.contains("(package.json)"));
1444 }
1445
1446 #[test]
1449 fn markdown_exports_grouped_by_file() {
1450 let root = PathBuf::from("/project");
1451 let mut results = AnalysisResults::default();
1452 results.unused_exports.push(UnusedExport {
1453 path: root.join("src/utils.ts"),
1454 export_name: "alpha".to_string(),
1455 is_type_only: false,
1456 line: 5,
1457 col: 0,
1458 span_start: 0,
1459 is_re_export: false,
1460 });
1461 results.unused_exports.push(UnusedExport {
1462 path: root.join("src/utils.ts"),
1463 export_name: "beta".to_string(),
1464 is_type_only: false,
1465 line: 10,
1466 col: 0,
1467 span_start: 0,
1468 is_re_export: false,
1469 });
1470 results.unused_exports.push(UnusedExport {
1471 path: root.join("src/other.ts"),
1472 export_name: "gamma".to_string(),
1473 is_type_only: false,
1474 line: 1,
1475 col: 0,
1476 span_start: 0,
1477 is_re_export: false,
1478 });
1479 let md = build_markdown(&results, &root);
1480 let utils_count = md.matches("- `src/utils.ts`").count();
1482 assert_eq!(utils_count, 1, "file header should appear once per file");
1483 assert!(md.contains(":5 `alpha`"));
1485 assert!(md.contains(":10 `beta`"));
1486 }
1487
1488 #[test]
1491 fn markdown_multiple_issues_plural() {
1492 let root = PathBuf::from("/project");
1493 let mut results = AnalysisResults::default();
1494 results.unused_files.push(UnusedFile {
1495 path: root.join("src/a.ts"),
1496 });
1497 results.unused_files.push(UnusedFile {
1498 path: root.join("src/b.ts"),
1499 });
1500 let md = build_markdown(&results, &root);
1501 assert!(md.starts_with("## Fallow: 2 issues found\n"));
1502 }
1503
1504 #[test]
1507 fn duplication_markdown_zero_savings_no_suffix() {
1508 let root = PathBuf::from("/project");
1509 let report = DuplicationReport {
1510 clone_groups: vec![CloneGroup {
1511 instances: vec![CloneInstance {
1512 file: root.join("src/a.ts"),
1513 start_line: 1,
1514 end_line: 5,
1515 start_col: 0,
1516 end_col: 0,
1517 fragment: String::new(),
1518 }],
1519 token_count: 30,
1520 line_count: 5,
1521 }],
1522 clone_families: vec![CloneFamily {
1523 files: vec![root.join("src/a.ts")],
1524 groups: vec![],
1525 total_duplicated_lines: 5,
1526 total_duplicated_tokens: 30,
1527 suggestions: vec![RefactoringSuggestion {
1528 kind: RefactoringKind::ExtractFunction,
1529 description: "Extract function".to_string(),
1530 estimated_savings: 0,
1531 }],
1532 }],
1533 mirrored_directories: vec![],
1534 stats: DuplicationStats {
1535 clone_groups: 1,
1536 clone_instances: 1,
1537 duplication_percentage: 1.0,
1538 ..Default::default()
1539 },
1540 };
1541 let md = build_duplication_markdown(&report, &root);
1542 assert!(md.contains("Extract function"));
1543 assert!(!md.contains("lines saved"));
1544 }
1545
1546 #[test]
1549 fn health_markdown_vital_signs_table() {
1550 let root = PathBuf::from("/project");
1551 let report = crate::health_types::HealthReport {
1552 findings: vec![],
1553 summary: crate::health_types::HealthSummary {
1554 files_analyzed: 10,
1555 functions_analyzed: 50,
1556 functions_above_threshold: 0,
1557 max_cyclomatic_threshold: 20,
1558 max_cognitive_threshold: 15,
1559 files_scored: None,
1560 average_maintainability: None,
1561 coverage_model: None,
1562 istanbul_matched: None,
1563 istanbul_total: None,
1564 },
1565 vital_signs: Some(crate::health_types::VitalSigns {
1566 avg_cyclomatic: 3.5,
1567 p90_cyclomatic: 12,
1568 dead_file_pct: Some(5.0),
1569 dead_export_pct: Some(10.2),
1570 duplication_pct: None,
1571 maintainability_avg: Some(72.3),
1572 hotspot_count: Some(3),
1573 circular_dep_count: Some(1),
1574 unused_dep_count: Some(2),
1575 counts: None,
1576 unit_size_profile: None,
1577 unit_interfacing_profile: None,
1578 p95_fan_in: None,
1579 coupling_high_pct: None,
1580 }),
1581 health_score: None,
1582 file_scores: vec![],
1583 coverage_gaps: None,
1584 hotspots: vec![],
1585 hotspot_summary: None,
1586 large_functions: vec![],
1587 targets: vec![],
1588 target_thresholds: None,
1589 health_trend: None,
1590 };
1591 let md = build_health_markdown(&report, &root);
1592 assert!(md.contains("## Vital Signs"));
1593 assert!(md.contains("| Metric | Value |"));
1594 assert!(md.contains("| Avg Cyclomatic | 3.5 |"));
1595 assert!(md.contains("| P90 Cyclomatic | 12 |"));
1596 assert!(md.contains("| Dead Files | 5.0% |"));
1597 assert!(md.contains("| Dead Exports | 10.2% |"));
1598 assert!(md.contains("| Maintainability (avg) | 72.3 |"));
1599 assert!(md.contains("| Hotspots | 3 |"));
1600 assert!(md.contains("| Circular Deps | 1 |"));
1601 assert!(md.contains("| Unused Deps | 2 |"));
1602 }
1603
1604 #[test]
1607 fn health_markdown_file_scores_table() {
1608 let root = PathBuf::from("/project");
1609 let report = crate::health_types::HealthReport {
1610 findings: vec![crate::health_types::HealthFinding {
1611 path: root.join("src/dummy.ts"),
1612 name: "fn".to_string(),
1613 line: 1,
1614 col: 0,
1615 cyclomatic: 25,
1616 cognitive: 20,
1617 line_count: 50,
1618 param_count: 0,
1619 exceeded: crate::health_types::ExceededThreshold::Both,
1620 }],
1621 summary: crate::health_types::HealthSummary {
1622 files_analyzed: 5,
1623 functions_analyzed: 10,
1624 functions_above_threshold: 1,
1625 max_cyclomatic_threshold: 20,
1626 max_cognitive_threshold: 15,
1627 files_scored: Some(1),
1628 average_maintainability: Some(65.0),
1629 coverage_model: None,
1630 istanbul_matched: None,
1631 istanbul_total: None,
1632 },
1633 vital_signs: None,
1634 health_score: None,
1635 file_scores: vec![crate::health_types::FileHealthScore {
1636 path: root.join("src/utils.ts"),
1637 fan_in: 5,
1638 fan_out: 3,
1639 dead_code_ratio: 0.25,
1640 complexity_density: 0.8,
1641 maintainability_index: 72.5,
1642 total_cyclomatic: 40,
1643 total_cognitive: 30,
1644 function_count: 10,
1645 lines: 200,
1646 crap_max: 0.0,
1647 crap_above_threshold: 0,
1648 }],
1649 coverage_gaps: None,
1650 hotspots: vec![],
1651 hotspot_summary: None,
1652 large_functions: vec![],
1653 targets: vec![],
1654 target_thresholds: None,
1655 health_trend: None,
1656 };
1657 let md = build_health_markdown(&report, &root);
1658 assert!(md.contains("### File Health Scores (1 files)"));
1659 assert!(md.contains("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density |"));
1660 assert!(md.contains("| `src/utils.ts` | 72.5 | 5 | 3 | 25% | 0.80 |"));
1661 assert!(md.contains("**Average maintainability index:** 65.0/100"));
1662 }
1663
1664 #[test]
1667 fn health_markdown_hotspots_table() {
1668 let root = PathBuf::from("/project");
1669 let report = crate::health_types::HealthReport {
1670 findings: vec![crate::health_types::HealthFinding {
1671 path: root.join("src/dummy.ts"),
1672 name: "fn".to_string(),
1673 line: 1,
1674 col: 0,
1675 cyclomatic: 25,
1676 cognitive: 20,
1677 line_count: 50,
1678 param_count: 0,
1679 exceeded: crate::health_types::ExceededThreshold::Both,
1680 }],
1681 summary: crate::health_types::HealthSummary {
1682 files_analyzed: 5,
1683 functions_analyzed: 10,
1684 functions_above_threshold: 1,
1685 max_cyclomatic_threshold: 20,
1686 max_cognitive_threshold: 15,
1687 files_scored: None,
1688 average_maintainability: None,
1689 coverage_model: None,
1690 istanbul_matched: None,
1691 istanbul_total: None,
1692 },
1693 vital_signs: None,
1694 health_score: None,
1695 file_scores: vec![],
1696 coverage_gaps: None,
1697 hotspots: vec![crate::health_types::HotspotEntry {
1698 path: root.join("src/hot.ts"),
1699 score: 85.0,
1700 commits: 42,
1701 weighted_commits: 35.0,
1702 lines_added: 500,
1703 lines_deleted: 200,
1704 complexity_density: 1.2,
1705 fan_in: 10,
1706 trend: fallow_core::churn::ChurnTrend::Accelerating,
1707 }],
1708 hotspot_summary: Some(crate::health_types::HotspotSummary {
1709 since: "6 months".to_string(),
1710 min_commits: 3,
1711 files_analyzed: 50,
1712 files_excluded: 5,
1713 shallow_clone: false,
1714 }),
1715 large_functions: vec![],
1716 targets: vec![],
1717 target_thresholds: None,
1718 health_trend: None,
1719 };
1720 let md = build_health_markdown(&report, &root);
1721 assert!(md.contains("### Hotspots (1 files, since 6 months)"));
1722 assert!(md.contains("| `src/hot.ts` | 85.0 | 42 | 700 | 1.20 | 10 | accelerating |"));
1723 assert!(md.contains("*5 files excluded (< 3 commits)*"));
1724 }
1725
1726 #[test]
1729 fn health_markdown_metric_legend_with_scores() {
1730 let root = PathBuf::from("/project");
1731 let report = crate::health_types::HealthReport {
1732 findings: vec![crate::health_types::HealthFinding {
1733 path: root.join("src/x.ts"),
1734 name: "f".to_string(),
1735 line: 1,
1736 col: 0,
1737 cyclomatic: 25,
1738 cognitive: 20,
1739 line_count: 10,
1740 param_count: 0,
1741 exceeded: crate::health_types::ExceededThreshold::Both,
1742 }],
1743 summary: crate::health_types::HealthSummary {
1744 files_analyzed: 1,
1745 functions_analyzed: 1,
1746 functions_above_threshold: 1,
1747 max_cyclomatic_threshold: 20,
1748 max_cognitive_threshold: 15,
1749 files_scored: Some(1),
1750 average_maintainability: Some(70.0),
1751 coverage_model: None,
1752 istanbul_matched: None,
1753 istanbul_total: None,
1754 },
1755 vital_signs: None,
1756 health_score: None,
1757 file_scores: vec![crate::health_types::FileHealthScore {
1758 path: root.join("src/x.ts"),
1759 fan_in: 1,
1760 fan_out: 1,
1761 dead_code_ratio: 0.0,
1762 complexity_density: 0.5,
1763 maintainability_index: 80.0,
1764 total_cyclomatic: 10,
1765 total_cognitive: 8,
1766 function_count: 2,
1767 lines: 50,
1768 crap_max: 0.0,
1769 crap_above_threshold: 0,
1770 }],
1771 coverage_gaps: None,
1772 hotspots: vec![],
1773 hotspot_summary: None,
1774 large_functions: vec![],
1775 targets: vec![],
1776 target_thresholds: None,
1777 health_trend: None,
1778 };
1779 let md = build_health_markdown(&report, &root);
1780 assert!(md.contains("<details><summary>Metric definitions</summary>"));
1781 assert!(md.contains("**MI** \u{2014} Maintainability Index"));
1782 assert!(md.contains("**Fan-in**"));
1783 assert!(md.contains("Full metric reference"));
1784 }
1785
1786 #[test]
1789 fn health_markdown_truncated_findings_shown_count() {
1790 let root = PathBuf::from("/project");
1791 let report = crate::health_types::HealthReport {
1792 findings: vec![crate::health_types::HealthFinding {
1793 path: root.join("src/x.ts"),
1794 name: "f".to_string(),
1795 line: 1,
1796 col: 0,
1797 cyclomatic: 25,
1798 cognitive: 20,
1799 line_count: 10,
1800 param_count: 0,
1801 exceeded: crate::health_types::ExceededThreshold::Both,
1802 }],
1803 summary: crate::health_types::HealthSummary {
1804 files_analyzed: 10,
1805 functions_analyzed: 50,
1806 functions_above_threshold: 5, max_cyclomatic_threshold: 20,
1808 max_cognitive_threshold: 15,
1809 files_scored: None,
1810 average_maintainability: None,
1811 coverage_model: None,
1812 istanbul_matched: None,
1813 istanbul_total: None,
1814 },
1815 vital_signs: None,
1816 health_score: None,
1817 file_scores: vec![],
1818 coverage_gaps: None,
1819 hotspots: vec![],
1820 hotspot_summary: None,
1821 large_functions: vec![],
1822 targets: vec![],
1823 target_thresholds: None,
1824 health_trend: None,
1825 };
1826 let md = build_health_markdown(&report, &root);
1827 assert!(md.contains("5 high complexity functions (1 shown)"));
1828 }
1829
1830 #[test]
1833 fn escape_backticks_handles_multiple() {
1834 assert_eq!(escape_backticks("a`b`c"), "a\\`b\\`c");
1835 }
1836
1837 #[test]
1838 fn escape_backticks_no_backticks_unchanged() {
1839 assert_eq!(escape_backticks("hello"), "hello");
1840 }
1841
1842 #[test]
1845 fn markdown_unresolved_import_grouped_by_file() {
1846 let root = PathBuf::from("/project");
1847 let mut results = AnalysisResults::default();
1848 results.unresolved_imports.push(UnresolvedImport {
1849 path: root.join("src/app.ts"),
1850 specifier: "./missing".to_string(),
1851 line: 3,
1852 col: 0,
1853 specifier_col: 0,
1854 });
1855 let md = build_markdown(&results, &root);
1856 assert!(md.contains("### Unresolved imports (1)"));
1857 assert!(md.contains("- `src/app.ts`"));
1858 assert!(md.contains(":3 `./missing`"));
1859 }
1860
1861 #[test]
1864 fn markdown_unused_optional_dep() {
1865 let root = PathBuf::from("/project");
1866 let mut results = AnalysisResults::default();
1867 results.unused_optional_dependencies.push(UnusedDependency {
1868 package_name: "fsevents".to_string(),
1869 location: DependencyLocation::OptionalDependencies,
1870 path: root.join("package.json"),
1871 line: 12,
1872 });
1873 let md = build_markdown(&results, &root);
1874 assert!(md.contains("### Unused optionalDependencies (1)"));
1875 assert!(md.contains("- `fsevents`"));
1876 }
1877
1878 #[test]
1881 fn health_markdown_hotspots_no_excluded_message() {
1882 let root = PathBuf::from("/project");
1883 let report = crate::health_types::HealthReport {
1884 findings: vec![crate::health_types::HealthFinding {
1885 path: root.join("src/x.ts"),
1886 name: "f".to_string(),
1887 line: 1,
1888 col: 0,
1889 cyclomatic: 25,
1890 cognitive: 20,
1891 line_count: 10,
1892 param_count: 0,
1893 exceeded: crate::health_types::ExceededThreshold::Both,
1894 }],
1895 summary: crate::health_types::HealthSummary {
1896 files_analyzed: 5,
1897 functions_analyzed: 10,
1898 functions_above_threshold: 1,
1899 max_cyclomatic_threshold: 20,
1900 max_cognitive_threshold: 15,
1901 files_scored: None,
1902 average_maintainability: None,
1903 coverage_model: None,
1904 istanbul_matched: None,
1905 istanbul_total: None,
1906 },
1907 vital_signs: None,
1908 health_score: None,
1909 file_scores: vec![],
1910 coverage_gaps: None,
1911 hotspots: vec![crate::health_types::HotspotEntry {
1912 path: root.join("src/hot.ts"),
1913 score: 50.0,
1914 commits: 10,
1915 weighted_commits: 8.0,
1916 lines_added: 100,
1917 lines_deleted: 50,
1918 complexity_density: 0.5,
1919 fan_in: 3,
1920 trend: fallow_core::churn::ChurnTrend::Stable,
1921 }],
1922 hotspot_summary: Some(crate::health_types::HotspotSummary {
1923 since: "6 months".to_string(),
1924 min_commits: 3,
1925 files_analyzed: 50,
1926 files_excluded: 0,
1927 shallow_clone: false,
1928 }),
1929 large_functions: vec![],
1930 targets: vec![],
1931 target_thresholds: None,
1932 health_trend: None,
1933 };
1934 let md = build_health_markdown(&report, &root);
1935 assert!(!md.contains("files excluded"));
1936 }
1937
1938 #[test]
1941 fn duplication_markdown_single_group_no_plural() {
1942 let root = PathBuf::from("/project");
1943 let report = DuplicationReport {
1944 clone_groups: vec![CloneGroup {
1945 instances: vec![CloneInstance {
1946 file: root.join("src/a.ts"),
1947 start_line: 1,
1948 end_line: 5,
1949 start_col: 0,
1950 end_col: 0,
1951 fragment: String::new(),
1952 }],
1953 token_count: 30,
1954 line_count: 5,
1955 }],
1956 clone_families: vec![],
1957 mirrored_directories: vec![],
1958 stats: DuplicationStats {
1959 clone_groups: 1,
1960 clone_instances: 1,
1961 duplication_percentage: 2.0,
1962 ..Default::default()
1963 },
1964 };
1965 let md = build_duplication_markdown(&report, &root);
1966 assert!(md.contains("1 clone group found"));
1967 assert!(!md.contains("1 clone groups found"));
1968 }
1969}