use std::path::PathBuf;
use tracing::{info, warn};
use crate::integrations::github::GithubClient;
use crate::profile::types::{ContributorProfile, Trajectory, TrendTag};
#[path = "reporter_github.rs"]
mod reporter_github;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReportFormat {
Json,
Markdown,
Both,
}
impl std::str::FromStr for ReportFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"json" => Ok(Self::Json),
"markdown" | "md" => Ok(Self::Markdown),
"both" => Ok(Self::Both),
other => Err(format!("unknown format: {other}")),
}
}
}
pub struct Reporter {
output_dir: PathBuf,
format: ReportFormat,
github_config: Option<GithubIssueConfig>,
}
#[derive(Debug, Clone)]
pub struct GithubIssueConfig {
pub owner: String,
pub repo: String,
pub label: String,
pub token: String,
}
impl Reporter {
pub fn new(output_dir: impl Into<PathBuf>, format: ReportFormat) -> Self {
Self {
output_dir: output_dir.into(),
format,
github_config: None,
}
}
pub fn with_github_issue(mut self, config: GithubIssueConfig) -> Self {
self.github_config = Some(config);
self
}
pub fn write_profile(
&self,
profile: &ContributorProfile,
) -> Result<Vec<PathBuf>, std::io::Error> {
std::fs::create_dir_all(&self.output_dir)?;
let mut written = Vec::new();
let stem = profile_file_stem(profile);
if matches!(self.format, ReportFormat::Json | ReportFormat::Both) {
let json_path = self.output_dir.join(format!("{stem}.json"));
let json = serde_json::to_string_pretty(profile)
.map_err(|e| std::io::Error::other(format!("JSON serialise: {e}")))?;
std::fs::write(&json_path, &json)?;
info!(path = %json_path.display(), "profile JSON written");
written.push(json_path);
}
if matches!(self.format, ReportFormat::Markdown | ReportFormat::Both) {
let md_path = self.output_dir.join(format!("{stem}.md"));
let md = render_markdown(profile);
std::fs::write(&md_path, &md)?;
info!(path = %md_path.display(), "profile Markdown written");
written.push(md_path);
}
Ok(written)
}
pub async fn upsert_github_issue(&self, profile: &ContributorProfile) -> Option<String> {
let config = self.github_config.as_ref()?;
let client = match GithubClient::new() {
Ok(c) => c,
Err(e) => {
warn!(error = %e, "upsert_github_issue: failed to build HTTP client — skipping");
return None;
}
};
let markdown = render_markdown(profile);
match reporter_github::github_upsert_issue(&client, config, profile, &markdown).await {
Ok(url) => {
info!(url = %url, "dev-profile issue upserted");
Some(url)
}
Err(e) => {
warn!(error = %e, "upsert_github_issue failed — continuing without issue update");
None
}
}
}
}
fn profile_file_stem(profile: &ContributorProfile) -> String {
let email_safe = profile.canonical_email.replace(['@', '.', '/', ' '], "_");
format!(
"profile_{}_{}_{}",
email_safe,
&profile.profiled_since[..10.min(profile.profiled_since.len())],
&profile.profiled_until[..10.min(profile.profiled_until.len())],
)
}
pub fn render_markdown(profile: &ContributorProfile) -> String {
let mut md = String::with_capacity(4096);
md.push_str(&format!(
"# Developer Profile: {} ({})\n\n",
profile.canonical_name, profile.canonical_email
));
md.push_str(&format!(
"**Window**: {} → {} \n",
profile.profiled_since, profile.profiled_until
));
if !profile.repositories.is_empty() {
md.push_str(&format!(
"**Repositories**: {} \n",
profile.repositories.join(", ")
));
}
let traj_str = match profile.improvement_trajectory {
Trajectory::Improving => "Improving",
Trajectory::Stable => "Stable",
Trajectory::Declining => "Declining",
};
md.push_str(&format!("**Trajectory**: {traj_str} \n"));
md.push_str(&format!("**Generated**: {} \n\n", profile.generated_at));
if !profile.quality_trend.is_empty() {
md.push_str("## Quality Trend\n\n");
md.push_str("| Period | Score |\n|--------|:-----:|\n");
for (label, score) in &profile.quality_trend {
let bar = quality_bar(*score, 5.0);
md.push_str(&format!("| {label} | {score:.2} {bar} |\n"));
}
md.push('\n');
}
if !profile.strengths.is_empty() {
md.push_str("## Strengths\n\n");
for s in &profile.strengths {
md.push_str(&format!("- {s}\n"));
}
md.push('\n');
}
if !profile.recurring_weaknesses.is_empty() {
md.push_str("## Areas for Improvement\n\n");
for w in &profile.recurring_weaknesses {
md.push_str(&format!("- {w}\n"));
}
md.push('\n');
}
if !profile.all_findings.is_empty() {
md.push_str("## Findings\n\n");
md.push_str(
"| Period | Kind | Trend | Description |\n\
|--------|------|-------|-------------|\n",
);
for lf in &profile.all_findings {
let tag = trend_tag_str(lf.trend_tag.as_ref());
let desc = lf.finding.description.replace('|', "\\|");
md.push_str(&format!(
"| {} | {} | {} | {} |\n",
lf.period_label, lf.finding.kind, tag, desc
));
}
md.push('\n');
}
if !profile.narrative.is_empty() {
md.push_str("## Engineering Assessment\n\n");
md.push_str(&profile.narrative);
md.push_str("\n\n");
}
let tc = &profile.token_cost;
if tc.input_tokens > 0 || tc.output_tokens > 0 {
md.push_str("## Token & Cost Summary\n\n");
md.push_str(&format!(
"| Metric | Value |\n|--------|-------|\n\
| Input tokens | {} |\n\
| Output tokens | {} |\n\
| Estimated cost | ${:.6} |\n\
| Total latency | {}ms |\n\n",
tc.input_tokens, tc.output_tokens, tc.cost_usd, tc.latency_ms
));
}
md.push_str(&format!(
"---\n*Generated by trusty-review {} — {}*\n",
profile.review_version, profile.generated_at
));
md
}
fn quality_bar(score: f64, max: f64) -> String {
let filled = ((score / max) * 5.0).round() as usize;
let empty = 5usize.saturating_sub(filled);
format!("{}{}", "▓".repeat(filled), "░".repeat(empty))
}
fn trend_tag_str(tag: Option<&TrendTag>) -> &'static str {
match tag {
Some(TrendTag::Recurring) => "🔁 Recurring",
Some(TrendTag::New) => "🆕 New",
Some(TrendTag::Resolved) => "✅ Resolved",
Some(TrendTag::Worsening) => "📈 Worsening",
None => "—",
}
}
#[cfg(test)]
#[path = "reporter_tests.rs"]
mod tests;