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