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