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