use crate::database::Database;
use crate::error::Result;
use serde::{Deserialize, Serialize};
use std::fmt::Write as _;
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ReportFormat {
Json,
Csv,
Html,
Text,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BatchReport {
pub title: String,
pub generated_at: String,
pub summary: ReportSummary,
pub jobs: Vec<JobReport>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReportSummary {
pub total_jobs: u64,
pub completed: u64,
pub failed: u64,
pub cancelled: u64,
pub total_duration_secs: f64,
pub success_rate: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JobReport {
pub id: String,
pub name: String,
pub status: String,
pub created_at: String,
pub started_at: Option<String>,
pub completed_at: Option<String>,
pub duration_secs: Option<f64>,
pub error: Option<String>,
}
pub struct ReportGenerator {
database: Arc<Database>,
}
impl ReportGenerator {
#[must_use]
pub fn new(database: Arc<Database>) -> Self {
Self { database }
}
pub fn generate(&self) -> Result<BatchReport> {
let job_stats = self.database.get_statistics()?;
let jobs = self.database.list_jobs()?;
let cancelled = self.database.count_jobs_by_status("Cancelled")?;
let total_duration_secs = self.database.get_total_duration_secs()?;
let summary = ReportSummary {
total_jobs: job_stats.total,
completed: job_stats.completed,
failed: job_stats.failed,
cancelled,
total_duration_secs,
success_rate: if job_stats.total > 0 {
#[allow(clippy::cast_precision_loss)]
let rate = (job_stats.completed as f64 / job_stats.total as f64) * 100.0;
rate
} else {
0.0
},
};
let job_reports: Vec<JobReport> = jobs
.into_iter()
.map(|job| {
let status = self
.database
.get_job_status_string(&job.id)
.unwrap_or_else(|_| "Unknown".to_string());
let started_at = self.database.get_job_started_at(&job.id).ok().flatten();
let completed_at = self.database.get_job_completed_at(&job.id).ok().flatten();
let duration_secs = self.database.get_job_duration_secs(&job.id).ok().flatten();
let error = self.database.get_job_error(&job.id).ok().flatten();
JobReport {
id: job.id.to_string(),
name: job.name.clone(),
status,
created_at: chrono::Utc::now().to_rfc3339(),
started_at,
completed_at,
duration_secs,
error,
}
})
.collect();
Ok(BatchReport {
title: "Batch Processing Report".to_string(),
generated_at: chrono::Utc::now().to_rfc3339(),
summary,
jobs: job_reports,
})
}
pub fn export_json(&self, report: &BatchReport) -> Result<String> {
Ok(serde_json::to_string_pretty(report)?)
}
pub fn export_csv(&self, report: &BatchReport) -> Result<String> {
let mut csv = String::from("ID,Name,Status,Created,Started,Completed,Duration,Error\n");
for job in &report.jobs {
let _ = writeln!(
csv,
"{},{},{},{},{},{},{},{}",
job.id,
job.name,
job.status,
job.created_at,
job.started_at.as_deref().unwrap_or(""),
job.completed_at.as_deref().unwrap_or(""),
job.duration_secs.map_or(String::new(), |d| d.to_string()),
job.error.as_deref().unwrap_or("")
);
}
Ok(csv)
}
pub fn export_html(&self, report: &BatchReport) -> Result<String> {
let mut html = String::from("<html><head><title>Batch Report</title></head><body>");
let _ = write!(html, "<h1>{}</h1>", report.title);
let _ = write!(html, "<p>Generated: {}</p>", report.generated_at);
html.push_str("<h2>Summary</h2>");
html.push_str("<table border='1'>");
let _ = write!(
html,
"<tr><td>Total Jobs</td><td>{}</td></tr>",
report.summary.total_jobs
);
let _ = write!(
html,
"<tr><td>Completed</td><td>{}</td></tr>",
report.summary.completed
);
let _ = write!(
html,
"<tr><td>Failed</td><td>{}</td></tr>",
report.summary.failed
);
let _ = write!(
html,
"<tr><td>Success Rate</td><td>{:.2}%</td></tr>",
report.summary.success_rate
);
html.push_str("</table>");
html.push_str("<h2>Jobs</h2>");
html.push_str("<table border='1'>");
html.push_str("<tr><th>ID</th><th>Name</th><th>Status</th><th>Duration</th></tr>");
for job in &report.jobs {
let _ = write!(
html,
"<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
job.id,
job.name,
job.status,
job.duration_secs
.map_or(String::new(), |d| format!("{d:.2}s"))
);
}
html.push_str("</table>");
html.push_str("</body></html>");
Ok(html)
}
pub fn export(&self, report: &BatchReport, format: &ReportFormat) -> Result<String> {
match format {
ReportFormat::Json => self.export_json(report),
ReportFormat::Csv => self.export_csv(report),
ReportFormat::Html => self.export_html(report),
ReportFormat::Text => Ok(format!("{report:?}")),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn test_report_generator_creation() {
let temp_file = NamedTempFile::new().expect("failed to create temp file");
let db_path = temp_file
.path()
.to_str()
.expect("path should be valid UTF-8");
let database = Arc::new(Database::new(db_path).expect("failed to create database"));
let generator = ReportGenerator::new(database);
assert!(std::mem::size_of_val(&generator) > 0);
}
#[test]
fn test_generate_report() {
let temp_file = NamedTempFile::new().expect("failed to create temp file");
let db_path = temp_file
.path()
.to_str()
.expect("path should be valid UTF-8");
let database = Arc::new(Database::new(db_path).expect("failed to create database"));
let generator = ReportGenerator::new(database);
let report = generator.generate();
assert!(report.is_ok());
let report = report.expect("report should be valid");
assert_eq!(report.summary.total_jobs, 0);
}
#[test]
fn test_generate_report_cancelled_count() {
let temp_file = NamedTempFile::new().expect("failed to create temp file");
let db_path = temp_file
.path()
.to_str()
.expect("path should be valid UTF-8");
let database = Arc::new(Database::new(db_path).expect("failed to create database"));
let generator = ReportGenerator::new(Arc::clone(&database));
let report = generator.generate().expect("failed to generate");
assert_eq!(report.summary.cancelled, 0);
}
#[test]
fn test_generate_report_total_duration() {
let temp_file = NamedTempFile::new().expect("failed to create temp file");
let db_path = temp_file
.path()
.to_str()
.expect("path should be valid UTF-8");
let database = Arc::new(Database::new(db_path).expect("failed to create database"));
let generator = ReportGenerator::new(Arc::clone(&database));
let report = generator.generate().expect("failed to generate");
assert_eq!(report.summary.total_duration_secs, 0.0);
}
#[test]
fn test_export_json() {
let temp_file = NamedTempFile::new().expect("failed to create temp file");
let db_path = temp_file
.path()
.to_str()
.expect("path should be valid UTF-8");
let database = Arc::new(Database::new(db_path).expect("failed to create database"));
let generator = ReportGenerator::new(database);
let report = generator.generate().expect("failed to generate");
let json = generator.export_json(&report);
assert!(json.is_ok());
let json_str = json.expect("json should be valid");
assert!(json_str.contains("title"));
}
#[test]
fn test_export_csv() {
let temp_file = NamedTempFile::new().expect("failed to create temp file");
let db_path = temp_file
.path()
.to_str()
.expect("path should be valid UTF-8");
let database = Arc::new(Database::new(db_path).expect("failed to create database"));
let generator = ReportGenerator::new(database);
let report = generator.generate().expect("failed to generate");
let csv = generator.export_csv(&report);
assert!(csv.is_ok());
let csv_str = csv.expect("csv should be valid");
assert!(csv_str.contains("ID,Name,Status"));
}
#[test]
fn test_export_html() {
let temp_file = NamedTempFile::new().expect("failed to create temp file");
let db_path = temp_file
.path()
.to_str()
.expect("path should be valid UTF-8");
let database = Arc::new(Database::new(db_path).expect("failed to create database"));
let generator = ReportGenerator::new(database);
let report = generator.generate().expect("failed to generate");
let html = generator.export_html(&report);
assert!(html.is_ok());
let html_str = html.expect("html should be valid");
assert!(html_str.contains("<html>"));
assert!(html_str.contains("</html>"));
}
}