Skip to main content

traitclaw_eval/
export.rs

1//! Report export — JSON and CSV serialization for `EvalReport`.
2//!
3//! # Example
4//!
5//! ```rust,no_run
6//! # fn example() -> traitclaw_core::Result<()> {
7//! use traitclaw_eval::{EvalReport, TestResult};
8//! use traitclaw_eval::export::EvalReportExport;
9//!
10//! let report = EvalReport {
11//!     suite_name: "my_suite".into(),
12//!     results: vec![
13//!         TestResult {
14//!             case_id: "c1".into(),
15//!             actual_output: "hello".into(),
16//!             scores: [("kw".to_string(), 1.0)].into_iter().collect(),
17//!             passed: true,
18//!         }
19//!     ],
20//!     average_score: 1.0,
21//!     passed: 1,
22//!     total: 1,
23//! };
24//!
25//! report.export_json("/tmp/report.json")?;
26//! report.export_csv("/tmp/report.csv")?;
27//! # Ok(())
28//! # }
29//! ```
30
31use std::io::Write;
32use std::path::Path;
33
34use traitclaw_core::{Error, Result};
35
36use crate::EvalReport;
37
38/// Extension trait adding export methods to `EvalReport`.
39pub trait EvalReportExport {
40    /// Write the report as a JSON file.
41    ///
42    /// The JSON is pretty-printed for readability.
43    ///
44    /// # Errors
45    ///
46    /// Returns an error if the file cannot be created or written.
47    fn export_json(&self, path: impl AsRef<Path>) -> Result<()>;
48
49    /// Write the report as a CSV file.
50    ///
51    /// Columns: `case_id,metric,score,passed`
52    ///
53    /// One row per (case × metric). If a case has no metrics, one row with empty metric.
54    ///
55    /// # Errors
56    ///
57    /// Returns an error if the file cannot be created or written.
58    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        // Header
80        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                // Sort metric names for deterministic output
96                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
117/// Escape a field for CSV: wrap in quotes if it contains comma, quote, or newline.
118fn 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// ─────────────────────────────────────────────────────────────────────────────
127// Tests
128// ─────────────────────────────────────────────────────────────────────────────
129
130#[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        // AC #4: export_json → valid JSON parseable back to EvalReport
163        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        // AC #5: export_csv → valid CSV with header + one row per case×metric
183        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        // Header
192        assert_eq!(lines[0], "case_id,metric,score,passed");
193
194        // 2 metrics for case_1 + 1 metric for case_2 = 3 rows
195        assert_eq!(
196            lines.len(),
197            4,
198            "header + 3 data rows expected, got:\n{content}"
199        );
200
201        // Check case_1 is present
202        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); // only header
224        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}