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