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