1use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9use crate::conversation::Transcript;
10use crate::eval::EvalOutcome;
11use crate::mock::MockCall;
12use crate::provider::Usage;
13use crate::skill::Finding;
14
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
17pub struct CaseRun {
18 pub case: String,
20 pub skill: String,
22 pub platform: String,
24 pub model: String,
26 pub passed: bool,
28 pub turns: usize,
30 pub evals: Vec<EvalOutcome>,
32 pub transcript: Transcript,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub usage: Option<Usage>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub mock_calls: Option<Vec<MockCall>>,
48}
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
52pub struct Summary {
53 pub cases: usize,
55 pub runs: usize,
57 pub passed: usize,
59 pub failed: usize,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub usage: Option<Usage>,
65}
66
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
69pub struct Report {
70 pub passed: bool,
72 pub summary: Summary,
74 pub runs: Vec<CaseRun>,
76}
77
78impl Report {
79 #[must_use]
81 pub fn new(runs: Vec<CaseRun>) -> Self {
82 let mut case_names: Vec<&str> = runs.iter().map(|r| r.case.as_str()).collect();
83 case_names.sort_unstable();
84 case_names.dedup();
85 let passed_runs = runs.iter().filter(|r| r.passed).count();
86 let mut total_usage = Usage::default();
87 for run in &runs {
88 if let Some(u) = &run.usage {
89 total_usage.add(u);
90 }
91 }
92 let usage = (!total_usage.is_empty()).then_some(total_usage);
93 let summary = Summary {
94 cases: case_names.len(),
95 runs: runs.len(),
96 passed: passed_runs,
97 failed: runs.len() - passed_runs,
98 usage,
99 };
100 Report {
101 passed: summary.failed == 0 && !runs.is_empty(),
102 summary,
103 runs,
104 }
105 }
106
107 pub fn to_json(&self) -> Result<String, serde_json::Error> {
113 serde_json::to_string_pretty(self)
114 }
115
116 #[must_use]
119 pub fn to_human(&self) -> String {
120 let mut out = String::new();
121 for run in &self.runs {
122 let mark = if run.passed { "PASS" } else { "FAIL" };
123 out.push_str(&format!(
124 "{mark} {} [{}/{}]\n",
125 run.case, run.platform, run.model
126 ));
127 for eval in &run.evals {
128 if !eval.passed {
129 out.push_str(&format!(
130 " - {}: {} ({})\n",
131 eval.label,
132 eval.detail.summary(),
133 eval.reason
134 ));
135 }
136 }
137 }
138 out.push_str(&format!(
139 "{}/{} runs passed\n",
140 self.summary.passed, self.summary.runs
141 ));
142 if let Some(usage) = &self.summary.usage {
143 let mut parts = Vec::new();
144 if let Some(cost) = usage.cost_usd {
145 parts.push(format!("${cost:.4}"));
146 }
147 if let (Some(i), Some(o)) = (usage.input_tokens, usage.output_tokens) {
148 parts.push(format!("{} in / {} out tokens", i, o));
149 } else {
150 if let Some(i) = usage.input_tokens {
151 parts.push(format!("{i} input tokens"));
152 }
153 if let Some(o) = usage.output_tokens {
154 parts.push(format!("{o} output tokens"));
155 }
156 }
157 if !parts.is_empty() {
158 out.push_str(&format!("usage: {}\n", parts.join(", ")));
159 }
160 }
161 out
162 }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
168pub struct ValidationFinding {
169 pub skill: String,
171 pub message: String,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
177pub struct ValidationReport {
178 pub valid: bool,
180 pub findings: Vec<ValidationFinding>,
182}
183
184impl ValidationReport {
185 #[must_use]
187 pub fn new(findings: &[Finding]) -> Self {
188 ValidationReport {
189 valid: findings.is_empty(),
190 findings: findings
191 .iter()
192 .map(|f| ValidationFinding {
193 skill: f.skill.to_string_lossy().into_owned(),
194 message: f.message.clone(),
195 })
196 .collect(),
197 }
198 }
199
200 pub fn to_json(&self) -> Result<String, serde_json::Error> {
206 serde_json::to_string_pretty(self)
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use crate::conversation::Transcript;
214 use crate::eval::{Comparator, EvalDetail, EvalOutcome};
215
216 fn run(case: &str, passed: bool, evals: Vec<EvalOutcome>, usage: Option<Usage>) -> CaseRun {
217 CaseRun {
218 case: case.to_string(),
219 skill: "/tmp/skill".to_string(),
220 platform: "claude-code".to_string(),
221 model: "sonnet".to_string(),
222 passed,
223 turns: 1,
224 evals,
225 transcript: Transcript::from_input("hi"),
226 usage,
227 mock_calls: None,
228 }
229 }
230
231 fn bool_eval(label: &str, passed: bool) -> EvalOutcome {
232 EvalOutcome {
233 label: label.to_string(),
234 passed,
235 detail: EvalDetail::Boolean {
236 value: passed,
237 expected: true,
238 },
239 reason: "because".to_string(),
240 }
241 }
242
243 #[test]
244 fn new_computes_summary_and_dedups_cases() {
245 let report = Report::new(vec![
246 run("a", true, vec![bool_eval("x", true)], None),
247 run("a", false, vec![bool_eval("y", false)], None),
248 run("b", true, vec![bool_eval("z", true)], None),
249 ]);
250 assert_eq!(report.summary.cases, 2);
252 assert_eq!(report.summary.runs, 3);
253 assert_eq!(report.summary.passed, 2);
254 assert_eq!(report.summary.failed, 1);
255 assert!(!report.passed);
256 assert!(report.summary.usage.is_none());
258 }
259
260 #[test]
261 fn empty_report_is_not_passed() {
262 let report = Report::new(vec![]);
263 assert!(!report.passed, "an empty run set is not a pass");
264 assert_eq!(report.summary.runs, 0);
265 }
266
267 #[test]
268 fn new_aggregates_usage_across_runs() {
269 let report = Report::new(vec![
270 run(
271 "a",
272 true,
273 vec![bool_eval("x", true)],
274 Some(Usage {
275 input_tokens: Some(10),
276 output_tokens: Some(2),
277 cost_usd: Some(0.01),
278 }),
279 ),
280 run(
281 "b",
282 true,
283 vec![bool_eval("y", true)],
284 Some(Usage {
285 input_tokens: Some(5),
286 output_tokens: None,
287 cost_usd: Some(0.02),
288 }),
289 ),
290 ]);
291 let usage = report.summary.usage.unwrap();
292 assert_eq!(usage.input_tokens, Some(15));
293 assert_eq!(usage.output_tokens, Some(2));
294 assert!((usage.cost_usd.unwrap() - 0.03).abs() < 1e-9);
295 }
296
297 #[test]
298 fn to_json_round_trips() {
299 let report = Report::new(vec![run("a", true, vec![bool_eval("x", true)], None)]);
300 let json = report.to_json().unwrap();
301 let parsed: Report = serde_json::from_str(&json).unwrap();
302 assert_eq!(parsed, report);
303 }
304
305 #[test]
306 fn to_human_lists_runs_and_failed_evals() {
307 let numeric = EvalOutcome {
308 label: "warmth".to_string(),
309 passed: false,
310 detail: EvalDetail::Numeric {
311 value: 4.0,
312 threshold: 7.0,
313 comparator: Comparator::Gte,
314 },
315 reason: "too cold".to_string(),
316 };
317 let report = Report::new(vec![
318 run("greets", true, vec![bool_eval("names", true)], None),
319 run("warm", false, vec![numeric], None),
320 ]);
321 let human = report.to_human();
322 assert!(human.contains("PASS greets [claude-code/sonnet]"));
323 assert!(human.contains("FAIL warm"));
324 assert!(human.contains("warmth: 4 >= 7 (too cold)"), "got:\n{human}");
326 assert!(human.contains("1/2 runs passed"));
327 }
328
329 #[test]
330 fn to_human_renders_usage_line_variants() {
331 let full = Report::new(vec![run(
333 "a",
334 true,
335 vec![bool_eval("x", true)],
336 Some(Usage {
337 input_tokens: Some(100),
338 output_tokens: Some(50),
339 cost_usd: Some(0.1234),
340 }),
341 )]);
342 let human = full.to_human();
343 assert!(
344 human.contains("usage: $0.1234, 100 in / 50 out tokens"),
345 "got:\n{human}"
346 );
347
348 let partial = Report::new(vec![run(
350 "a",
351 true,
352 vec![bool_eval("x", true)],
353 Some(Usage {
354 input_tokens: Some(7),
355 output_tokens: None,
356 cost_usd: None,
357 }),
358 )]);
359 assert!(partial.to_human().contains("usage: 7 input tokens"));
360
361 let out_only = Report::new(vec![run(
363 "a",
364 true,
365 vec![bool_eval("x", true)],
366 Some(Usage {
367 input_tokens: None,
368 output_tokens: Some(9),
369 cost_usd: None,
370 }),
371 )]);
372 assert!(out_only.to_human().contains("usage: 9 output tokens"));
373 }
374
375 #[test]
376 fn to_human_without_usage_has_no_usage_line() {
377 let report = Report::new(vec![run("a", true, vec![bool_eval("x", true)], None)]);
378 assert!(!report.to_human().contains("usage:"));
379 }
380
381 #[test]
382 fn validation_report_new_and_json() {
383 use crate::skill::Finding;
384 let empty = ValidationReport::new(&[]);
385 assert!(empty.valid);
386 assert!(empty.findings.is_empty());
387
388 let findings = vec![
389 Finding {
390 skill: std::path::PathBuf::from("/tmp/a"),
391 message: "missing name".to_string(),
392 },
393 Finding {
394 skill: std::path::PathBuf::from("/tmp/b"),
395 message: "no body".to_string(),
396 },
397 ];
398 let report = ValidationReport::new(&findings);
399 assert!(!report.valid);
400 assert_eq!(report.findings.len(), 2);
401 assert_eq!(report.findings[0].skill, "/tmp/a");
402 let json = report.to_json().unwrap();
403 let parsed: ValidationReport = serde_json::from_str(&json).unwrap();
404 assert_eq!(parsed, report);
405 }
406}