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