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