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