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, 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!(
35 out,
36 "## Fallow: {total} issue{} found\n\n",
37 if total == 1 { "" } else { "s" }
38 );
39
40 markdown_section(&mut out, &results.unused_files, "Unused files", |file| {
42 vec![format!("- `{}`", rel(&file.path))]
43 });
44
45 markdown_grouped_section(
47 &mut out,
48 &results.unused_exports,
49 "Unused exports",
50 root,
51 |e| e.path.as_path(),
52 format_export,
53 );
54
55 markdown_grouped_section(
57 &mut out,
58 &results.unused_types,
59 "Unused type exports",
60 root,
61 |e| e.path.as_path(),
62 format_export,
63 );
64
65 markdown_section(
67 &mut out,
68 &results.unused_dependencies,
69 "Unused dependencies",
70 |dep| format_dependency(&dep.package_name, &dep.path, root),
71 );
72
73 markdown_section(
75 &mut out,
76 &results.unused_dev_dependencies,
77 "Unused devDependencies",
78 |dep| format_dependency(&dep.package_name, &dep.path, root),
79 );
80
81 markdown_section(
83 &mut out,
84 &results.unused_optional_dependencies,
85 "Unused optionalDependencies",
86 |dep| format_dependency(&dep.package_name, &dep.path, root),
87 );
88
89 markdown_grouped_section(
91 &mut out,
92 &results.unused_enum_members,
93 "Unused enum members",
94 root,
95 |m| m.path.as_path(),
96 format_member,
97 );
98
99 markdown_grouped_section(
101 &mut out,
102 &results.unused_class_members,
103 "Unused class members",
104 root,
105 |m| m.path.as_path(),
106 format_member,
107 );
108
109 markdown_grouped_section(
111 &mut out,
112 &results.unresolved_imports,
113 "Unresolved imports",
114 root,
115 |i| i.path.as_path(),
116 |i| format!(":{} `{}`", i.line, escape_backticks(&i.specifier)),
117 );
118
119 markdown_section(
121 &mut out,
122 &results.unlisted_dependencies,
123 "Unlisted dependencies",
124 |dep| vec![format!("- `{}`", escape_backticks(&dep.package_name))],
125 );
126
127 markdown_section(
129 &mut out,
130 &results.duplicate_exports,
131 "Duplicate exports",
132 |dup| {
133 let locations: Vec<String> = dup
134 .locations
135 .iter()
136 .map(|loc| format!("`{}`", rel(&loc.path)))
137 .collect();
138 vec![format!(
139 "- `{}` in {}",
140 escape_backticks(&dup.export_name),
141 locations.join(", ")
142 )]
143 },
144 );
145
146 markdown_section(
148 &mut out,
149 &results.type_only_dependencies,
150 "Type-only dependencies (consider moving to devDependencies)",
151 |dep| format_dependency(&dep.package_name, &dep.path, root),
152 );
153
154 markdown_section(
156 &mut out,
157 &results.circular_dependencies,
158 "Circular dependencies",
159 |cycle| {
160 let chain: Vec<String> = cycle.files.iter().map(|p| rel(p)).collect();
161 let mut display_chain = chain.clone();
162 if let Some(first) = chain.first() {
163 display_chain.push(first.clone());
164 }
165 vec![format!(
166 "- {}",
167 display_chain
168 .iter()
169 .map(|s| format!("`{s}`"))
170 .collect::<Vec<_>>()
171 .join(" \u{2192} ")
172 )]
173 },
174 );
175
176 out
177}
178
179fn format_export(e: &UnusedExport) -> String {
180 let re = if e.is_re_export { " (re-export)" } else { "" };
181 format!(":{} `{}`{re}", e.line, escape_backticks(&e.export_name))
182}
183
184fn format_member(m: &UnusedMember) -> String {
185 format!(
186 ":{} `{}.{}`",
187 m.line,
188 escape_backticks(&m.parent_name),
189 escape_backticks(&m.member_name)
190 )
191}
192
193fn format_dependency(dep_name: &str, pkg_path: &Path, root: &Path) -> Vec<String> {
194 let name = escape_backticks(dep_name);
195 let pkg_label = relative_path(pkg_path, root).display().to_string();
196 if pkg_label == "package.json" {
197 vec![format!("- `{name}`")]
198 } else {
199 let label = escape_backticks(&pkg_label);
200 vec![format!("- `{name}` ({label})")]
201 }
202}
203
204fn markdown_section<T>(
206 out: &mut String,
207 items: &[T],
208 title: &str,
209 format_lines: impl Fn(&T) -> Vec<String>,
210) {
211 if items.is_empty() {
212 return;
213 }
214 let _ = write!(out, "### {title} ({})\n\n", items.len());
215 for item in items {
216 for line in format_lines(item) {
217 out.push_str(&line);
218 out.push('\n');
219 }
220 }
221 out.push('\n');
222}
223
224fn markdown_grouped_section<'a, T>(
226 out: &mut String,
227 items: &'a [T],
228 title: &str,
229 root: &Path,
230 get_path: impl Fn(&'a T) -> &'a Path,
231 format_detail: impl Fn(&T) -> String,
232) {
233 if items.is_empty() {
234 return;
235 }
236 let _ = write!(out, "### {title} ({})\n\n", items.len());
237
238 let mut indices: Vec<usize> = (0..items.len()).collect();
239 indices.sort_by(|&a, &b| get_path(&items[a]).cmp(get_path(&items[b])));
240
241 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
242 let mut last_file = String::new();
243 for &i in &indices {
244 let item = &items[i];
245 let file_str = rel(get_path(item));
246 if file_str != last_file {
247 let _ = writeln!(out, "- `{file_str}`");
248 last_file = file_str;
249 }
250 let _ = writeln!(out, " - {}", format_detail(item));
251 }
252 out.push('\n');
253}
254
255pub(super) fn print_duplication_markdown(report: &DuplicationReport, root: &Path) {
258 println!("{}", build_duplication_markdown(report, root));
259}
260
261pub fn build_duplication_markdown(report: &DuplicationReport, root: &Path) -> String {
263 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
264
265 let mut out = String::new();
266
267 if report.clone_groups.is_empty() {
268 out.push_str("## Fallow: no code duplication found\n");
269 return out;
270 }
271
272 let stats = &report.stats;
273 let _ = write!(
274 out,
275 "## Fallow: {} clone group{} found ({:.1}% duplication)\n\n",
276 stats.clone_groups,
277 if stats.clone_groups == 1 { "" } else { "s" },
278 stats.duplication_percentage,
279 );
280
281 out.push_str("### Duplicates\n\n");
282 for (i, group) in report.clone_groups.iter().enumerate() {
283 let instance_count = group.instances.len();
284 let _ = write!(
285 out,
286 "**Clone group {}** ({} lines, {instance_count} instance{})\n\n",
287 i + 1,
288 group.line_count,
289 if instance_count == 1 { "" } else { "s" }
290 );
291 for instance in &group.instances {
292 let relative = rel(&instance.file);
293 let _ = writeln!(
294 out,
295 "- `{relative}:{}-{}`",
296 instance.start_line, instance.end_line
297 );
298 }
299 out.push('\n');
300 }
301
302 if !report.clone_families.is_empty() {
304 out.push_str("### Clone Families\n\n");
305 for (i, family) in report.clone_families.iter().enumerate() {
306 let file_names: Vec<_> = family.files.iter().map(|f| rel(f)).collect();
307 let _ = write!(
308 out,
309 "**Family {}** ({} group{}, {} lines across {})\n\n",
310 i + 1,
311 family.groups.len(),
312 if family.groups.len() == 1 { "" } else { "s" },
313 family.total_duplicated_lines,
314 file_names
315 .iter()
316 .map(|s| format!("`{s}`"))
317 .collect::<Vec<_>>()
318 .join(", "),
319 );
320 for suggestion in &family.suggestions {
321 let savings = if suggestion.estimated_savings > 0 {
322 format!(" (~{} lines saved)", suggestion.estimated_savings)
323 } else {
324 String::new()
325 };
326 let _ = writeln!(out, "- {}{savings}", suggestion.description);
327 }
328 out.push('\n');
329 }
330 }
331
332 let _ = writeln!(
334 out,
335 "**Summary:** {} duplicated lines ({:.1}%) across {} file{}",
336 stats.duplicated_lines,
337 stats.duplication_percentage,
338 stats.files_with_clones,
339 if stats.files_with_clones == 1 {
340 ""
341 } else {
342 "s"
343 },
344 );
345
346 out
347}
348
349pub(super) fn print_health_markdown(report: &crate::health_types::HealthReport, root: &Path) {
352 println!("{}", build_health_markdown(report, root));
353}
354
355pub fn build_health_markdown(report: &crate::health_types::HealthReport, root: &Path) -> String {
357 let rel = |p: &Path| {
358 escape_backticks(&normalize_uri(
359 &relative_path(p, root).display().to_string(),
360 ))
361 };
362
363 let mut out = String::new();
364
365 if let Some(ref vs) = report.vital_signs {
367 out.push_str("## Vital Signs\n\n");
368 out.push_str("| Metric | Value |\n");
369 out.push_str("|:-------|------:|\n");
370 let _ = writeln!(out, "| Avg Cyclomatic | {:.1} |", vs.avg_cyclomatic);
371 let _ = writeln!(out, "| P90 Cyclomatic | {} |", vs.p90_cyclomatic);
372 if let Some(v) = vs.dead_file_pct {
373 let _ = writeln!(out, "| Dead Files | {v:.1}% |");
374 }
375 if let Some(v) = vs.dead_export_pct {
376 let _ = writeln!(out, "| Dead Exports | {v:.1}% |");
377 }
378 if let Some(v) = vs.maintainability_avg {
379 let _ = writeln!(out, "| Maintainability (avg) | {v:.1} |");
380 }
381 if let Some(v) = vs.hotspot_count {
382 let _ = writeln!(out, "| Hotspots | {v} |");
383 }
384 if let Some(v) = vs.circular_dep_count {
385 let _ = writeln!(out, "| Circular Deps | {v} |");
386 }
387 if let Some(v) = vs.unused_dep_count {
388 let _ = writeln!(out, "| Unused Deps | {v} |");
389 }
390 out.push('\n');
391 }
392
393 if report.findings.is_empty()
394 && report.file_scores.is_empty()
395 && report.hotspots.is_empty()
396 && report.targets.is_empty()
397 {
398 if report.vital_signs.is_none() {
399 let _ = write!(
400 out,
401 "## Fallow: no functions exceed complexity thresholds\n\n\
402 **{}** functions analyzed (max cyclomatic: {}, max cognitive: {})\n",
403 report.summary.functions_analyzed,
404 report.summary.max_cyclomatic_threshold,
405 report.summary.max_cognitive_threshold,
406 );
407 }
408 return out;
409 }
410
411 if !report.findings.is_empty() {
412 let count = report.summary.functions_above_threshold;
413 let shown = report.findings.len();
414 if shown < count {
415 let _ = write!(
416 out,
417 "## Fallow: {count} high complexity function{} ({shown} shown)\n\n",
418 if count == 1 { "" } else { "s" },
419 );
420 } else {
421 let _ = write!(
422 out,
423 "## Fallow: {count} high complexity function{}\n\n",
424 if count == 1 { "" } else { "s" },
425 );
426 }
427
428 out.push_str("| File | Function | Cyclomatic | Cognitive | Lines |\n");
429 out.push_str("|:-----|:---------|:-----------|:----------|:------|\n");
430
431 for finding in &report.findings {
432 let file_str = rel(&finding.path);
433 let cyc_marker = if finding.cyclomatic > report.summary.max_cyclomatic_threshold {
434 " **!**"
435 } else {
436 ""
437 };
438 let cog_marker = if finding.cognitive > report.summary.max_cognitive_threshold {
439 " **!**"
440 } else {
441 ""
442 };
443 let _ = writeln!(
444 out,
445 "| `{file_str}:{line}` | `{name}` | {cyc}{cyc_marker} | {cog}{cog_marker} | {lines} |",
446 line = finding.line,
447 name = escape_backticks(&finding.name),
448 cyc = finding.cyclomatic,
449 cog = finding.cognitive,
450 lines = finding.line_count,
451 );
452 }
453
454 let s = &report.summary;
455 let _ = write!(
456 out,
457 "\n**{files}** files, **{funcs}** functions analyzed \
458 (thresholds: cyclomatic > {cyc}, cognitive > {cog})\n",
459 files = s.files_analyzed,
460 funcs = s.functions_analyzed,
461 cyc = s.max_cyclomatic_threshold,
462 cog = s.max_cognitive_threshold,
463 );
464 }
465
466 if !report.file_scores.is_empty() {
468 out.push('\n');
469 let _ = writeln!(
470 out,
471 "### File Health Scores ({} files)\n",
472 report.file_scores.len(),
473 );
474 out.push_str("| File | MI | Fan-in | Fan-out | Dead Code | Density |\n");
475 out.push_str("|:-----|:---|:-------|:--------|:----------|:--------|\n");
476
477 for score in &report.file_scores {
478 let file_str = rel(&score.path);
479 let _ = writeln!(
480 out,
481 "| `{file_str}` | {mi:.1} | {fi} | {fan_out} | {dead:.0}% | {density:.2} |",
482 mi = score.maintainability_index,
483 fi = score.fan_in,
484 fan_out = score.fan_out,
485 dead = score.dead_code_ratio * 100.0,
486 density = score.complexity_density,
487 );
488 }
489
490 if let Some(avg) = report.summary.average_maintainability {
491 let _ = write!(out, "\n**Average maintainability index:** {avg:.1}/100\n");
492 }
493 }
494
495 if !report.hotspots.is_empty() {
497 out.push('\n');
498 let header = if let Some(ref summary) = report.hotspot_summary {
499 format!(
500 "### Hotspots ({} files, since {})\n",
501 report.hotspots.len(),
502 summary.since,
503 )
504 } else {
505 format!("### Hotspots ({} files)\n", report.hotspots.len())
506 };
507 let _ = writeln!(out, "{header}");
508 out.push_str("| File | Score | Commits | Churn | Density | Fan-in | Trend |\n");
509 out.push_str("|:-----|:------|:--------|:------|:--------|:-------|:------|\n");
510
511 for entry in &report.hotspots {
512 let file_str = rel(&entry.path);
513 let _ = writeln!(
514 out,
515 "| `{file_str}` | {score:.1} | {commits} | {churn} | {density:.2} | {fi} | {trend} |",
516 score = entry.score,
517 commits = entry.commits,
518 churn = entry.lines_added + entry.lines_deleted,
519 density = entry.complexity_density,
520 fi = entry.fan_in,
521 trend = entry.trend,
522 );
523 }
524
525 if let Some(ref summary) = report.hotspot_summary
526 && summary.files_excluded > 0
527 {
528 let _ = write!(
529 out,
530 "\n*{} file{} excluded (< {} commits)*\n",
531 summary.files_excluded,
532 if summary.files_excluded == 1 { "" } else { "s" },
533 summary.min_commits,
534 );
535 }
536 }
537
538 if !report.targets.is_empty() {
540 let _ = write!(
541 out,
542 "\n### Refactoring Targets ({})\n\n",
543 report.targets.len()
544 );
545 out.push_str("| Efficiency | Category | Effort / Confidence | File | Recommendation |\n");
546 out.push_str("|:-----------|:---------|:--------------------|:-----|:---------------|\n");
547 for target in &report.targets {
548 let file_str = normalize_uri(&relative_path(&target.path, root).display().to_string());
549 let category = target.category.label();
550 let effort = target.effort.label();
551 let confidence = target.confidence.label();
552 let _ = writeln!(
553 out,
554 "| {:.1} | {category} | {effort} / {confidence} | `{file_str}` | {} |",
555 target.efficiency, target.recommendation,
556 );
557 }
558 }
559
560 let has_scores = !report.file_scores.is_empty();
562 let has_hotspots = !report.hotspots.is_empty();
563 let has_targets = !report.targets.is_empty();
564 if has_scores || has_hotspots || has_targets {
565 out.push_str("\n---\n\n<details><summary>Metric definitions</summary>\n\n");
566 if has_scores {
567 out.push_str("- **MI** — Maintainability Index (0\u{2013}100, higher is better)\n");
568 out.push_str("- **Fan-in** — files that import this file (blast radius)\n");
569 out.push_str("- **Fan-out** — files this file imports (coupling)\n");
570 out.push_str("- **Dead Code** — % of value exports with zero references\n");
571 out.push_str("- **Density** — cyclomatic complexity / lines of code\n");
572 }
573 if has_hotspots {
574 out.push_str(
575 "- **Score** — churn \u{00d7} complexity (0\u{2013}100, higher = riskier)\n",
576 );
577 out.push_str("- **Commits** — commits in the analysis window\n");
578 out.push_str("- **Churn** — total lines added + deleted\n");
579 out.push_str("- **Trend** — accelerating / stable / cooling\n");
580 }
581 if has_targets {
582 out.push_str("- **Efficiency** — priority / effort (higher = better quick-win value, default sort)\n");
583 out.push_str("- **Category** — recommendation type (churn+complexity, high impact, dead code, complexity, coupling, circular dep)\n");
584 out.push_str("- **Effort** — estimated effort (low / medium / high) based on file size, function count, and fan-in\n");
585 out.push_str("- **Confidence** — recommendation reliability (high = deterministic analysis, medium = heuristic, low = git-dependent)\n");
586 }
587 out.push_str("\n[Full metric reference](https://docs.fallow.tools/explanations/metrics)\n\n</details>\n");
588 }
589
590 out
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596 use fallow_core::duplicates::{
597 CloneFamily, CloneGroup, CloneInstance, DuplicationReport, DuplicationStats,
598 RefactoringKind, RefactoringSuggestion,
599 };
600 use fallow_core::extract::MemberKind;
601 use fallow_core::results::*;
602 use std::path::PathBuf;
603
604 fn sample_results(root: &Path) -> AnalysisResults {
606 let mut r = AnalysisResults::default();
607
608 r.unused_files.push(UnusedFile {
609 path: root.join("src/dead.ts"),
610 });
611 r.unused_exports.push(UnusedExport {
612 path: root.join("src/utils.ts"),
613 export_name: "helperFn".to_string(),
614 is_type_only: false,
615 line: 10,
616 col: 4,
617 span_start: 120,
618 is_re_export: false,
619 });
620 r.unused_types.push(UnusedExport {
621 path: root.join("src/types.ts"),
622 export_name: "OldType".to_string(),
623 is_type_only: true,
624 line: 5,
625 col: 0,
626 span_start: 60,
627 is_re_export: false,
628 });
629 r.unused_dependencies.push(UnusedDependency {
630 package_name: "lodash".to_string(),
631 location: DependencyLocation::Dependencies,
632 path: root.join("package.json"),
633 line: 5,
634 });
635 r.unused_dev_dependencies.push(UnusedDependency {
636 package_name: "jest".to_string(),
637 location: DependencyLocation::DevDependencies,
638 path: root.join("package.json"),
639 line: 5,
640 });
641 r.unused_enum_members.push(UnusedMember {
642 path: root.join("src/enums.ts"),
643 parent_name: "Status".to_string(),
644 member_name: "Deprecated".to_string(),
645 kind: MemberKind::EnumMember,
646 line: 8,
647 col: 2,
648 });
649 r.unused_class_members.push(UnusedMember {
650 path: root.join("src/service.ts"),
651 parent_name: "UserService".to_string(),
652 member_name: "legacyMethod".to_string(),
653 kind: MemberKind::ClassMethod,
654 line: 42,
655 col: 4,
656 });
657 r.unresolved_imports.push(UnresolvedImport {
658 path: root.join("src/app.ts"),
659 specifier: "./missing-module".to_string(),
660 line: 3,
661 col: 0,
662 specifier_col: 0,
663 });
664 r.unlisted_dependencies.push(UnlistedDependency {
665 package_name: "chalk".to_string(),
666 imported_from: vec![ImportSite {
667 path: root.join("src/cli.ts"),
668 line: 2,
669 col: 0,
670 }],
671 });
672 r.duplicate_exports.push(DuplicateExport {
673 export_name: "Config".to_string(),
674 locations: vec![
675 DuplicateLocation {
676 path: root.join("src/config.ts"),
677 line: 15,
678 col: 0,
679 },
680 DuplicateLocation {
681 path: root.join("src/types.ts"),
682 line: 30,
683 col: 0,
684 },
685 ],
686 });
687 r.type_only_dependencies.push(TypeOnlyDependency {
688 package_name: "zod".to_string(),
689 path: root.join("package.json"),
690 line: 8,
691 });
692 r.circular_dependencies.push(CircularDependency {
693 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
694 length: 2,
695 line: 3,
696 col: 0,
697 });
698
699 r
700 }
701
702 #[test]
703 fn markdown_empty_results_no_issues() {
704 let root = PathBuf::from("/project");
705 let results = AnalysisResults::default();
706 let md = build_markdown(&results, &root);
707 assert_eq!(md, "## Fallow: no issues found\n");
708 }
709
710 #[test]
711 fn markdown_contains_header_with_count() {
712 let root = PathBuf::from("/project");
713 let results = sample_results(&root);
714 let md = build_markdown(&results, &root);
715 assert!(md.starts_with(&format!(
716 "## Fallow: {} issues found\n",
717 results.total_issues()
718 )));
719 }
720
721 #[test]
722 fn markdown_contains_all_sections() {
723 let root = PathBuf::from("/project");
724 let results = sample_results(&root);
725 let md = build_markdown(&results, &root);
726
727 assert!(md.contains("### Unused files (1)"));
728 assert!(md.contains("### Unused exports (1)"));
729 assert!(md.contains("### Unused type exports (1)"));
730 assert!(md.contains("### Unused dependencies (1)"));
731 assert!(md.contains("### Unused devDependencies (1)"));
732 assert!(md.contains("### Unused enum members (1)"));
733 assert!(md.contains("### Unused class members (1)"));
734 assert!(md.contains("### Unresolved imports (1)"));
735 assert!(md.contains("### Unlisted dependencies (1)"));
736 assert!(md.contains("### Duplicate exports (1)"));
737 assert!(md.contains("### Type-only 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 file_scores: vec![],
1001 hotspots: vec![],
1002 hotspot_summary: None,
1003 targets: vec![],
1004 target_thresholds: None,
1005 };
1006 let md = build_health_markdown(&report, &root);
1007 assert!(md.contains("no functions exceed complexity thresholds"));
1008 assert!(md.contains("**50** functions analyzed"));
1009 }
1010
1011 #[test]
1012 fn health_markdown_table_format() {
1013 let root = PathBuf::from("/project");
1014 let report = crate::health_types::HealthReport {
1015 findings: vec![crate::health_types::HealthFinding {
1016 path: root.join("src/utils.ts"),
1017 name: "parseExpression".to_string(),
1018 line: 42,
1019 col: 0,
1020 cyclomatic: 25,
1021 cognitive: 30,
1022 line_count: 80,
1023 exceeded: crate::health_types::ExceededThreshold::Both,
1024 }],
1025 summary: crate::health_types::HealthSummary {
1026 files_analyzed: 10,
1027 functions_analyzed: 50,
1028 functions_above_threshold: 1,
1029 max_cyclomatic_threshold: 20,
1030 max_cognitive_threshold: 15,
1031 files_scored: None,
1032 average_maintainability: None,
1033 },
1034 vital_signs: None,
1035 file_scores: vec![],
1036 hotspots: vec![],
1037 hotspot_summary: None,
1038 targets: vec![],
1039 target_thresholds: None,
1040 };
1041 let md = build_health_markdown(&report, &root);
1042 assert!(md.contains("## Fallow: 1 high complexity function\n"));
1043 assert!(md.contains("| File | Function |"));
1044 assert!(md.contains("`src/utils.ts:42`"));
1045 assert!(md.contains("`parseExpression`"));
1046 assert!(md.contains("25 **!**"));
1047 assert!(md.contains("30 **!**"));
1048 assert!(md.contains("| 80 |"));
1049 }
1050
1051 #[test]
1052 fn health_markdown_no_marker_when_below_threshold() {
1053 let root = PathBuf::from("/project");
1054 let report = crate::health_types::HealthReport {
1055 findings: vec![crate::health_types::HealthFinding {
1056 path: root.join("src/utils.ts"),
1057 name: "helper".to_string(),
1058 line: 10,
1059 col: 0,
1060 cyclomatic: 15,
1061 cognitive: 20,
1062 line_count: 30,
1063 exceeded: crate::health_types::ExceededThreshold::Cognitive,
1064 }],
1065 summary: crate::health_types::HealthSummary {
1066 files_analyzed: 5,
1067 functions_analyzed: 20,
1068 functions_above_threshold: 1,
1069 max_cyclomatic_threshold: 20,
1070 max_cognitive_threshold: 15,
1071 files_scored: None,
1072 average_maintainability: None,
1073 },
1074 vital_signs: None,
1075 file_scores: vec![],
1076 hotspots: vec![],
1077 hotspot_summary: None,
1078 targets: vec![],
1079 target_thresholds: None,
1080 };
1081 let md = build_health_markdown(&report, &root);
1082 assert!(md.contains("| 15 |"));
1084 assert!(md.contains("20 **!**"));
1086 }
1087
1088 #[test]
1089 fn health_markdown_with_targets() {
1090 use crate::health_types::*;
1091
1092 let root = PathBuf::from("/project");
1093 let report = HealthReport {
1094 findings: vec![],
1095 summary: HealthSummary {
1096 files_analyzed: 10,
1097 functions_analyzed: 50,
1098 functions_above_threshold: 0,
1099 max_cyclomatic_threshold: 20,
1100 max_cognitive_threshold: 15,
1101 files_scored: None,
1102 average_maintainability: None,
1103 },
1104 vital_signs: None,
1105 file_scores: vec![],
1106 hotspots: vec![],
1107 hotspot_summary: None,
1108 targets: vec![
1109 RefactoringTarget {
1110 path: PathBuf::from("/project/src/complex.ts"),
1111 priority: 82.5,
1112 efficiency: 27.5,
1113 recommendation: "Split high-impact file".into(),
1114 category: RecommendationCategory::SplitHighImpact,
1115 effort: crate::health_types::EffortEstimate::High,
1116 confidence: crate::health_types::Confidence::Medium,
1117 factors: vec![ContributingFactor {
1118 metric: "fan_in",
1119 value: 25.0,
1120 threshold: 10.0,
1121 detail: "25 files depend on this".into(),
1122 }],
1123 evidence: None,
1124 },
1125 RefactoringTarget {
1126 path: PathBuf::from("/project/src/legacy.ts"),
1127 priority: 45.0,
1128 efficiency: 45.0,
1129 recommendation: "Remove 5 unused exports".into(),
1130 category: RecommendationCategory::RemoveDeadCode,
1131 effort: crate::health_types::EffortEstimate::Low,
1132 confidence: crate::health_types::Confidence::High,
1133 factors: vec![],
1134 evidence: None,
1135 },
1136 ],
1137 target_thresholds: None,
1138 };
1139 let md = build_health_markdown(&report, &root);
1140
1141 assert!(
1143 md.contains("Refactoring Targets"),
1144 "should contain targets heading"
1145 );
1146 assert!(
1147 md.contains("src/complex.ts"),
1148 "should contain target file path"
1149 );
1150 assert!(md.contains("27.5"), "should contain efficiency score");
1151 assert!(
1152 md.contains("Split high-impact file"),
1153 "should contain recommendation"
1154 );
1155 assert!(md.contains("src/legacy.ts"), "should contain second target");
1156 }
1157}