use crate::report::errors::Result;
use super::model::AuthorDrilldownData;
pub fn format_markdown(data: &AuthorDrilldownData) -> String {
let mut out = String::new();
let period_str = match (&data.period.since, &data.period.until) {
(Some(s), Some(u)) => format!("{s} – {u}"),
(Some(s), None) => format!("{s} – present"),
(None, Some(u)) => format!("all history – {u}"),
(None, None) => "all history".to_string(),
};
let generated_date = data.generated_at.get(..10).unwrap_or(&data.generated_at);
out.push_str(&format!(
"# Engineer Report: {} <{}>\n",
data.name, data.email
));
out.push_str(&format!(
"Generated: {generated_date} | Period: {period_str}\n\n"
));
out.push_str("## Summary\n");
out.push_str("| Metric | Value |\n");
out.push_str("|-----------------|---------------------------|\n");
out.push_str(&format!(
"| Total commits | {:<25} |\n",
data.commits.total
));
let repos_str = data.commits.repositories.join(", ");
out.push_str(&format!(
"| Repositories | {:<25} |\n",
if repos_str.is_empty() {
"—".to_string()
} else {
repos_str
}
));
out.push_str(&format!(
"| First commit | {:<25} |\n",
data.commits
.first_commit
.as_deref()
.and_then(|s| s.get(..10))
.unwrap_or("—")
));
out.push_str(&format!(
"| Last commit | {:<25} |\n",
data.commits
.last_commit
.as_deref()
.and_then(|s| s.get(..10))
.unwrap_or("—")
));
let coverage_str = match (data.commits.total, data.commits.ticket_coverage) {
(0, _) => "no commits in scope".to_string(),
(total, Some(cov)) => {
format!(
"{} / {} ({:.0}%)",
data.commits.ticketed,
total,
cov * 100.0
)
}
(total, None) => format!("{} / {} (0%)", data.commits.ticketed, total),
};
out.push_str(&format!("| Ticket coverage | {:<25} |\n", coverage_str));
out.push('\n');
out.push_str(&format!(
"## Effort Histogram ({} / {} commits scored)\n",
data.effort.scored_commits, data.effort.total_commits
));
out.push_str("| Size | Count | % scored |\n");
out.push_str("|------|-------|----------|\n");
let scored = data.effort.scored_commits as f64;
for size in &["XS", "S", "M", "L", "XL"] {
let count = data.effort.histogram.get(*size).copied().unwrap_or(0);
let pct = if scored > 0.0 {
format!("{:.0}%", f64::from(count) / scored * 100.0)
} else {
"—".to_string()
};
out.push_str(&format!("| {:<4} | {:>5} | {:>8} |\n", size, count, pct));
}
out.push('\n');
out.push_str("## Pull Request Metrics\n");
out.push_str("| Metric | Value |\n");
out.push_str("|--------------------|-----------||\n");
out.push_str(&format!(
"| Total PRs | {:<9} |\n",
data.pull_requests.total
));
out.push_str(&format!(
"| Merged PRs | {:<9} |\n",
data.pull_requests.merged
));
let fmt_ct = |v: Option<f64>| -> String {
v.map(|h| format!("{h:.1} h"))
.unwrap_or_else(|| "—".to_string())
};
out.push_str(&format!(
"| Avg cycle time | {:<9} |\n",
fmt_ct(data.pull_requests.avg_cycle_time_hours)
));
out.push_str(&format!(
"| Median cycle time | {:<9} |\n",
fmt_ct(data.pull_requests.median_cycle_time_hours)
));
let p95_str = match data.pull_requests.p95_cycle_time_hours {
Some(h) => format!("{h:.1} h"),
None if data.pull_requests.merged < 20 => "(< 20 PRs)".to_string(),
None => "—".to_string(),
};
out.push_str(&format!("| p95 cycle time | {:<9} |\n", p95_str));
if data.pull_requests.total == 0 {
out.push_str(
"\n> No pull requests found. Ensure provider logins are mapped via \
`tga aliases add-login`.\n",
);
}
out.push('\n');
if !data.categories.is_empty() {
out.push_str("## Category Breakdown\n");
out.push_str("| Category | Commits | % total |\n");
out.push_str("|-------------|---------|--------|\n");
let total_cats: usize = data.categories.values().sum();
let mut cats: Vec<(&String, &usize)> = data.categories.iter().collect();
cats.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
for (cat, count) in cats {
let pct = if total_cats > 0 {
format!("{:.0}%", *count as f64 / total_cats as f64 * 100.0)
} else {
"—".to_string()
};
out.push_str(&format!("| {:<11} | {:>7} | {:>7} |\n", cat, count, pct));
}
out.push('\n');
}
out
}
pub fn format_json(data: &AuthorDrilldownData) -> Result<String> {
serde_json::to_string_pretty(data).map_err(crate::report::errors::ReportError::Json)
}