use crate::models::{Finding, HealthReport, Severity};
use anyhow::Result;
use chrono::Local;
const MAX_FINDINGS_PER_SEVERITY: usize = 10;
pub fn render(report: &HealthReport) -> Result<String> {
let mut md = String::new();
md.push_str(&render_header(report));
md.push('\n');
md.push_str(&render_toc());
md.push('\n');
md.push_str(&render_summary(report));
md.push('\n');
md.push_str(&render_category_scores(report));
md.push('\n');
md.push_str(&render_metrics(report));
md.push('\n');
md.push_str(&render_findings_summary(report));
md.push('\n');
md.push_str(&render_detailed_findings(report));
md.push('\n');
md.push_str(&render_footer());
Ok(md)
}
fn render_header(report: &HealthReport) -> String {
let grade_emoji = match report.grade.as_str() {
"A" => "đ",
"B" => "â",
"C" => "â ī¸",
"D" => "â",
"F" => "đ",
_ => "â",
};
let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S");
format!(
r#"# {} Repotoire Code Health Report
**Grade: {}** | **Score: {:.1}/100**
Generated: {}
"#,
grade_emoji, report.grade, report.overall_score, timestamp
)
}
fn render_toc() -> String {
r#"## Table of Contents
- [Summary](#summary)
- [Category Scores](#category-scores)
- [Key Metrics](#key-metrics)
- [Findings Summary](#findings-summary)
- [Detailed Findings](#detailed-findings)
"#
.to_string()
}
fn render_summary(report: &HealthReport) -> String {
let assessment = match report.grade.as_str() {
"A" => "Excellent - Code is well-structured and maintainable",
"B" => "Good - Minor improvements recommended",
"C" => "Fair - Several issues should be addressed",
"D" => "Poor - Significant refactoring needed",
"F" => "Critical - Major technical debt present",
_ => "",
};
format!(
r#"## Summary
| Metric | Value |
|--------|-------|
| **Overall Grade** | {} |
| **Overall Score** | {:.1}/100 |
| **Total Findings** | {} |
| **Assessment** | {} |
"#,
report.grade, report.overall_score, report.findings_summary.total, assessment
)
}
fn render_category_scores(report: &HealthReport) -> String {
let arch = report.architecture_score.unwrap_or(0.0);
format!(
r#"## Category Scores
| Category | Weight | Score | Status |
|----------|--------|-------|--------|
| Graph Structure | 40% | {:.1}/100 | {} |
| Code Quality | 30% | {:.1}/100 | {} |
| Architecture Health | 30% | {:.1}/100 | {} |
"#,
report.structure_score,
score_indicator(report.structure_score),
report.quality_score,
score_indicator(report.quality_score),
arch,
score_indicator(arch)
)
}
fn render_metrics(report: &HealthReport) -> String {
format!(
r#"## Key Metrics
### Codebase Size
| Metric | Value |
|--------|-------|
| Total Files | {} |
| Total Functions | {} |
| Total Classes | {} |
"#,
report.total_files, report.total_functions, report.total_classes
)
}
fn render_findings_summary(report: &HealthReport) -> String {
let fs = &report.findings_summary;
format!(
r#"## Findings Summary
| Severity | Count | Emoji |
|----------|-------|-------|
| Critical | {} | đ´ |
| High | {} | đ |
| Medium | {} | đĄ |
| Low | {} | đĩ |
| Info | {} | âšī¸ |
| **Total** | **{}** | |
"#,
fs.critical, fs.high, fs.medium, fs.low, fs.info, fs.total
)
}
fn render_detailed_findings(report: &HealthReport) -> String {
let mut md = String::from("## Detailed Findings\n\n");
if report.findings.is_empty() {
md.push_str("â
No issues found! Your codebase is in great shape.\n");
return md;
}
let severity_order = [
Severity::Critical,
Severity::High,
Severity::Medium,
Severity::Low,
Severity::Info,
];
for severity in severity_order {
let findings: Vec<&Finding> = report
.findings
.iter()
.filter(|f| f.severity == severity)
.collect();
if findings.is_empty() {
continue;
}
let emoji = severity_emoji(&severity);
let label = severity.to_string();
md.push_str(&format!(
"### {} {} Findings ({})\n\n",
emoji,
capitalize(&label),
findings.len()
));
let shown: Vec<_> = findings.iter().take(MAX_FINDINGS_PER_SEVERITY).collect();
let hidden = findings.len().saturating_sub(MAX_FINDINGS_PER_SEVERITY);
for finding in shown {
md.push_str(&render_finding(finding));
}
if hidden > 0 {
md.push_str(&format!("*...and {} more {} findings*\n\n", hidden, label));
}
}
md
}
fn render_finding(finding: &Finding) -> String {
let mut md = String::new();
let detector = finding.detector.replace("Detector", "");
md.push_str(&format!("#### {}\n\n", finding.title));
md.push_str(&format!("`{}` ", detector));
if let Some(line) = finding.line_start {
if let Some(file) = finding.affected_files.first() {
md.push_str(&format!("at `{}:{}`", file.display(), line));
}
}
md.push_str("\n\n");
if !finding.description.is_empty() {
md.push_str(&format!("{}\n\n", finding.description));
}
if !finding.affected_files.is_empty() {
let files: Vec<String> = finding
.affected_files
.iter()
.take(5)
.map(|f| format!("`{}`", f.display()))
.collect();
let more = if finding.affected_files.len() > 5 {
format!(" (+{} more)", finding.affected_files.len() - 5)
} else {
String::new()
};
md.push_str(&format!("**Files:** {}{}\n\n", files.join(", "), more));
}
if let Some(fix) = &finding.suggested_fix {
md.push_str(&format!("> **đĄ Fix:** {}\n\n", fix));
}
md
}
fn render_footer() -> String {
r#"---
*Generated by [Repotoire](https://repotoire.com) - Graph-Powered Code Health Platform*
"#
.to_string()
}
fn score_indicator(score: f64) -> &'static str {
if score >= 80.0 {
"â
Good"
} else if score >= 60.0 {
"â ī¸ Fair"
} else {
"â Poor"
}
}
fn severity_emoji(severity: &Severity) -> &'static str {
match severity {
Severity::Critical => "đ´",
Severity::High => "đ ",
Severity::Medium => "đĄ",
Severity::Low => "đĩ",
Severity::Info => "âšī¸",
}
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
}
}