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