use std::io::Write;
use std::path::Path;
use traitclaw_core::{Error, Result};
use crate::EvalReport;
pub trait EvalReportExport {
fn export_json(&self, path: impl AsRef<Path>) -> Result<()>;
fn export_csv(&self, path: impl AsRef<Path>) -> Result<()>;
}
impl EvalReportExport for EvalReport {
fn export_json(&self, path: impl AsRef<Path>) -> Result<()> {
let json = serde_json::to_string_pretty(self)
.map_err(|e| Error::Runtime(format!("JSON serialization error: {e}")))?;
let mut file = std::fs::File::create(path)
.map_err(|e| Error::Runtime(format!("Cannot create JSON file: {e}")))?;
file.write_all(json.as_bytes())
.map_err(|e| Error::Runtime(format!("Cannot write JSON file: {e}")))?;
Ok(())
}
fn export_csv(&self, path: impl AsRef<Path>) -> Result<()> {
let mut file = std::fs::File::create(path)
.map_err(|e| Error::Runtime(format!("Cannot create CSV file: {e}")))?;
writeln!(file, "case_id,metric,score,passed")
.map_err(|e| Error::Runtime(format!("Cannot write CSV header: {e}")))?;
for result in &self.results {
if result.scores.is_empty() {
writeln!(
file,
"{},{},{},{}",
escape_csv(&result.case_id),
"",
"",
result.passed
)
.map_err(|e| Error::Runtime(format!("Cannot write CSV row: {e}")))?;
} else {
let mut metrics: Vec<_> = result.scores.iter().collect();
metrics.sort_by_key(|(k, _)| k.as_str());
for (metric, score) in &metrics {
writeln!(
file,
"{},{},{:.4},{}",
escape_csv(&result.case_id),
escape_csv(metric),
score,
result.passed
)
.map_err(|e| Error::Runtime(format!("Cannot write CSV row: {e}")))?;
}
}
}
Ok(())
}
}
fn escape_csv(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{EvalReport, TestResult};
fn make_report() -> EvalReport {
EvalReport {
suite_name: "test_suite".into(),
results: vec![
TestResult {
case_id: "case_1".into(),
actual_output: "Hello, this is a response.".into(),
scores: [("keyword".to_string(), 0.85), ("length".to_string(), 1.0)]
.into_iter()
.collect(),
passed: true,
},
TestResult {
case_id: "case_2".into(),
actual_output: "Short.".into(),
scores: [("keyword".to_string(), 0.5)].into_iter().collect(),
passed: false,
},
],
average_score: 0.78,
passed: 1,
total: 2,
}
}
#[test]
fn test_export_json_parseable() {
let report = make_report();
let path = std::env::temp_dir().join("traitclaw_eval_test.json");
report.export_json(&path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let parsed: EvalReport = serde_json::from_str(&content).unwrap();
assert_eq!(parsed.suite_name, "test_suite");
assert_eq!(parsed.results.len(), 2);
assert_eq!(parsed.passed, 1);
assert_eq!(parsed.total, 2);
assert!((parsed.average_score - 0.78).abs() < 1e-6);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_export_csv_has_header_and_rows() {
let report = make_report();
let path = std::env::temp_dir().join("traitclaw_eval_test.csv");
report.export_csv(&path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines[0], "case_id,metric,score,passed");
assert_eq!(
lines.len(),
4,
"header + 3 data rows expected, got:\n{content}"
);
assert!(content.contains("case_1"), "should contain case_1");
assert!(content.contains("case_2"), "should contain case_2");
assert!(content.contains("keyword"), "should contain metric name");
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_export_csv_empty_report() {
let report = EvalReport {
suite_name: "empty".into(),
results: vec![],
average_score: 0.0,
passed: 0,
total: 0,
};
let path = std::env::temp_dir().join("traitclaw_eval_empty.csv");
report.export_csv(&path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 1); assert_eq!(lines[0], "case_id,metric,score,passed");
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_escape_csv() {
assert_eq!(escape_csv("plain"), "plain");
assert_eq!(escape_csv("with,comma"), "\"with,comma\"");
assert_eq!(escape_csv("with\"quote"), "\"with\"\"quote\"");
}
}