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