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 rel = |p: &Path| {
377 escape_backticks(&normalize_uri(
378 &relative_path(p, root).display().to_string(),
379 ))
380 };
381
382 let mut out = String::new();
383
384 if let Some(ref hs) = report.health_score {
386 let _ = writeln!(out, "## Health Score: {:.0} ({})\n", hs.score, hs.grade);
387 }
388
389 if let Some(ref trend) = report.health_trend {
391 let sha_str = trend
392 .compared_to
393 .git_sha
394 .as_deref()
395 .map_or(String::new(), |sha| format!(" ({sha})"));
396 let _ = writeln!(
397 out,
398 "## Trend (vs {}{})\n",
399 trend
400 .compared_to
401 .timestamp
402 .get(..10)
403 .unwrap_or(&trend.compared_to.timestamp),
404 sha_str,
405 );
406 out.push_str("| Metric | Previous | Current | Delta | Direction |\n");
407 out.push_str("|:-------|:---------|:--------|:------|:----------|\n");
408 for m in &trend.metrics {
409 let fmt_val = |v: f64| -> String {
410 if m.unit == "%" {
411 format!("{v:.1}%")
412 } else if (v - v.round()).abs() < 0.05 {
413 format!("{v:.0}")
414 } else {
415 format!("{v:.1}")
416 }
417 };
418 let prev = fmt_val(m.previous);
419 let cur = fmt_val(m.current);
420 let delta = if m.unit == "%" {
421 format!("{:+.1}%", m.delta)
422 } else if (m.delta - m.delta.round()).abs() < 0.05 {
423 format!("{:+.0}", m.delta)
424 } else {
425 format!("{:+.1}", m.delta)
426 };
427 let _ = writeln!(
428 out,
429 "| {} | {} | {} | {} | {} {} |",
430 m.label,
431 prev,
432 cur,
433 delta,
434 m.direction.arrow(),
435 m.direction.label(),
436 );
437 }
438 let md_sha = trend
439 .compared_to
440 .git_sha
441 .as_deref()
442 .map_or(String::new(), |sha| format!(" ({sha})"));
443 let _ = writeln!(
444 out,
445 "\n*vs {}{} · {} {} available*\n",
446 trend
447 .compared_to
448 .timestamp
449 .get(..10)
450 .unwrap_or(&trend.compared_to.timestamp),
451 md_sha,
452 trend.snapshots_loaded,
453 if trend.snapshots_loaded == 1 {
454 "snapshot"
455 } else {
456 "snapshots"
457 },
458 );
459 }
460
461 if let Some(ref vs) = report.vital_signs {
463 out.push_str("## Vital Signs\n\n");
464 out.push_str("| Metric | Value |\n");
465 out.push_str("|:-------|------:|\n");
466 let _ = writeln!(out, "| Avg Cyclomatic | {:.1} |", vs.avg_cyclomatic);
467 let _ = writeln!(out, "| P90 Cyclomatic | {} |", vs.p90_cyclomatic);
468 if let Some(v) = vs.dead_file_pct {
469 let _ = writeln!(out, "| Dead Files | {v:.1}% |");
470 }
471 if let Some(v) = vs.dead_export_pct {
472 let _ = writeln!(out, "| Dead Exports | {v:.1}% |");
473 }
474 if let Some(v) = vs.maintainability_avg {
475 let _ = writeln!(out, "| Maintainability (avg) | {v:.1} |");
476 }
477 if let Some(v) = vs.hotspot_count {
478 let _ = writeln!(out, "| Hotspots | {v} |");
479 }
480 if let Some(v) = vs.circular_dep_count {
481 let _ = writeln!(out, "| Circular Deps | {v} |");
482 }
483 if let Some(v) = vs.unused_dep_count {
484 let _ = writeln!(out, "| Unused Deps | {v} |");
485 }
486 out.push('\n');
487 }
488
489 if report.findings.is_empty()
490 && report.file_scores.is_empty()
491 && report.hotspots.is_empty()
492 && report.targets.is_empty()
493 {
494 if report.vital_signs.is_none() {
495 let _ = write!(
496 out,
497 "## Fallow: no functions exceed complexity thresholds\n\n\
498 **{}** functions analyzed (max cyclomatic: {}, max cognitive: {})\n",
499 report.summary.functions_analyzed,
500 report.summary.max_cyclomatic_threshold,
501 report.summary.max_cognitive_threshold,
502 );
503 }
504 return out;
505 }
506
507 if !report.findings.is_empty() {
508 let count = report.summary.functions_above_threshold;
509 let shown = report.findings.len();
510 if shown < count {
511 let _ = write!(
512 out,
513 "## Fallow: {count} high complexity function{} ({shown} shown)\n\n",
514 plural(count),
515 );
516 } else {
517 let _ = write!(
518 out,
519 "## Fallow: {count} high complexity function{}\n\n",
520 plural(count),
521 );
522 }
523
524 out.push_str("| File | Function | Cyclomatic | Cognitive | Lines |\n");
525 out.push_str("|:-----|:---------|:-----------|:----------|:------|\n");
526
527 for finding in &report.findings {
528 let file_str = rel(&finding.path);
529 let cyc_marker = if finding.cyclomatic > report.summary.max_cyclomatic_threshold {
530 " **!**"
531 } else {
532 ""
533 };
534 let cog_marker = if finding.cognitive > report.summary.max_cognitive_threshold {
535 " **!**"
536 } else {
537 ""
538 };
539 let _ = writeln!(
540 out,
541 "| `{file_str}:{line}` | `{name}` | {cyc}{cyc_marker} | {cog}{cog_marker} | {lines} |",
542 line = finding.line,
543 name = escape_backticks(&finding.name),
544 cyc = finding.cyclomatic,
545 cog = finding.cognitive,
546 lines = finding.line_count,
547 );
548 }
549
550 let s = &report.summary;
551 let _ = write!(
552 out,
553 "\n**{files}** files, **{funcs}** functions analyzed \
554 (thresholds: cyclomatic > {cyc}, cognitive > {cog})\n",
555 files = s.files_analyzed,
556 funcs = s.functions_analyzed,
557 cyc = s.max_cyclomatic_threshold,
558 cog = s.max_cognitive_threshold,
559 );
560 }
561
562 if !report.file_scores.is_empty() {
564 out.push('\n');
565 let _ = writeln!(
566 out,
567 "### File Health Scores ({} files)\n",
568 report.file_scores.len(),
569 );
570 out.push_str("| File | MI | Fan-in | Fan-out | Dead Code | Density |\n");
571 out.push_str("|:-----|:---|:-------|:--------|:----------|:--------|\n");
572
573 for score in &report.file_scores {
574 let file_str = rel(&score.path);
575 let _ = writeln!(
576 out,
577 "| `{file_str}` | {mi:.1} | {fi} | {fan_out} | {dead:.0}% | {density:.2} |",
578 mi = score.maintainability_index,
579 fi = score.fan_in,
580 fan_out = score.fan_out,
581 dead = score.dead_code_ratio * 100.0,
582 density = score.complexity_density,
583 );
584 }
585
586 if let Some(avg) = report.summary.average_maintainability {
587 let _ = write!(out, "\n**Average maintainability index:** {avg:.1}/100\n");
588 }
589 }
590
591 if !report.hotspots.is_empty() {
593 out.push('\n');
594 let header = report.hotspot_summary.as_ref().map_or_else(
595 || format!("### Hotspots ({} files)\n", report.hotspots.len()),
596 |summary| {
597 format!(
598 "### Hotspots ({} files, since {})\n",
599 report.hotspots.len(),
600 summary.since,
601 )
602 },
603 );
604 let _ = writeln!(out, "{header}");
605 out.push_str("| File | Score | Commits | Churn | Density | Fan-in | Trend |\n");
606 out.push_str("|:-----|:------|:--------|:------|:--------|:-------|:------|\n");
607
608 for entry in &report.hotspots {
609 let file_str = rel(&entry.path);
610 let _ = writeln!(
611 out,
612 "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} |",
613 score = entry.score,
614 commits = entry.commits,
615 churn = entry.lines_added + entry.lines_deleted,
616 density = entry.complexity_density,
617 fi = entry.fan_in,
618 trend = entry.trend,
619 );
620 }
621
622 if let Some(ref summary) = report.hotspot_summary
623 && summary.files_excluded > 0
624 {
625 let _ = write!(
626 out,
627 "\n*{} file{} excluded (< {} commits)*\n",
628 summary.files_excluded,
629 plural(summary.files_excluded),
630 summary.min_commits,
631 );
632 }
633 }
634
635 if !report.targets.is_empty() {
637 let _ = write!(
638 out,
639 "\n### Refactoring Targets ({})\n\n",
640 report.targets.len()
641 );
642 out.push_str("| Efficiency | Category | Effort / Confidence | File | Recommendation |\n");
643 out.push_str("|:-----------|:---------|:--------------------|:-----|:---------------|\n");
644 for target in &report.targets {
645 let file_str = normalize_uri(&relative_path(&target.path, root).display().to_string());
646 let category = target.category.label();
647 let effort = target.effort.label();
648 let confidence = target.confidence.label();
649 let _ = writeln!(
650 out,
651 "| {:.1} | {category} | {effort} / {confidence} | `{file_str}` | {} |",
652 target.efficiency, target.recommendation,
653 );
654 }
655 }
656
657 let has_scores = !report.file_scores.is_empty();
659 let has_hotspots = !report.hotspots.is_empty();
660 let has_targets = !report.targets.is_empty();
661 if has_scores || has_hotspots || has_targets {
662 out.push_str("\n---\n\n<details><summary>Metric definitions</summary>\n\n");
663 if has_scores {
664 out.push_str("- **MI** — Maintainability Index (0\u{2013}100, higher is better)\n");
665 out.push_str("- **Fan-in** — files that import this file (blast radius)\n");
666 out.push_str("- **Fan-out** — files this file imports (coupling)\n");
667 out.push_str("- **Dead Code** — % of value exports with zero references\n");
668 out.push_str("- **Density** — cyclomatic complexity / lines of code\n");
669 }
670 if has_hotspots {
671 out.push_str(
672 "- **Score** — churn \u{00d7} complexity (0\u{2013}100, higher = riskier)\n",
673 );
674 out.push_str("- **Commits** — commits in the analysis window\n");
675 out.push_str("- **Churn** — total lines added + deleted\n");
676 out.push_str("- **Trend** — accelerating / stable / cooling\n");
677 }
678 if has_targets {
679 out.push_str("- **Efficiency** — priority / effort (higher = better quick-win value, default sort)\n");
680 out.push_str("- **Category** — recommendation type (churn+complexity, high impact, dead code, complexity, coupling, circular dep)\n");
681 out.push_str("- **Effort** — estimated effort (low / medium / high) based on file size, function count, and fan-in\n");
682 out.push_str("- **Confidence** — recommendation reliability (high = deterministic analysis, medium = heuristic, low = git-dependent)\n");
683 }
684 out.push_str("\n[Full metric reference](https://docs.fallow.tools/explanations/metrics)\n\n</details>\n");
685 }
686
687 out
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693 use crate::report::test_helpers::sample_results;
694 use fallow_core::duplicates::{
695 CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
696 RefactoringKind, RefactoringSuggestion,
697 };
698 use fallow_core::results::*;
699 use std::path::PathBuf;
700
701 #[test]
702 fn markdown_empty_results_no_issues() {
703 let root = PathBuf::from("/project");
704 let results = AnalysisResults::default();
705 let md = build_markdown(&results, &root);
706 assert_eq!(md, "## Fallow: no issues found\n");
707 }
708
709 #[test]
710 fn markdown_contains_header_with_count() {
711 let root = PathBuf::from("/project");
712 let results = sample_results(&root);
713 let md = build_markdown(&results, &root);
714 assert!(md.starts_with(&format!(
715 "## Fallow: {} issues found\n",
716 results.total_issues()
717 )));
718 }
719
720 #[test]
721 fn markdown_contains_all_sections() {
722 let root = PathBuf::from("/project");
723 let results = sample_results(&root);
724 let md = build_markdown(&results, &root);
725
726 assert!(md.contains("### Unused files (1)"));
727 assert!(md.contains("### Unused exports (1)"));
728 assert!(md.contains("### Unused type exports (1)"));
729 assert!(md.contains("### Unused dependencies (1)"));
730 assert!(md.contains("### Unused devDependencies (1)"));
731 assert!(md.contains("### Unused enum members (1)"));
732 assert!(md.contains("### Unused class members (1)"));
733 assert!(md.contains("### Unresolved imports (1)"));
734 assert!(md.contains("### Unlisted dependencies (1)"));
735 assert!(md.contains("### Duplicate exports (1)"));
736 assert!(md.contains("### Type-only dependencies"));
737 assert!(md.contains("### Test-only production dependencies"));
738 assert!(md.contains("### Circular dependencies (1)"));
739 }
740
741 #[test]
742 fn markdown_unused_file_format() {
743 let root = PathBuf::from("/project");
744 let mut results = AnalysisResults::default();
745 results.unused_files.push(UnusedFile {
746 path: root.join("src/dead.ts"),
747 });
748 let md = build_markdown(&results, &root);
749 assert!(md.contains("- `src/dead.ts`"));
750 }
751
752 #[test]
753 fn markdown_unused_export_grouped_by_file() {
754 let root = PathBuf::from("/project");
755 let mut results = AnalysisResults::default();
756 results.unused_exports.push(UnusedExport {
757 path: root.join("src/utils.ts"),
758 export_name: "helperFn".to_string(),
759 is_type_only: false,
760 line: 10,
761 col: 4,
762 span_start: 120,
763 is_re_export: false,
764 });
765 let md = build_markdown(&results, &root);
766 assert!(md.contains("- `src/utils.ts`"));
767 assert!(md.contains(":10 `helperFn`"));
768 }
769
770 #[test]
771 fn markdown_re_export_tagged() {
772 let root = PathBuf::from("/project");
773 let mut results = AnalysisResults::default();
774 results.unused_exports.push(UnusedExport {
775 path: root.join("src/index.ts"),
776 export_name: "reExported".to_string(),
777 is_type_only: false,
778 line: 1,
779 col: 0,
780 span_start: 0,
781 is_re_export: true,
782 });
783 let md = build_markdown(&results, &root);
784 assert!(md.contains("(re-export)"));
785 }
786
787 #[test]
788 fn markdown_unused_dep_format() {
789 let root = PathBuf::from("/project");
790 let mut results = AnalysisResults::default();
791 results.unused_dependencies.push(UnusedDependency {
792 package_name: "lodash".to_string(),
793 location: DependencyLocation::Dependencies,
794 path: root.join("package.json"),
795 line: 5,
796 });
797 let md = build_markdown(&results, &root);
798 assert!(md.contains("- `lodash`"));
799 }
800
801 #[test]
802 fn markdown_circular_dep_format() {
803 let root = PathBuf::from("/project");
804 let mut results = AnalysisResults::default();
805 results.circular_dependencies.push(CircularDependency {
806 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
807 length: 2,
808 line: 3,
809 col: 0,
810 });
811 let md = build_markdown(&results, &root);
812 assert!(md.contains("`src/a.ts`"));
813 assert!(md.contains("`src/b.ts`"));
814 assert!(md.contains("\u{2192}"));
815 }
816
817 #[test]
818 fn markdown_strips_root_prefix() {
819 let root = PathBuf::from("/project");
820 let mut results = AnalysisResults::default();
821 results.unused_files.push(UnusedFile {
822 path: PathBuf::from("/project/src/deep/nested/file.ts"),
823 });
824 let md = build_markdown(&results, &root);
825 assert!(md.contains("`src/deep/nested/file.ts`"));
826 assert!(!md.contains("/project/"));
827 }
828
829 #[test]
830 fn markdown_single_issue_no_plural() {
831 let root = PathBuf::from("/project");
832 let mut results = AnalysisResults::default();
833 results.unused_files.push(UnusedFile {
834 path: root.join("src/dead.ts"),
835 });
836 let md = build_markdown(&results, &root);
837 assert!(md.starts_with("## Fallow: 1 issue found\n"));
838 }
839
840 #[test]
841 fn markdown_type_only_dep_format() {
842 let root = PathBuf::from("/project");
843 let mut results = AnalysisResults::default();
844 results.type_only_dependencies.push(TypeOnlyDependency {
845 package_name: "zod".to_string(),
846 path: root.join("package.json"),
847 line: 8,
848 });
849 let md = build_markdown(&results, &root);
850 assert!(md.contains("### Type-only dependencies"));
851 assert!(md.contains("- `zod`"));
852 }
853
854 #[test]
855 fn markdown_escapes_backticks_in_export_names() {
856 let root = PathBuf::from("/project");
857 let mut results = AnalysisResults::default();
858 results.unused_exports.push(UnusedExport {
859 path: root.join("src/utils.ts"),
860 export_name: "foo`bar".to_string(),
861 is_type_only: false,
862 line: 1,
863 col: 0,
864 span_start: 0,
865 is_re_export: false,
866 });
867 let md = build_markdown(&results, &root);
868 assert!(md.contains("foo\\`bar"));
869 assert!(!md.contains("foo`bar`"));
870 }
871
872 #[test]
873 fn markdown_escapes_backticks_in_package_names() {
874 let root = PathBuf::from("/project");
875 let mut results = AnalysisResults::default();
876 results.unused_dependencies.push(UnusedDependency {
877 package_name: "pkg`name".to_string(),
878 location: DependencyLocation::Dependencies,
879 path: root.join("package.json"),
880 line: 5,
881 });
882 let md = build_markdown(&results, &root);
883 assert!(md.contains("pkg\\`name"));
884 }
885
886 #[test]
889 fn duplication_markdown_empty() {
890 let report = DuplicationReport::default();
891 let root = PathBuf::from("/project");
892 let md = build_duplication_markdown(&report, &root);
893 assert_eq!(md, "## Fallow: no code duplication found\n");
894 }
895
896 #[test]
897 fn duplication_markdown_contains_groups() {
898 let root = PathBuf::from("/project");
899 let report = DuplicationReport {
900 clone_groups: vec![CloneGroup {
901 instances: vec![
902 CloneInstance {
903 file: root.join("src/a.ts"),
904 start_line: 1,
905 end_line: 10,
906 start_col: 0,
907 end_col: 0,
908 fragment: String::new(),
909 },
910 CloneInstance {
911 file: root.join("src/b.ts"),
912 start_line: 5,
913 end_line: 14,
914 start_col: 0,
915 end_col: 0,
916 fragment: String::new(),
917 },
918 ],
919 token_count: 50,
920 line_count: 10,
921 }],
922 clone_families: vec![],
923 stats: DuplicationStats {
924 total_files: 10,
925 files_with_clones: 2,
926 total_lines: 500,
927 duplicated_lines: 20,
928 total_tokens: 2500,
929 duplicated_tokens: 100,
930 clone_groups: 1,
931 clone_instances: 2,
932 duplication_percentage: 4.0,
933 },
934 };
935 let md = build_duplication_markdown(&report, &root);
936 assert!(md.contains("**Clone group 1**"));
937 assert!(md.contains("`src/a.ts:1-10`"));
938 assert!(md.contains("`src/b.ts:5-14`"));
939 assert!(md.contains("4.0% duplication"));
940 }
941
942 #[test]
943 fn duplication_markdown_contains_families() {
944 let root = PathBuf::from("/project");
945 let report = DuplicationReport {
946 clone_groups: vec![CloneGroup {
947 instances: vec![CloneInstance {
948 file: root.join("src/a.ts"),
949 start_line: 1,
950 end_line: 5,
951 start_col: 0,
952 end_col: 0,
953 fragment: String::new(),
954 }],
955 token_count: 30,
956 line_count: 5,
957 }],
958 clone_families: vec![CloneFamily {
959 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
960 groups: vec![],
961 total_duplicated_lines: 20,
962 total_duplicated_tokens: 100,
963 suggestions: vec![RefactoringSuggestion {
964 kind: RefactoringKind::ExtractFunction,
965 description: "Extract shared utility function".to_string(),
966 estimated_savings: 15,
967 }],
968 }],
969 stats: DuplicationStats {
970 clone_groups: 1,
971 clone_instances: 1,
972 duplication_percentage: 2.0,
973 ..Default::default()
974 },
975 };
976 let md = build_duplication_markdown(&report, &root);
977 assert!(md.contains("### Clone Families"));
978 assert!(md.contains("**Family 1**"));
979 assert!(md.contains("Extract shared utility function"));
980 assert!(md.contains("~15 lines saved"));
981 }
982
983 #[test]
986 fn health_markdown_empty_no_findings() {
987 let root = PathBuf::from("/project");
988 let report = crate::health_types::HealthReport {
989 findings: vec![],
990 summary: crate::health_types::HealthSummary {
991 files_analyzed: 10,
992 functions_analyzed: 50,
993 functions_above_threshold: 0,
994 max_cyclomatic_threshold: 20,
995 max_cognitive_threshold: 15,
996 files_scored: None,
997 average_maintainability: None,
998 },
999 vital_signs: None,
1000 health_score: None,
1001 file_scores: vec![],
1002 hotspots: vec![],
1003 hotspot_summary: None,
1004 targets: vec![],
1005 target_thresholds: None,
1006 health_trend: None,
1007 };
1008 let md = build_health_markdown(&report, &root);
1009 assert!(md.contains("no functions exceed complexity thresholds"));
1010 assert!(md.contains("**50** functions analyzed"));
1011 }
1012
1013 #[test]
1014 fn health_markdown_table_format() {
1015 let root = PathBuf::from("/project");
1016 let report = crate::health_types::HealthReport {
1017 findings: vec![crate::health_types::HealthFinding {
1018 path: root.join("src/utils.ts"),
1019 name: "parseExpression".to_string(),
1020 line: 42,
1021 col: 0,
1022 cyclomatic: 25,
1023 cognitive: 30,
1024 line_count: 80,
1025 exceeded: crate::health_types::ExceededThreshold::Both,
1026 }],
1027 summary: crate::health_types::HealthSummary {
1028 files_analyzed: 10,
1029 functions_analyzed: 50,
1030 functions_above_threshold: 1,
1031 max_cyclomatic_threshold: 20,
1032 max_cognitive_threshold: 15,
1033 files_scored: None,
1034 average_maintainability: None,
1035 },
1036 vital_signs: None,
1037 health_score: None,
1038 file_scores: vec![],
1039 hotspots: vec![],
1040 hotspot_summary: None,
1041 targets: vec![],
1042 target_thresholds: None,
1043 health_trend: None,
1044 };
1045 let md = build_health_markdown(&report, &root);
1046 assert!(md.contains("## Fallow: 1 high complexity function\n"));
1047 assert!(md.contains("| File | Function |"));
1048 assert!(md.contains("`src/utils.ts:42`"));
1049 assert!(md.contains("`parseExpression`"));
1050 assert!(md.contains("25 **!**"));
1051 assert!(md.contains("30 **!**"));
1052 assert!(md.contains("| 80 |"));
1053 }
1054
1055 #[test]
1056 fn health_markdown_no_marker_when_below_threshold() {
1057 let root = PathBuf::from("/project");
1058 let report = crate::health_types::HealthReport {
1059 findings: vec![crate::health_types::HealthFinding {
1060 path: root.join("src/utils.ts"),
1061 name: "helper".to_string(),
1062 line: 10,
1063 col: 0,
1064 cyclomatic: 15,
1065 cognitive: 20,
1066 line_count: 30,
1067 exceeded: crate::health_types::ExceededThreshold::Cognitive,
1068 }],
1069 summary: crate::health_types::HealthSummary {
1070 files_analyzed: 5,
1071 functions_analyzed: 20,
1072 functions_above_threshold: 1,
1073 max_cyclomatic_threshold: 20,
1074 max_cognitive_threshold: 15,
1075 files_scored: None,
1076 average_maintainability: None,
1077 },
1078 vital_signs: None,
1079 health_score: None,
1080 file_scores: vec![],
1081 hotspots: vec![],
1082 hotspot_summary: None,
1083 targets: vec![],
1084 target_thresholds: None,
1085 health_trend: None,
1086 };
1087 let md = build_health_markdown(&report, &root);
1088 assert!(md.contains("| 15 |"));
1090 assert!(md.contains("20 **!**"));
1092 }
1093
1094 #[test]
1095 fn health_markdown_with_targets() {
1096 use crate::health_types::*;
1097
1098 let root = PathBuf::from("/project");
1099 let report = HealthReport {
1100 findings: vec![],
1101 summary: HealthSummary {
1102 files_analyzed: 10,
1103 functions_analyzed: 50,
1104 functions_above_threshold: 0,
1105 max_cyclomatic_threshold: 20,
1106 max_cognitive_threshold: 15,
1107 files_scored: None,
1108 average_maintainability: None,
1109 },
1110 vital_signs: None,
1111 health_score: None,
1112 file_scores: vec![],
1113 hotspots: vec![],
1114 hotspot_summary: None,
1115 targets: vec![
1116 RefactoringTarget {
1117 path: PathBuf::from("/project/src/complex.ts"),
1118 priority: 82.5,
1119 efficiency: 27.5,
1120 recommendation: "Split high-impact file".into(),
1121 category: RecommendationCategory::SplitHighImpact,
1122 effort: crate::health_types::EffortEstimate::High,
1123 confidence: crate::health_types::Confidence::Medium,
1124 factors: vec![ContributingFactor {
1125 metric: "fan_in",
1126 value: 25.0,
1127 threshold: 10.0,
1128 detail: "25 files depend on this".into(),
1129 }],
1130 evidence: None,
1131 },
1132 RefactoringTarget {
1133 path: PathBuf::from("/project/src/legacy.ts"),
1134 priority: 45.0,
1135 efficiency: 45.0,
1136 recommendation: "Remove 5 unused exports".into(),
1137 category: RecommendationCategory::RemoveDeadCode,
1138 effort: crate::health_types::EffortEstimate::Low,
1139 confidence: crate::health_types::Confidence::High,
1140 factors: vec![],
1141 evidence: None,
1142 },
1143 ],
1144 target_thresholds: None,
1145 health_trend: None,
1146 };
1147 let md = build_health_markdown(&report, &root);
1148
1149 assert!(
1151 md.contains("Refactoring Targets"),
1152 "should contain targets heading"
1153 );
1154 assert!(
1155 md.contains("src/complex.ts"),
1156 "should contain target file path"
1157 );
1158 assert!(md.contains("27.5"), "should contain efficiency score");
1159 assert!(
1160 md.contains("Split high-impact file"),
1161 "should contain recommendation"
1162 );
1163 assert!(md.contains("src/legacy.ts"), "should contain second target");
1164 }
1165
1166 #[test]
1169 fn markdown_dep_in_workspace_shows_package_label() {
1170 let root = PathBuf::from("/project");
1171 let mut results = AnalysisResults::default();
1172 results.unused_dependencies.push(UnusedDependency {
1173 package_name: "lodash".to_string(),
1174 location: DependencyLocation::Dependencies,
1175 path: root.join("packages/core/package.json"),
1176 line: 5,
1177 });
1178 let md = build_markdown(&results, &root);
1179 assert!(md.contains("(packages/core/package.json)"));
1181 }
1182
1183 #[test]
1184 fn markdown_dep_at_root_no_extra_label() {
1185 let root = PathBuf::from("/project");
1186 let mut results = AnalysisResults::default();
1187 results.unused_dependencies.push(UnusedDependency {
1188 package_name: "lodash".to_string(),
1189 location: DependencyLocation::Dependencies,
1190 path: root.join("package.json"),
1191 line: 5,
1192 });
1193 let md = build_markdown(&results, &root);
1194 assert!(md.contains("- `lodash`"));
1195 assert!(!md.contains("(package.json)"));
1196 }
1197
1198 #[test]
1201 fn markdown_exports_grouped_by_file() {
1202 let root = PathBuf::from("/project");
1203 let mut results = AnalysisResults::default();
1204 results.unused_exports.push(UnusedExport {
1205 path: root.join("src/utils.ts"),
1206 export_name: "alpha".to_string(),
1207 is_type_only: false,
1208 line: 5,
1209 col: 0,
1210 span_start: 0,
1211 is_re_export: false,
1212 });
1213 results.unused_exports.push(UnusedExport {
1214 path: root.join("src/utils.ts"),
1215 export_name: "beta".to_string(),
1216 is_type_only: false,
1217 line: 10,
1218 col: 0,
1219 span_start: 0,
1220 is_re_export: false,
1221 });
1222 results.unused_exports.push(UnusedExport {
1223 path: root.join("src/other.ts"),
1224 export_name: "gamma".to_string(),
1225 is_type_only: false,
1226 line: 1,
1227 col: 0,
1228 span_start: 0,
1229 is_re_export: false,
1230 });
1231 let md = build_markdown(&results, &root);
1232 let utils_count = md.matches("- `src/utils.ts`").count();
1234 assert_eq!(utils_count, 1, "file header should appear once per file");
1235 assert!(md.contains(":5 `alpha`"));
1237 assert!(md.contains(":10 `beta`"));
1238 }
1239
1240 #[test]
1243 fn markdown_multiple_issues_plural() {
1244 let root = PathBuf::from("/project");
1245 let mut results = AnalysisResults::default();
1246 results.unused_files.push(UnusedFile {
1247 path: root.join("src/a.ts"),
1248 });
1249 results.unused_files.push(UnusedFile {
1250 path: root.join("src/b.ts"),
1251 });
1252 let md = build_markdown(&results, &root);
1253 assert!(md.starts_with("## Fallow: 2 issues found\n"));
1254 }
1255
1256 #[test]
1259 fn duplication_markdown_zero_savings_no_suffix() {
1260 let root = PathBuf::from("/project");
1261 let report = DuplicationReport {
1262 clone_groups: vec![CloneGroup {
1263 instances: vec![CloneInstance {
1264 file: root.join("src/a.ts"),
1265 start_line: 1,
1266 end_line: 5,
1267 start_col: 0,
1268 end_col: 0,
1269 fragment: String::new(),
1270 }],
1271 token_count: 30,
1272 line_count: 5,
1273 }],
1274 clone_families: vec![CloneFamily {
1275 files: vec![root.join("src/a.ts")],
1276 groups: vec![],
1277 total_duplicated_lines: 5,
1278 total_duplicated_tokens: 30,
1279 suggestions: vec![RefactoringSuggestion {
1280 kind: RefactoringKind::ExtractFunction,
1281 description: "Extract function".to_string(),
1282 estimated_savings: 0,
1283 }],
1284 }],
1285 stats: DuplicationStats {
1286 clone_groups: 1,
1287 clone_instances: 1,
1288 duplication_percentage: 1.0,
1289 ..Default::default()
1290 },
1291 };
1292 let md = build_duplication_markdown(&report, &root);
1293 assert!(md.contains("Extract function"));
1294 assert!(!md.contains("lines saved"));
1295 }
1296
1297 #[test]
1300 fn health_markdown_vital_signs_table() {
1301 let root = PathBuf::from("/project");
1302 let report = crate::health_types::HealthReport {
1303 findings: vec![],
1304 summary: crate::health_types::HealthSummary {
1305 files_analyzed: 10,
1306 functions_analyzed: 50,
1307 functions_above_threshold: 0,
1308 max_cyclomatic_threshold: 20,
1309 max_cognitive_threshold: 15,
1310 files_scored: None,
1311 average_maintainability: None,
1312 },
1313 vital_signs: Some(crate::health_types::VitalSigns {
1314 avg_cyclomatic: 3.5,
1315 p90_cyclomatic: 12,
1316 dead_file_pct: Some(5.0),
1317 dead_export_pct: Some(10.2),
1318 duplication_pct: None,
1319 maintainability_avg: Some(72.3),
1320 hotspot_count: Some(3),
1321 circular_dep_count: Some(1),
1322 unused_dep_count: Some(2),
1323 }),
1324 health_score: None,
1325 file_scores: vec![],
1326 hotspots: vec![],
1327 hotspot_summary: None,
1328 targets: vec![],
1329 target_thresholds: None,
1330 health_trend: None,
1331 };
1332 let md = build_health_markdown(&report, &root);
1333 assert!(md.contains("## Vital Signs"));
1334 assert!(md.contains("| Metric | Value |"));
1335 assert!(md.contains("| Avg Cyclomatic | 3.5 |"));
1336 assert!(md.contains("| P90 Cyclomatic | 12 |"));
1337 assert!(md.contains("| Dead Files | 5.0% |"));
1338 assert!(md.contains("| Dead Exports | 10.2% |"));
1339 assert!(md.contains("| Maintainability (avg) | 72.3 |"));
1340 assert!(md.contains("| Hotspots | 3 |"));
1341 assert!(md.contains("| Circular Deps | 1 |"));
1342 assert!(md.contains("| Unused Deps | 2 |"));
1343 }
1344
1345 #[test]
1348 fn health_markdown_file_scores_table() {
1349 let root = PathBuf::from("/project");
1350 let report = crate::health_types::HealthReport {
1351 findings: vec![crate::health_types::HealthFinding {
1352 path: root.join("src/dummy.ts"),
1353 name: "fn".to_string(),
1354 line: 1,
1355 col: 0,
1356 cyclomatic: 25,
1357 cognitive: 20,
1358 line_count: 50,
1359 exceeded: crate::health_types::ExceededThreshold::Both,
1360 }],
1361 summary: crate::health_types::HealthSummary {
1362 files_analyzed: 5,
1363 functions_analyzed: 10,
1364 functions_above_threshold: 1,
1365 max_cyclomatic_threshold: 20,
1366 max_cognitive_threshold: 15,
1367 files_scored: Some(1),
1368 average_maintainability: Some(65.0),
1369 },
1370 vital_signs: None,
1371 health_score: None,
1372 file_scores: vec![crate::health_types::FileHealthScore {
1373 path: root.join("src/utils.ts"),
1374 fan_in: 5,
1375 fan_out: 3,
1376 dead_code_ratio: 0.25,
1377 complexity_density: 0.8,
1378 maintainability_index: 72.5,
1379 total_cyclomatic: 40,
1380 total_cognitive: 30,
1381 function_count: 10,
1382 lines: 200,
1383 }],
1384 hotspots: vec![],
1385 hotspot_summary: None,
1386 targets: vec![],
1387 target_thresholds: None,
1388 health_trend: None,
1389 };
1390 let md = build_health_markdown(&report, &root);
1391 assert!(md.contains("### File Health Scores (1 files)"));
1392 assert!(md.contains("| File | MI | Fan-in | Fan-out | Dead Code | Density |"));
1393 assert!(md.contains("| `src/utils.ts` | 72.5 | 5 | 3 | 25% | 0.80 |"));
1394 assert!(md.contains("**Average maintainability index:** 65.0/100"));
1395 }
1396
1397 #[test]
1400 fn health_markdown_hotspots_table() {
1401 let root = PathBuf::from("/project");
1402 let report = crate::health_types::HealthReport {
1403 findings: vec![crate::health_types::HealthFinding {
1404 path: root.join("src/dummy.ts"),
1405 name: "fn".to_string(),
1406 line: 1,
1407 col: 0,
1408 cyclomatic: 25,
1409 cognitive: 20,
1410 line_count: 50,
1411 exceeded: crate::health_types::ExceededThreshold::Both,
1412 }],
1413 summary: crate::health_types::HealthSummary {
1414 files_analyzed: 5,
1415 functions_analyzed: 10,
1416 functions_above_threshold: 1,
1417 max_cyclomatic_threshold: 20,
1418 max_cognitive_threshold: 15,
1419 files_scored: None,
1420 average_maintainability: None,
1421 },
1422 vital_signs: None,
1423 health_score: None,
1424 file_scores: vec![],
1425 hotspots: vec![crate::health_types::HotspotEntry {
1426 path: root.join("src/hot.ts"),
1427 score: 85.0,
1428 commits: 42,
1429 weighted_commits: 35.0,
1430 lines_added: 500,
1431 lines_deleted: 200,
1432 complexity_density: 1.2,
1433 fan_in: 10,
1434 trend: fallow_core::churn::ChurnTrend::Accelerating,
1435 }],
1436 hotspot_summary: Some(crate::health_types::HotspotSummary {
1437 since: "6 months".to_string(),
1438 min_commits: 3,
1439 files_analyzed: 50,
1440 files_excluded: 5,
1441 shallow_clone: false,
1442 }),
1443 targets: vec![],
1444 target_thresholds: None,
1445 health_trend: None,
1446 };
1447 let md = build_health_markdown(&report, &root);
1448 assert!(md.contains("### Hotspots (1 files, since 6 months)"));
1449 assert!(md.contains("| `src/hot.ts` | 85.0 | 42 | 700 | 1.20 | 10 | accelerating |"));
1450 assert!(md.contains("*5 files excluded (< 3 commits)*"));
1451 }
1452
1453 #[test]
1456 fn health_markdown_metric_legend_with_scores() {
1457 let root = PathBuf::from("/project");
1458 let report = crate::health_types::HealthReport {
1459 findings: vec![crate::health_types::HealthFinding {
1460 path: root.join("src/x.ts"),
1461 name: "f".to_string(),
1462 line: 1,
1463 col: 0,
1464 cyclomatic: 25,
1465 cognitive: 20,
1466 line_count: 10,
1467 exceeded: crate::health_types::ExceededThreshold::Both,
1468 }],
1469 summary: crate::health_types::HealthSummary {
1470 files_analyzed: 1,
1471 functions_analyzed: 1,
1472 functions_above_threshold: 1,
1473 max_cyclomatic_threshold: 20,
1474 max_cognitive_threshold: 15,
1475 files_scored: Some(1),
1476 average_maintainability: Some(70.0),
1477 },
1478 vital_signs: None,
1479 health_score: None,
1480 file_scores: vec![crate::health_types::FileHealthScore {
1481 path: root.join("src/x.ts"),
1482 fan_in: 1,
1483 fan_out: 1,
1484 dead_code_ratio: 0.0,
1485 complexity_density: 0.5,
1486 maintainability_index: 80.0,
1487 total_cyclomatic: 10,
1488 total_cognitive: 8,
1489 function_count: 2,
1490 lines: 50,
1491 }],
1492 hotspots: vec![],
1493 hotspot_summary: None,
1494 targets: vec![],
1495 target_thresholds: None,
1496 health_trend: None,
1497 };
1498 let md = build_health_markdown(&report, &root);
1499 assert!(md.contains("<details><summary>Metric definitions</summary>"));
1500 assert!(md.contains("**MI** \u{2014} Maintainability Index"));
1501 assert!(md.contains("**Fan-in**"));
1502 assert!(md.contains("Full metric reference"));
1503 }
1504
1505 #[test]
1508 fn health_markdown_truncated_findings_shown_count() {
1509 let root = PathBuf::from("/project");
1510 let report = crate::health_types::HealthReport {
1511 findings: vec![crate::health_types::HealthFinding {
1512 path: root.join("src/x.ts"),
1513 name: "f".to_string(),
1514 line: 1,
1515 col: 0,
1516 cyclomatic: 25,
1517 cognitive: 20,
1518 line_count: 10,
1519 exceeded: crate::health_types::ExceededThreshold::Both,
1520 }],
1521 summary: crate::health_types::HealthSummary {
1522 files_analyzed: 10,
1523 functions_analyzed: 50,
1524 functions_above_threshold: 5, max_cyclomatic_threshold: 20,
1526 max_cognitive_threshold: 15,
1527 files_scored: None,
1528 average_maintainability: None,
1529 },
1530 vital_signs: None,
1531 health_score: None,
1532 file_scores: vec![],
1533 hotspots: vec![],
1534 hotspot_summary: None,
1535 targets: vec![],
1536 target_thresholds: None,
1537 health_trend: None,
1538 };
1539 let md = build_health_markdown(&report, &root);
1540 assert!(md.contains("5 high complexity functions (1 shown)"));
1541 }
1542
1543 #[test]
1546 fn escape_backticks_handles_multiple() {
1547 assert_eq!(escape_backticks("a`b`c"), "a\\`b\\`c");
1548 }
1549
1550 #[test]
1551 fn escape_backticks_no_backticks_unchanged() {
1552 assert_eq!(escape_backticks("hello"), "hello");
1553 }
1554
1555 #[test]
1558 fn markdown_unresolved_import_grouped_by_file() {
1559 let root = PathBuf::from("/project");
1560 let mut results = AnalysisResults::default();
1561 results.unresolved_imports.push(UnresolvedImport {
1562 path: root.join("src/app.ts"),
1563 specifier: "./missing".to_string(),
1564 line: 3,
1565 col: 0,
1566 specifier_col: 0,
1567 });
1568 let md = build_markdown(&results, &root);
1569 assert!(md.contains("### Unresolved imports (1)"));
1570 assert!(md.contains("- `src/app.ts`"));
1571 assert!(md.contains(":3 `./missing`"));
1572 }
1573
1574 #[test]
1577 fn markdown_unused_optional_dep() {
1578 let root = PathBuf::from("/project");
1579 let mut results = AnalysisResults::default();
1580 results.unused_optional_dependencies.push(UnusedDependency {
1581 package_name: "fsevents".to_string(),
1582 location: DependencyLocation::OptionalDependencies,
1583 path: root.join("package.json"),
1584 line: 12,
1585 });
1586 let md = build_markdown(&results, &root);
1587 assert!(md.contains("### Unused optionalDependencies (1)"));
1588 assert!(md.contains("- `fsevents`"));
1589 }
1590
1591 #[test]
1594 fn health_markdown_hotspots_no_excluded_message() {
1595 let root = PathBuf::from("/project");
1596 let report = crate::health_types::HealthReport {
1597 findings: vec![crate::health_types::HealthFinding {
1598 path: root.join("src/x.ts"),
1599 name: "f".to_string(),
1600 line: 1,
1601 col: 0,
1602 cyclomatic: 25,
1603 cognitive: 20,
1604 line_count: 10,
1605 exceeded: crate::health_types::ExceededThreshold::Both,
1606 }],
1607 summary: crate::health_types::HealthSummary {
1608 files_analyzed: 5,
1609 functions_analyzed: 10,
1610 functions_above_threshold: 1,
1611 max_cyclomatic_threshold: 20,
1612 max_cognitive_threshold: 15,
1613 files_scored: None,
1614 average_maintainability: None,
1615 },
1616 vital_signs: None,
1617 health_score: None,
1618 file_scores: vec![],
1619 hotspots: vec![crate::health_types::HotspotEntry {
1620 path: root.join("src/hot.ts"),
1621 score: 50.0,
1622 commits: 10,
1623 weighted_commits: 8.0,
1624 lines_added: 100,
1625 lines_deleted: 50,
1626 complexity_density: 0.5,
1627 fan_in: 3,
1628 trend: fallow_core::churn::ChurnTrend::Stable,
1629 }],
1630 hotspot_summary: Some(crate::health_types::HotspotSummary {
1631 since: "6 months".to_string(),
1632 min_commits: 3,
1633 files_analyzed: 50,
1634 files_excluded: 0,
1635 shallow_clone: false,
1636 }),
1637 targets: vec![],
1638 target_thresholds: None,
1639 health_trend: None,
1640 };
1641 let md = build_health_markdown(&report, &root);
1642 assert!(!md.contains("files excluded"));
1643 }
1644
1645 #[test]
1648 fn duplication_markdown_single_group_no_plural() {
1649 let root = PathBuf::from("/project");
1650 let report = DuplicationReport {
1651 clone_groups: vec![CloneGroup {
1652 instances: vec![CloneInstance {
1653 file: root.join("src/a.ts"),
1654 start_line: 1,
1655 end_line: 5,
1656 start_col: 0,
1657 end_col: 0,
1658 fragment: String::new(),
1659 }],
1660 token_count: 30,
1661 line_count: 5,
1662 }],
1663 clone_families: vec![],
1664 stats: DuplicationStats {
1665 clone_groups: 1,
1666 clone_instances: 1,
1667 duplication_percentage: 2.0,
1668 ..Default::default()
1669 },
1670 };
1671 let md = build_duplication_markdown(&report, &root);
1672 assert!(md.contains("1 clone group found"));
1673 assert!(!md.contains("1 clone groups found"));
1674 }
1675}