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