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(
1087 "- **Efficiency**: priority / effort (higher = better quick-win value, default sort)\n",
1088 );
1089 out.push_str("- **Category**: recommendation type (churn+complexity, high impact, dead code, complexity, coupling, circular dep)\n");
1090 out.push_str("- **Effort**: estimated effort (low / medium / high) based on file size, function count, and fan-in\n");
1091 out.push_str("- **Confidence**: recommendation reliability (high = deterministic analysis, medium = heuristic, low = git-dependent)\n");
1092 }
1093 out.push_str(
1094 "\n[Full metric reference](https://docs.fallow.tools/explanations/metrics)\n\n</details>\n",
1095 );
1096}
1097
1098#[cfg(test)]
1099mod tests {
1100 use super::*;
1101 use crate::report::test_helpers::sample_results;
1102 use fallow_core::duplicates::{
1103 CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
1104 RefactoringKind, RefactoringSuggestion,
1105 };
1106 use fallow_core::results::*;
1107 use std::path::PathBuf;
1108
1109 #[test]
1110 fn markdown_empty_results_no_issues() {
1111 let root = PathBuf::from("/project");
1112 let results = AnalysisResults::default();
1113 let md = build_markdown(&results, &root);
1114 assert_eq!(md, "## Fallow: no issues found\n");
1115 }
1116
1117 #[test]
1118 fn markdown_contains_header_with_count() {
1119 let root = PathBuf::from("/project");
1120 let results = sample_results(&root);
1121 let md = build_markdown(&results, &root);
1122 assert!(md.starts_with(&format!(
1123 "## Fallow: {} issues found\n",
1124 results.total_issues()
1125 )));
1126 }
1127
1128 #[test]
1129 fn markdown_contains_all_sections() {
1130 let root = PathBuf::from("/project");
1131 let results = sample_results(&root);
1132 let md = build_markdown(&results, &root);
1133
1134 assert!(md.contains("### Unused files (1)"));
1135 assert!(md.contains("### Unused exports (1)"));
1136 assert!(md.contains("### Unused type exports (1)"));
1137 assert!(md.contains("### Unused dependencies (1)"));
1138 assert!(md.contains("### Unused devDependencies (1)"));
1139 assert!(md.contains("### Unused enum members (1)"));
1140 assert!(md.contains("### Unused class members (1)"));
1141 assert!(md.contains("### Unresolved imports (1)"));
1142 assert!(md.contains("### Unlisted dependencies (1)"));
1143 assert!(md.contains("### Duplicate exports (1)"));
1144 assert!(md.contains("### Type-only dependencies"));
1145 assert!(md.contains("### Test-only production dependencies"));
1146 assert!(md.contains("### Circular dependencies (1)"));
1147 }
1148
1149 #[test]
1150 fn markdown_unused_file_format() {
1151 let root = PathBuf::from("/project");
1152 let mut results = AnalysisResults::default();
1153 results.unused_files.push(UnusedFile {
1154 path: root.join("src/dead.ts"),
1155 });
1156 let md = build_markdown(&results, &root);
1157 assert!(md.contains("- `src/dead.ts`"));
1158 }
1159
1160 #[test]
1161 fn markdown_unused_export_grouped_by_file() {
1162 let root = PathBuf::from("/project");
1163 let mut results = AnalysisResults::default();
1164 results.unused_exports.push(UnusedExport {
1165 path: root.join("src/utils.ts"),
1166 export_name: "helperFn".to_string(),
1167 is_type_only: false,
1168 line: 10,
1169 col: 4,
1170 span_start: 120,
1171 is_re_export: false,
1172 });
1173 let md = build_markdown(&results, &root);
1174 assert!(md.contains("- `src/utils.ts`"));
1175 assert!(md.contains(":10 `helperFn`"));
1176 }
1177
1178 #[test]
1179 fn markdown_re_export_tagged() {
1180 let root = PathBuf::from("/project");
1181 let mut results = AnalysisResults::default();
1182 results.unused_exports.push(UnusedExport {
1183 path: root.join("src/index.ts"),
1184 export_name: "reExported".to_string(),
1185 is_type_only: false,
1186 line: 1,
1187 col: 0,
1188 span_start: 0,
1189 is_re_export: true,
1190 });
1191 let md = build_markdown(&results, &root);
1192 assert!(md.contains("(re-export)"));
1193 }
1194
1195 #[test]
1196 fn markdown_unused_dep_format() {
1197 let root = PathBuf::from("/project");
1198 let mut results = AnalysisResults::default();
1199 results.unused_dependencies.push(UnusedDependency {
1200 package_name: "lodash".to_string(),
1201 location: DependencyLocation::Dependencies,
1202 path: root.join("package.json"),
1203 line: 5,
1204 used_in_workspaces: Vec::new(),
1205 });
1206 let md = build_markdown(&results, &root);
1207 assert!(md.contains("- `lodash`"));
1208 }
1209
1210 #[test]
1211 fn markdown_circular_dep_format() {
1212 let root = PathBuf::from("/project");
1213 let mut results = AnalysisResults::default();
1214 results.circular_dependencies.push(CircularDependency {
1215 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1216 length: 2,
1217 line: 3,
1218 col: 0,
1219 is_cross_package: false,
1220 });
1221 let md = build_markdown(&results, &root);
1222 assert!(md.contains("`src/a.ts`"));
1223 assert!(md.contains("`src/b.ts`"));
1224 assert!(md.contains("\u{2192}"));
1225 }
1226
1227 #[test]
1228 fn markdown_strips_root_prefix() {
1229 let root = PathBuf::from("/project");
1230 let mut results = AnalysisResults::default();
1231 results.unused_files.push(UnusedFile {
1232 path: PathBuf::from("/project/src/deep/nested/file.ts"),
1233 });
1234 let md = build_markdown(&results, &root);
1235 assert!(md.contains("`src/deep/nested/file.ts`"));
1236 assert!(!md.contains("/project/"));
1237 }
1238
1239 #[test]
1240 fn markdown_single_issue_no_plural() {
1241 let root = PathBuf::from("/project");
1242 let mut results = AnalysisResults::default();
1243 results.unused_files.push(UnusedFile {
1244 path: root.join("src/dead.ts"),
1245 });
1246 let md = build_markdown(&results, &root);
1247 assert!(md.starts_with("## Fallow: 1 issue found\n"));
1248 }
1249
1250 #[test]
1251 fn markdown_type_only_dep_format() {
1252 let root = PathBuf::from("/project");
1253 let mut results = AnalysisResults::default();
1254 results.type_only_dependencies.push(TypeOnlyDependency {
1255 package_name: "zod".to_string(),
1256 path: root.join("package.json"),
1257 line: 8,
1258 });
1259 let md = build_markdown(&results, &root);
1260 assert!(md.contains("### Type-only dependencies"));
1261 assert!(md.contains("- `zod`"));
1262 }
1263
1264 #[test]
1265 fn markdown_escapes_backticks_in_export_names() {
1266 let root = PathBuf::from("/project");
1267 let mut results = AnalysisResults::default();
1268 results.unused_exports.push(UnusedExport {
1269 path: root.join("src/utils.ts"),
1270 export_name: "foo`bar".to_string(),
1271 is_type_only: false,
1272 line: 1,
1273 col: 0,
1274 span_start: 0,
1275 is_re_export: false,
1276 });
1277 let md = build_markdown(&results, &root);
1278 assert!(md.contains("foo\\`bar"));
1279 assert!(!md.contains("foo`bar`"));
1280 }
1281
1282 #[test]
1283 fn markdown_escapes_backticks_in_package_names() {
1284 let root = PathBuf::from("/project");
1285 let mut results = AnalysisResults::default();
1286 results.unused_dependencies.push(UnusedDependency {
1287 package_name: "pkg`name".to_string(),
1288 location: DependencyLocation::Dependencies,
1289 path: root.join("package.json"),
1290 line: 5,
1291 used_in_workspaces: Vec::new(),
1292 });
1293 let md = build_markdown(&results, &root);
1294 assert!(md.contains("pkg\\`name"));
1295 }
1296
1297 #[test]
1300 fn duplication_markdown_empty() {
1301 let report = DuplicationReport::default();
1302 let root = PathBuf::from("/project");
1303 let md = build_duplication_markdown(&report, &root);
1304 assert_eq!(md, "## Fallow: no code duplication found\n");
1305 }
1306
1307 #[test]
1308 fn duplication_markdown_contains_groups() {
1309 let root = PathBuf::from("/project");
1310 let report = DuplicationReport {
1311 clone_groups: vec![CloneGroup {
1312 instances: vec![
1313 CloneInstance {
1314 file: root.join("src/a.ts"),
1315 start_line: 1,
1316 end_line: 10,
1317 start_col: 0,
1318 end_col: 0,
1319 fragment: String::new(),
1320 },
1321 CloneInstance {
1322 file: root.join("src/b.ts"),
1323 start_line: 5,
1324 end_line: 14,
1325 start_col: 0,
1326 end_col: 0,
1327 fragment: String::new(),
1328 },
1329 ],
1330 token_count: 50,
1331 line_count: 10,
1332 }],
1333 clone_families: vec![],
1334 mirrored_directories: vec![],
1335 stats: DuplicationStats {
1336 total_files: 10,
1337 files_with_clones: 2,
1338 total_lines: 500,
1339 duplicated_lines: 20,
1340 total_tokens: 2500,
1341 duplicated_tokens: 100,
1342 clone_groups: 1,
1343 clone_instances: 2,
1344 duplication_percentage: 4.0,
1345 },
1346 };
1347 let md = build_duplication_markdown(&report, &root);
1348 assert!(md.contains("**Clone group 1**"));
1349 assert!(md.contains("`src/a.ts:1-10`"));
1350 assert!(md.contains("`src/b.ts:5-14`"));
1351 assert!(md.contains("4.0% duplication"));
1352 }
1353
1354 #[test]
1355 fn duplication_markdown_contains_families() {
1356 let root = PathBuf::from("/project");
1357 let report = DuplicationReport {
1358 clone_groups: vec![CloneGroup {
1359 instances: vec![CloneInstance {
1360 file: root.join("src/a.ts"),
1361 start_line: 1,
1362 end_line: 5,
1363 start_col: 0,
1364 end_col: 0,
1365 fragment: String::new(),
1366 }],
1367 token_count: 30,
1368 line_count: 5,
1369 }],
1370 clone_families: vec![CloneFamily {
1371 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1372 groups: vec![],
1373 total_duplicated_lines: 20,
1374 total_duplicated_tokens: 100,
1375 suggestions: vec![RefactoringSuggestion {
1376 kind: RefactoringKind::ExtractFunction,
1377 description: "Extract shared utility function".to_string(),
1378 estimated_savings: 15,
1379 }],
1380 }],
1381 mirrored_directories: vec![],
1382 stats: DuplicationStats {
1383 clone_groups: 1,
1384 clone_instances: 1,
1385 duplication_percentage: 2.0,
1386 ..Default::default()
1387 },
1388 };
1389 let md = build_duplication_markdown(&report, &root);
1390 assert!(md.contains("### Clone Families"));
1391 assert!(md.contains("**Family 1**"));
1392 assert!(md.contains("Extract shared utility function"));
1393 assert!(md.contains("~15 lines saved"));
1394 }
1395
1396 #[test]
1399 fn health_markdown_empty_no_findings() {
1400 let root = PathBuf::from("/project");
1401 let report = crate::health_types::HealthReport {
1402 summary: crate::health_types::HealthSummary {
1403 files_analyzed: 10,
1404 functions_analyzed: 50,
1405 ..Default::default()
1406 },
1407 ..Default::default()
1408 };
1409 let md = build_health_markdown(&report, &root);
1410 assert!(md.contains("no functions exceed complexity thresholds"));
1411 assert!(md.contains("**50** functions analyzed"));
1412 }
1413
1414 #[test]
1415 fn health_markdown_table_format() {
1416 let root = PathBuf::from("/project");
1417 let report = crate::health_types::HealthReport {
1418 findings: vec![crate::health_types::HealthFinding {
1419 path: root.join("src/utils.ts"),
1420 name: "parseExpression".to_string(),
1421 line: 42,
1422 col: 0,
1423 cyclomatic: 25,
1424 cognitive: 30,
1425 line_count: 80,
1426 param_count: 0,
1427 exceeded: crate::health_types::ExceededThreshold::Both,
1428 severity: crate::health_types::FindingSeverity::High,
1429 crap: None,
1430 coverage_pct: None,
1431 coverage_tier: None,
1432 }],
1433 summary: crate::health_types::HealthSummary {
1434 files_analyzed: 10,
1435 functions_analyzed: 50,
1436 functions_above_threshold: 1,
1437 ..Default::default()
1438 },
1439 ..Default::default()
1440 };
1441 let md = build_health_markdown(&report, &root);
1442 assert!(md.contains("## Fallow: 1 high complexity function\n"));
1443 assert!(md.contains("| File | Function |"));
1444 assert!(md.contains("`src/utils.ts:42`"));
1445 assert!(md.contains("`parseExpression`"));
1446 assert!(md.contains("25 **!**"));
1447 assert!(md.contains("30 **!**"));
1448 assert!(md.contains("| 80 |"));
1449 assert!(md.contains("| - |"));
1451 }
1452
1453 #[test]
1454 fn health_markdown_crap_column_shows_score_and_marker() {
1455 let root = PathBuf::from("/project");
1456 let report = crate::health_types::HealthReport {
1457 findings: vec![crate::health_types::HealthFinding {
1458 path: root.join("src/risky.ts"),
1459 name: "branchy".to_string(),
1460 line: 1,
1461 col: 0,
1462 cyclomatic: 67,
1463 cognitive: 10,
1464 line_count: 80,
1465 param_count: 1,
1466 exceeded: crate::health_types::ExceededThreshold::CyclomaticCrap,
1467 severity: crate::health_types::FindingSeverity::Critical,
1468 crap: Some(182.0),
1469 coverage_pct: None,
1470 coverage_tier: None,
1471 }],
1472 summary: crate::health_types::HealthSummary {
1473 files_analyzed: 1,
1474 functions_analyzed: 1,
1475 functions_above_threshold: 1,
1476 ..Default::default()
1477 },
1478 ..Default::default()
1479 };
1480 let md = build_health_markdown(&report, &root);
1481 assert!(
1482 md.contains("| CRAP |"),
1483 "markdown table should have CRAP column header: {md}"
1484 );
1485 assert!(
1486 md.contains("182.0 **!**"),
1487 "CRAP value should be rendered with a threshold marker: {md}"
1488 );
1489 assert!(
1490 md.contains("CRAP >="),
1491 "trailing summary line should reference the CRAP threshold: {md}"
1492 );
1493 }
1494
1495 #[test]
1496 fn health_markdown_no_marker_when_below_threshold() {
1497 let root = PathBuf::from("/project");
1498 let report = crate::health_types::HealthReport {
1499 findings: vec![crate::health_types::HealthFinding {
1500 path: root.join("src/utils.ts"),
1501 name: "helper".to_string(),
1502 line: 10,
1503 col: 0,
1504 cyclomatic: 15,
1505 cognitive: 20,
1506 line_count: 30,
1507 param_count: 0,
1508 exceeded: crate::health_types::ExceededThreshold::Cognitive,
1509 severity: crate::health_types::FindingSeverity::High,
1510 crap: None,
1511 coverage_pct: None,
1512 coverage_tier: None,
1513 }],
1514 summary: crate::health_types::HealthSummary {
1515 files_analyzed: 5,
1516 functions_analyzed: 20,
1517 functions_above_threshold: 1,
1518 ..Default::default()
1519 },
1520 ..Default::default()
1521 };
1522 let md = build_health_markdown(&report, &root);
1523 assert!(md.contains("| 15 |"));
1525 assert!(md.contains("20 **!**"));
1527 }
1528
1529 #[test]
1530 fn health_markdown_with_targets() {
1531 use crate::health_types::*;
1532
1533 let root = PathBuf::from("/project");
1534 let report = HealthReport {
1535 summary: HealthSummary {
1536 files_analyzed: 10,
1537 functions_analyzed: 50,
1538 ..Default::default()
1539 },
1540 targets: vec![
1541 RefactoringTarget {
1542 path: PathBuf::from("/project/src/complex.ts"),
1543 priority: 82.5,
1544 efficiency: 27.5,
1545 recommendation: "Split high-impact file".into(),
1546 category: RecommendationCategory::SplitHighImpact,
1547 effort: crate::health_types::EffortEstimate::High,
1548 confidence: crate::health_types::Confidence::Medium,
1549 factors: vec![ContributingFactor {
1550 metric: "fan_in",
1551 value: 25.0,
1552 threshold: 10.0,
1553 detail: "25 files depend on this".into(),
1554 }],
1555 evidence: None,
1556 },
1557 RefactoringTarget {
1558 path: PathBuf::from("/project/src/legacy.ts"),
1559 priority: 45.0,
1560 efficiency: 45.0,
1561 recommendation: "Remove 5 unused exports".into(),
1562 category: RecommendationCategory::RemoveDeadCode,
1563 effort: crate::health_types::EffortEstimate::Low,
1564 confidence: crate::health_types::Confidence::High,
1565 factors: vec![],
1566 evidence: None,
1567 },
1568 ],
1569 ..Default::default()
1570 };
1571 let md = build_health_markdown(&report, &root);
1572
1573 assert!(
1575 md.contains("Refactoring Targets"),
1576 "should contain targets heading"
1577 );
1578 assert!(
1579 md.contains("src/complex.ts"),
1580 "should contain target file path"
1581 );
1582 assert!(md.contains("27.5"), "should contain efficiency score");
1583 assert!(
1584 md.contains("Split high-impact file"),
1585 "should contain recommendation"
1586 );
1587 assert!(md.contains("src/legacy.ts"), "should contain second target");
1588 }
1589
1590 #[test]
1591 fn health_markdown_with_coverage_gaps() {
1592 use crate::health_types::*;
1593
1594 let root = PathBuf::from("/project");
1595 let report = HealthReport {
1596 summary: HealthSummary {
1597 files_analyzed: 10,
1598 functions_analyzed: 50,
1599 ..Default::default()
1600 },
1601 coverage_gaps: Some(CoverageGaps {
1602 summary: CoverageGapSummary {
1603 runtime_files: 2,
1604 covered_files: 0,
1605 file_coverage_pct: 0.0,
1606 untested_files: 1,
1607 untested_exports: 1,
1608 },
1609 files: vec![UntestedFile {
1610 path: root.join("src/app.ts"),
1611 value_export_count: 2,
1612 }],
1613 exports: vec![UntestedExport {
1614 path: root.join("src/app.ts"),
1615 export_name: "loader".into(),
1616 line: 12,
1617 col: 4,
1618 }],
1619 }),
1620 ..Default::default()
1621 };
1622
1623 let md = build_health_markdown(&report, &root);
1624 assert!(md.contains("### Coverage Gaps"));
1625 assert!(md.contains("*1 untested files"));
1626 assert!(md.contains("`src/app.ts` (2 value exports)"));
1627 assert!(md.contains("`src/app.ts`:12 `loader`"));
1628 }
1629
1630 #[test]
1633 fn markdown_dep_in_workspace_shows_package_label() {
1634 let root = PathBuf::from("/project");
1635 let mut results = AnalysisResults::default();
1636 results.unused_dependencies.push(UnusedDependency {
1637 package_name: "lodash".to_string(),
1638 location: DependencyLocation::Dependencies,
1639 path: root.join("packages/core/package.json"),
1640 line: 5,
1641 used_in_workspaces: Vec::new(),
1642 });
1643 let md = build_markdown(&results, &root);
1644 assert!(md.contains("(packages/core/package.json)"));
1646 }
1647
1648 #[test]
1649 fn markdown_dep_at_root_no_extra_label() {
1650 let root = PathBuf::from("/project");
1651 let mut results = AnalysisResults::default();
1652 results.unused_dependencies.push(UnusedDependency {
1653 package_name: "lodash".to_string(),
1654 location: DependencyLocation::Dependencies,
1655 path: root.join("package.json"),
1656 line: 5,
1657 used_in_workspaces: Vec::new(),
1658 });
1659 let md = build_markdown(&results, &root);
1660 assert!(md.contains("- `lodash`"));
1661 assert!(!md.contains("(package.json)"));
1662 }
1663
1664 #[test]
1665 fn markdown_root_dep_with_cross_workspace_context_uses_context_label() {
1666 let root = PathBuf::from("/project");
1667 let mut results = AnalysisResults::default();
1668 results.unused_dependencies.push(UnusedDependency {
1669 package_name: "lodash-es".to_string(),
1670 location: DependencyLocation::Dependencies,
1671 path: root.join("package.json"),
1672 line: 5,
1673 used_in_workspaces: vec![root.join("packages/consumer")],
1674 });
1675 let md = build_markdown(&results, &root);
1676 assert!(md.contains("- `lodash-es` (imported in packages/consumer)"));
1677 assert!(!md.contains("(package.json; imported in packages/consumer)"));
1678 }
1679
1680 #[test]
1683 fn markdown_exports_grouped_by_file() {
1684 let root = PathBuf::from("/project");
1685 let mut results = AnalysisResults::default();
1686 results.unused_exports.push(UnusedExport {
1687 path: root.join("src/utils.ts"),
1688 export_name: "alpha".to_string(),
1689 is_type_only: false,
1690 line: 5,
1691 col: 0,
1692 span_start: 0,
1693 is_re_export: false,
1694 });
1695 results.unused_exports.push(UnusedExport {
1696 path: root.join("src/utils.ts"),
1697 export_name: "beta".to_string(),
1698 is_type_only: false,
1699 line: 10,
1700 col: 0,
1701 span_start: 0,
1702 is_re_export: false,
1703 });
1704 results.unused_exports.push(UnusedExport {
1705 path: root.join("src/other.ts"),
1706 export_name: "gamma".to_string(),
1707 is_type_only: false,
1708 line: 1,
1709 col: 0,
1710 span_start: 0,
1711 is_re_export: false,
1712 });
1713 let md = build_markdown(&results, &root);
1714 let utils_count = md.matches("- `src/utils.ts`").count();
1716 assert_eq!(utils_count, 1, "file header should appear once per file");
1717 assert!(md.contains(":5 `alpha`"));
1719 assert!(md.contains(":10 `beta`"));
1720 }
1721
1722 #[test]
1725 fn markdown_multiple_issues_plural() {
1726 let root = PathBuf::from("/project");
1727 let mut results = AnalysisResults::default();
1728 results.unused_files.push(UnusedFile {
1729 path: root.join("src/a.ts"),
1730 });
1731 results.unused_files.push(UnusedFile {
1732 path: root.join("src/b.ts"),
1733 });
1734 let md = build_markdown(&results, &root);
1735 assert!(md.starts_with("## Fallow: 2 issues found\n"));
1736 }
1737
1738 #[test]
1741 fn duplication_markdown_zero_savings_no_suffix() {
1742 let root = PathBuf::from("/project");
1743 let report = DuplicationReport {
1744 clone_groups: vec![CloneGroup {
1745 instances: vec![CloneInstance {
1746 file: root.join("src/a.ts"),
1747 start_line: 1,
1748 end_line: 5,
1749 start_col: 0,
1750 end_col: 0,
1751 fragment: String::new(),
1752 }],
1753 token_count: 30,
1754 line_count: 5,
1755 }],
1756 clone_families: vec![CloneFamily {
1757 files: vec![root.join("src/a.ts")],
1758 groups: vec![],
1759 total_duplicated_lines: 5,
1760 total_duplicated_tokens: 30,
1761 suggestions: vec![RefactoringSuggestion {
1762 kind: RefactoringKind::ExtractFunction,
1763 description: "Extract function".to_string(),
1764 estimated_savings: 0,
1765 }],
1766 }],
1767 mirrored_directories: vec![],
1768 stats: DuplicationStats {
1769 clone_groups: 1,
1770 clone_instances: 1,
1771 duplication_percentage: 1.0,
1772 ..Default::default()
1773 },
1774 };
1775 let md = build_duplication_markdown(&report, &root);
1776 assert!(md.contains("Extract function"));
1777 assert!(!md.contains("lines saved"));
1778 }
1779
1780 #[test]
1783 fn health_markdown_vital_signs_table() {
1784 let root = PathBuf::from("/project");
1785 let report = crate::health_types::HealthReport {
1786 summary: crate::health_types::HealthSummary {
1787 files_analyzed: 10,
1788 functions_analyzed: 50,
1789 ..Default::default()
1790 },
1791 vital_signs: Some(crate::health_types::VitalSigns {
1792 avg_cyclomatic: 3.5,
1793 p90_cyclomatic: 12,
1794 dead_file_pct: Some(5.0),
1795 dead_export_pct: Some(10.2),
1796 duplication_pct: None,
1797 maintainability_avg: Some(72.3),
1798 hotspot_count: Some(3),
1799 circular_dep_count: Some(1),
1800 unused_dep_count: Some(2),
1801 counts: None,
1802 unit_size_profile: None,
1803 unit_interfacing_profile: None,
1804 p95_fan_in: None,
1805 coupling_high_pct: None,
1806 total_loc: 15_200,
1807 ..Default::default()
1808 }),
1809 ..Default::default()
1810 };
1811 let md = build_health_markdown(&report, &root);
1812 assert!(md.contains("## Vital Signs"));
1813 assert!(md.contains("| Metric | Value |"));
1814 assert!(md.contains("| Total LOC | 15200 |"));
1815 assert!(md.contains("| Avg Cyclomatic | 3.5 |"));
1816 assert!(md.contains("| P90 Cyclomatic | 12 |"));
1817 assert!(md.contains("| Dead Files | 5.0% |"));
1818 assert!(md.contains("| Dead Exports | 10.2% |"));
1819 assert!(md.contains("| Maintainability (avg) | 72.3 |"));
1820 assert!(md.contains("| Hotspots | 3 |"));
1821 assert!(md.contains("| Circular Deps | 1 |"));
1822 assert!(md.contains("| Unused Deps | 2 |"));
1823 }
1824
1825 #[test]
1828 fn health_markdown_file_scores_table() {
1829 let root = PathBuf::from("/project");
1830 let report = crate::health_types::HealthReport {
1831 findings: vec![crate::health_types::HealthFinding {
1832 path: root.join("src/dummy.ts"),
1833 name: "fn".to_string(),
1834 line: 1,
1835 col: 0,
1836 cyclomatic: 25,
1837 cognitive: 20,
1838 line_count: 50,
1839 param_count: 0,
1840 exceeded: crate::health_types::ExceededThreshold::Both,
1841 severity: crate::health_types::FindingSeverity::High,
1842 crap: None,
1843 coverage_pct: None,
1844 coverage_tier: None,
1845 }],
1846 summary: crate::health_types::HealthSummary {
1847 files_analyzed: 5,
1848 functions_analyzed: 10,
1849 functions_above_threshold: 1,
1850 files_scored: Some(1),
1851 average_maintainability: Some(65.0),
1852 ..Default::default()
1853 },
1854 file_scores: vec![crate::health_types::FileHealthScore {
1855 path: root.join("src/utils.ts"),
1856 fan_in: 5,
1857 fan_out: 3,
1858 dead_code_ratio: 0.25,
1859 complexity_density: 0.8,
1860 maintainability_index: 72.5,
1861 total_cyclomatic: 40,
1862 total_cognitive: 30,
1863 function_count: 10,
1864 lines: 200,
1865 crap_max: 0.0,
1866 crap_above_threshold: 0,
1867 }],
1868 ..Default::default()
1869 };
1870 let md = build_health_markdown(&report, &root);
1871 assert!(md.contains("### File Health Scores (1 files)"));
1872 assert!(md.contains("| File | Maintainability | Fan-in | Fan-out | Dead Code | Density |"));
1873 assert!(md.contains("| `src/utils.ts` | 72.5 | 5 | 3 | 25% | 0.80 |"));
1874 assert!(md.contains("**Average maintainability index:** 65.0/100"));
1875 }
1876
1877 #[test]
1880 fn health_markdown_hotspots_table() {
1881 let root = PathBuf::from("/project");
1882 let report = crate::health_types::HealthReport {
1883 findings: vec![crate::health_types::HealthFinding {
1884 path: root.join("src/dummy.ts"),
1885 name: "fn".to_string(),
1886 line: 1,
1887 col: 0,
1888 cyclomatic: 25,
1889 cognitive: 20,
1890 line_count: 50,
1891 param_count: 0,
1892 exceeded: crate::health_types::ExceededThreshold::Both,
1893 severity: crate::health_types::FindingSeverity::High,
1894 crap: None,
1895 coverage_pct: None,
1896 coverage_tier: None,
1897 }],
1898 summary: crate::health_types::HealthSummary {
1899 files_analyzed: 5,
1900 functions_analyzed: 10,
1901 functions_above_threshold: 1,
1902 ..Default::default()
1903 },
1904 hotspots: vec![crate::health_types::HotspotEntry {
1905 path: root.join("src/hot.ts"),
1906 score: 85.0,
1907 commits: 42,
1908 weighted_commits: 35.0,
1909 lines_added: 500,
1910 lines_deleted: 200,
1911 complexity_density: 1.2,
1912 fan_in: 10,
1913 trend: fallow_core::churn::ChurnTrend::Accelerating,
1914 ownership: None,
1915 is_test_path: false,
1916 }],
1917 hotspot_summary: Some(crate::health_types::HotspotSummary {
1918 since: "6 months".to_string(),
1919 min_commits: 3,
1920 files_analyzed: 50,
1921 files_excluded: 5,
1922 shallow_clone: false,
1923 }),
1924 ..Default::default()
1925 };
1926 let md = build_health_markdown(&report, &root);
1927 assert!(md.contains("### Hotspots (1 files, since 6 months)"));
1928 assert!(md.contains("| `src/hot.ts` | 85.0 | 42 | 700 | 1.20 | 10 | accelerating |"));
1929 assert!(md.contains("*5 files excluded (< 3 commits)*"));
1930 }
1931
1932 #[test]
1935 fn health_markdown_metric_legend_with_scores() {
1936 let root = PathBuf::from("/project");
1937 let report = crate::health_types::HealthReport {
1938 findings: vec![crate::health_types::HealthFinding {
1939 path: root.join("src/x.ts"),
1940 name: "f".to_string(),
1941 line: 1,
1942 col: 0,
1943 cyclomatic: 25,
1944 cognitive: 20,
1945 line_count: 10,
1946 param_count: 0,
1947 exceeded: crate::health_types::ExceededThreshold::Both,
1948 severity: crate::health_types::FindingSeverity::High,
1949 crap: None,
1950 coverage_pct: None,
1951 coverage_tier: None,
1952 }],
1953 summary: crate::health_types::HealthSummary {
1954 files_analyzed: 1,
1955 functions_analyzed: 1,
1956 functions_above_threshold: 1,
1957 files_scored: Some(1),
1958 average_maintainability: Some(70.0),
1959 ..Default::default()
1960 },
1961 file_scores: vec![crate::health_types::FileHealthScore {
1962 path: root.join("src/x.ts"),
1963 fan_in: 1,
1964 fan_out: 1,
1965 dead_code_ratio: 0.0,
1966 complexity_density: 0.5,
1967 maintainability_index: 80.0,
1968 total_cyclomatic: 10,
1969 total_cognitive: 8,
1970 function_count: 2,
1971 lines: 50,
1972 crap_max: 0.0,
1973 crap_above_threshold: 0,
1974 }],
1975 ..Default::default()
1976 };
1977 let md = build_health_markdown(&report, &root);
1978 assert!(md.contains("<details><summary>Metric definitions</summary>"));
1979 assert!(md.contains("**MI**: Maintainability Index"));
1980 assert!(md.contains("**Fan-in**"));
1981 assert!(md.contains("Full metric reference"));
1982 }
1983
1984 #[test]
1987 fn health_markdown_truncated_findings_shown_count() {
1988 let root = PathBuf::from("/project");
1989 let report = crate::health_types::HealthReport {
1990 findings: vec![crate::health_types::HealthFinding {
1991 path: root.join("src/x.ts"),
1992 name: "f".to_string(),
1993 line: 1,
1994 col: 0,
1995 cyclomatic: 25,
1996 cognitive: 20,
1997 line_count: 10,
1998 param_count: 0,
1999 exceeded: crate::health_types::ExceededThreshold::Both,
2000 severity: crate::health_types::FindingSeverity::High,
2001 crap: None,
2002 coverage_pct: None,
2003 coverage_tier: None,
2004 }],
2005 summary: crate::health_types::HealthSummary {
2006 files_analyzed: 10,
2007 functions_analyzed: 50,
2008 functions_above_threshold: 5, ..Default::default()
2010 },
2011 ..Default::default()
2012 };
2013 let md = build_health_markdown(&report, &root);
2014 assert!(md.contains("5 high complexity functions (1 shown)"));
2015 }
2016
2017 #[test]
2020 fn escape_backticks_handles_multiple() {
2021 assert_eq!(escape_backticks("a`b`c"), "a\\`b\\`c");
2022 }
2023
2024 #[test]
2025 fn escape_backticks_no_backticks_unchanged() {
2026 assert_eq!(escape_backticks("hello"), "hello");
2027 }
2028
2029 #[test]
2032 fn markdown_unresolved_import_grouped_by_file() {
2033 let root = PathBuf::from("/project");
2034 let mut results = AnalysisResults::default();
2035 results.unresolved_imports.push(UnresolvedImport {
2036 path: root.join("src/app.ts"),
2037 specifier: "./missing".to_string(),
2038 line: 3,
2039 col: 0,
2040 specifier_col: 0,
2041 });
2042 let md = build_markdown(&results, &root);
2043 assert!(md.contains("### Unresolved imports (1)"));
2044 assert!(md.contains("- `src/app.ts`"));
2045 assert!(md.contains(":3 `./missing`"));
2046 }
2047
2048 #[test]
2051 fn markdown_unused_optional_dep() {
2052 let root = PathBuf::from("/project");
2053 let mut results = AnalysisResults::default();
2054 results.unused_optional_dependencies.push(UnusedDependency {
2055 package_name: "fsevents".to_string(),
2056 location: DependencyLocation::OptionalDependencies,
2057 path: root.join("package.json"),
2058 line: 12,
2059 used_in_workspaces: Vec::new(),
2060 });
2061 let md = build_markdown(&results, &root);
2062 assert!(md.contains("### Unused optionalDependencies (1)"));
2063 assert!(md.contains("- `fsevents`"));
2064 }
2065
2066 #[test]
2069 fn health_markdown_hotspots_no_excluded_message() {
2070 let root = PathBuf::from("/project");
2071 let report = crate::health_types::HealthReport {
2072 findings: vec![crate::health_types::HealthFinding {
2073 path: root.join("src/x.ts"),
2074 name: "f".to_string(),
2075 line: 1,
2076 col: 0,
2077 cyclomatic: 25,
2078 cognitive: 20,
2079 line_count: 10,
2080 param_count: 0,
2081 exceeded: crate::health_types::ExceededThreshold::Both,
2082 severity: crate::health_types::FindingSeverity::High,
2083 crap: None,
2084 coverage_pct: None,
2085 coverage_tier: None,
2086 }],
2087 summary: crate::health_types::HealthSummary {
2088 files_analyzed: 5,
2089 functions_analyzed: 10,
2090 functions_above_threshold: 1,
2091 ..Default::default()
2092 },
2093 hotspots: vec![crate::health_types::HotspotEntry {
2094 path: root.join("src/hot.ts"),
2095 score: 50.0,
2096 commits: 10,
2097 weighted_commits: 8.0,
2098 lines_added: 100,
2099 lines_deleted: 50,
2100 complexity_density: 0.5,
2101 fan_in: 3,
2102 trend: fallow_core::churn::ChurnTrend::Stable,
2103 ownership: None,
2104 is_test_path: false,
2105 }],
2106 hotspot_summary: Some(crate::health_types::HotspotSummary {
2107 since: "6 months".to_string(),
2108 min_commits: 3,
2109 files_analyzed: 50,
2110 files_excluded: 0,
2111 shallow_clone: false,
2112 }),
2113 ..Default::default()
2114 };
2115 let md = build_health_markdown(&report, &root);
2116 assert!(!md.contains("files excluded"));
2117 }
2118
2119 #[test]
2122 fn duplication_markdown_single_group_no_plural() {
2123 let root = PathBuf::from("/project");
2124 let report = DuplicationReport {
2125 clone_groups: vec![CloneGroup {
2126 instances: vec![CloneInstance {
2127 file: root.join("src/a.ts"),
2128 start_line: 1,
2129 end_line: 5,
2130 start_col: 0,
2131 end_col: 0,
2132 fragment: String::new(),
2133 }],
2134 token_count: 30,
2135 line_count: 5,
2136 }],
2137 clone_families: vec![],
2138 mirrored_directories: vec![],
2139 stats: DuplicationStats {
2140 clone_groups: 1,
2141 clone_instances: 1,
2142 duplication_percentage: 2.0,
2143 ..Default::default()
2144 },
2145 };
2146 let md = build_duplication_markdown(&report, &root);
2147 assert!(md.contains("1 clone group found"));
2148 assert!(!md.contains("1 clone groups found"));
2149 }
2150}