1use std::io::Write;
32use std::path::Path;
33
34use traitclaw_core::{Error, Result};
35
36use crate::EvalReport;
37
38pub trait EvalReportExport {
40 fn export_json(&self, path: impl AsRef<Path>) -> Result<()>;
48
49 fn export_csv(&self, path: impl AsRef<Path>) -> Result<()>;
59}
60
61impl EvalReportExport for EvalReport {
62 fn export_json(&self, path: impl AsRef<Path>) -> Result<()> {
63 let json = serde_json::to_string_pretty(self)
64 .map_err(|e| Error::Runtime(format!("JSON serialization error: {e}")))?;
65
66 let mut file = std::fs::File::create(path)
67 .map_err(|e| Error::Runtime(format!("Cannot create JSON file: {e}")))?;
68
69 file.write_all(json.as_bytes())
70 .map_err(|e| Error::Runtime(format!("Cannot write JSON file: {e}")))?;
71
72 Ok(())
73 }
74
75 fn export_csv(&self, path: impl AsRef<Path>) -> Result<()> {
76 let mut file = std::fs::File::create(path)
77 .map_err(|e| Error::Runtime(format!("Cannot create CSV file: {e}")))?;
78
79 writeln!(file, "case_id,metric,score,passed")
81 .map_err(|e| Error::Runtime(format!("Cannot write CSV header: {e}")))?;
82
83 for result in &self.results {
84 if result.scores.is_empty() {
85 writeln!(
86 file,
87 "{},{},{},{}",
88 escape_csv(&result.case_id),
89 "",
90 "",
91 result.passed
92 )
93 .map_err(|e| Error::Runtime(format!("Cannot write CSV row: {e}")))?;
94 } else {
95 let mut metrics: Vec<_> = result.scores.iter().collect();
97 metrics.sort_by_key(|(k, _)| k.as_str());
98
99 for (metric, score) in &metrics {
100 writeln!(
101 file,
102 "{},{},{:.4},{}",
103 escape_csv(&result.case_id),
104 escape_csv(metric),
105 score,
106 result.passed
107 )
108 .map_err(|e| Error::Runtime(format!("Cannot write CSV row: {e}")))?;
109 }
110 }
111 }
112
113 Ok(())
114 }
115}
116
117fn escape_csv(s: &str) -> String {
119 if s.contains(',') || s.contains('"') || s.contains('\n') {
120 format!("\"{}\"", s.replace('"', "\"\""))
121 } else {
122 s.to_string()
123 }
124}
125
126#[cfg(test)]
131mod tests {
132 use super::*;
133 use crate::{EvalReport, TestResult};
134
135 fn make_report() -> EvalReport {
136 EvalReport {
137 suite_name: "test_suite".into(),
138 results: vec![
139 TestResult {
140 case_id: "case_1".into(),
141 actual_output: "Hello, this is a response.".into(),
142 scores: [("keyword".to_string(), 0.85), ("length".to_string(), 1.0)]
143 .into_iter()
144 .collect(),
145 passed: true,
146 },
147 TestResult {
148 case_id: "case_2".into(),
149 actual_output: "Short.".into(),
150 scores: [("keyword".to_string(), 0.5)].into_iter().collect(),
151 passed: false,
152 },
153 ],
154 average_score: 0.78,
155 passed: 1,
156 total: 2,
157 }
158 }
159
160 #[test]
161 fn test_export_json_parseable() {
162 let report = make_report();
164 let path = std::env::temp_dir().join("traitclaw_eval_test.json");
165
166 report.export_json(&path).unwrap();
167
168 let content = std::fs::read_to_string(&path).unwrap();
169 let parsed: EvalReport = serde_json::from_str(&content).unwrap();
170
171 assert_eq!(parsed.suite_name, "test_suite");
172 assert_eq!(parsed.results.len(), 2);
173 assert_eq!(parsed.passed, 1);
174 assert_eq!(parsed.total, 2);
175 assert!((parsed.average_score - 0.78).abs() < 1e-6);
176
177 let _ = std::fs::remove_file(&path);
178 }
179
180 #[test]
181 fn test_export_csv_has_header_and_rows() {
182 let report = make_report();
184 let path = std::env::temp_dir().join("traitclaw_eval_test.csv");
185
186 report.export_csv(&path).unwrap();
187
188 let content = std::fs::read_to_string(&path).unwrap();
189 let lines: Vec<&str> = content.lines().collect();
190
191 assert_eq!(lines[0], "case_id,metric,score,passed");
193
194 assert_eq!(
196 lines.len(),
197 4,
198 "header + 3 data rows expected, got:\n{content}"
199 );
200
201 assert!(content.contains("case_1"), "should contain case_1");
203 assert!(content.contains("case_2"), "should contain case_2");
204 assert!(content.contains("keyword"), "should contain metric name");
205
206 let _ = std::fs::remove_file(&path);
207 }
208
209 #[test]
210 fn test_export_csv_empty_report() {
211 let report = EvalReport {
212 suite_name: "empty".into(),
213 results: vec![],
214 average_score: 0.0,
215 passed: 0,
216 total: 0,
217 };
218 let path = std::env::temp_dir().join("traitclaw_eval_empty.csv");
219 report.export_csv(&path).unwrap();
220
221 let content = std::fs::read_to_string(&path).unwrap();
222 let lines: Vec<&str> = content.lines().collect();
223 assert_eq!(lines.len(), 1); assert_eq!(lines[0], "case_id,metric,score,passed");
225
226 let _ = std::fs::remove_file(&path);
227 }
228
229 #[test]
230 fn test_escape_csv() {
231 assert_eq!(escape_csv("plain"), "plain");
232 assert_eq!(escape_csv("with,comma"), "\"with,comma\"");
233 assert_eq!(escape_csv("with\"quote"), "\"with\"\"quote\"");
234 }
235}