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