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