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