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