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