1use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9use crate::conversation::Transcript;
10use crate::error::{Error, ProviderErrorKind};
11use crate::eval::EvalOutcome;
12use crate::mock::MockCall;
13use crate::provider::Usage;
14use crate::skill::Finding;
15
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
18pub struct CaseRun {
19 pub case: String,
21 pub skill: String,
23 pub platform: String,
25 pub model: String,
27 pub passed: bool,
29 pub turns: usize,
31 pub evals: Vec<EvalOutcome>,
33 pub transcript: Transcript,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub usage: Option<Usage>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub mock_calls: Option<Vec<MockCall>>,
49}
50
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
53pub struct Summary {
54 pub cases: usize,
56 pub runs: usize,
58 pub passed: usize,
60 pub failed: usize,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub usage: Option<Usage>,
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
70pub struct Report {
71 pub passed: bool,
73 pub summary: Summary,
75 pub runs: Vec<CaseRun>,
77}
78
79impl Report {
80 #[must_use]
82 pub fn new(runs: Vec<CaseRun>) -> Self {
83 let mut case_names: Vec<&str> = runs.iter().map(|r| r.case.as_str()).collect();
84 case_names.sort_unstable();
85 case_names.dedup();
86 let passed_runs = runs.iter().filter(|r| r.passed).count();
87 let mut total_usage = Usage::default();
88 for run in &runs {
89 if let Some(u) = &run.usage {
90 total_usage.add(u);
91 }
92 }
93 let usage = (!total_usage.is_empty()).then_some(total_usage);
94 let summary = Summary {
95 cases: case_names.len(),
96 runs: runs.len(),
97 passed: passed_runs,
98 failed: runs.len() - passed_runs,
99 usage,
100 };
101 Report {
102 passed: summary.failed == 0 && !runs.is_empty(),
103 summary,
104 runs,
105 }
106 }
107
108 pub fn to_json(&self) -> Result<String, serde_json::Error> {
114 serde_json::to_string_pretty(self)
115 }
116
117 #[must_use]
120 pub fn to_human(&self) -> String {
121 let mut out = String::new();
122 for run in &self.runs {
123 let mark = if run.passed { "PASS" } else { "FAIL" };
124 out.push_str(&format!(
125 "{mark} {} [{}/{}]\n",
126 run.case, run.platform, run.model
127 ));
128 for eval in &run.evals {
129 if !eval.passed {
130 out.push_str(&format!(
131 " - {}: {} ({})\n",
132 eval.label,
133 eval.detail.summary(),
134 eval.reason
135 ));
136 }
137 }
138 }
139 out.push_str(&format!(
140 "{}/{} runs passed\n",
141 self.summary.passed, self.summary.runs
142 ));
143 if let Some(usage) = &self.summary.usage {
144 let mut parts = Vec::new();
145 if let Some(cost) = usage.cost_usd {
146 parts.push(format!("${cost:.4}"));
147 }
148 if let (Some(i), Some(o)) = (usage.input_tokens, usage.output_tokens) {
149 parts.push(format!("{} in / {} out tokens", i, o));
150 } else {
151 if let Some(i) = usage.input_tokens {
152 parts.push(format!("{i} input tokens"));
153 }
154 if let Some(o) = usage.output_tokens {
155 parts.push(format!("{o} output tokens"));
156 }
157 }
158 if !parts.is_empty() {
159 out.push_str(&format!("usage: {}\n", parts.join(", ")));
160 }
161 }
162 out
163 }
164}
165
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
169pub struct ValidationFinding {
170 pub skill: String,
172 pub message: String,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
178pub struct ValidationReport {
179 pub valid: bool,
181 pub findings: Vec<ValidationFinding>,
183}
184
185impl ValidationReport {
186 #[must_use]
188 pub fn new(findings: &[Finding]) -> Self {
189 ValidationReport {
190 valid: findings.is_empty(),
191 findings: findings
192 .iter()
193 .map(|f| ValidationFinding {
194 skill: f.skill.to_string_lossy().into_owned(),
195 message: f.message.clone(),
196 })
197 .collect(),
198 }
199 }
200
201 pub fn to_json(&self) -> Result<String, serde_json::Error> {
207 serde_json::to_string_pretty(self)
208 }
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
215#[serde(rename_all = "snake_case")]
216pub enum ErrorCode {
217 Usage,
220 Provider,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
236pub struct ReportError {
237 pub code: ErrorCode,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub kind: Option<ProviderErrorKind>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub context: Option<String>,
247 pub message: String,
250}
251
252impl ReportError {
253 #[must_use]
258 pub fn from_error(err: &Error) -> Self {
259 match err {
260 Error::Provider {
261 context,
262 message,
263 kind,
264 } => ReportError {
265 code: ErrorCode::Provider,
266 kind: *kind,
267 context: Some(context.clone()),
268 message: message.clone(),
269 },
270 other => ReportError {
271 code: ErrorCode::Usage,
272 kind: None,
273 context: None,
274 message: other.to_string(),
275 },
276 }
277 }
278
279 pub fn to_json(&self) -> Result<String, serde_json::Error> {
285 serde_json::to_string_pretty(self)
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::conversation::Transcript;
293 use crate::eval::{Comparator, EvalDetail, EvalOutcome};
294
295 fn run(case: &str, passed: bool, evals: Vec<EvalOutcome>, usage: Option<Usage>) -> CaseRun {
296 CaseRun {
297 case: case.to_string(),
298 skill: "/tmp/skill".to_string(),
299 platform: "claude-code".to_string(),
300 model: "sonnet".to_string(),
301 passed,
302 turns: 1,
303 evals,
304 transcript: Transcript::from_input("hi"),
305 usage,
306 mock_calls: None,
307 }
308 }
309
310 fn bool_eval(label: &str, passed: bool) -> EvalOutcome {
311 EvalOutcome {
312 label: label.to_string(),
313 passed,
314 detail: EvalDetail::Boolean {
315 value: passed,
316 expected: true,
317 },
318 reason: "because".to_string(),
319 }
320 }
321
322 #[test]
323 fn new_computes_summary_and_dedups_cases() {
324 let report = Report::new(vec![
325 run("a", true, vec![bool_eval("x", true)], None),
326 run("a", false, vec![bool_eval("y", false)], None),
327 run("b", true, vec![bool_eval("z", true)], None),
328 ]);
329 assert_eq!(report.summary.cases, 2);
331 assert_eq!(report.summary.runs, 3);
332 assert_eq!(report.summary.passed, 2);
333 assert_eq!(report.summary.failed, 1);
334 assert!(!report.passed);
335 assert!(report.summary.usage.is_none());
337 }
338
339 #[test]
340 fn empty_report_is_not_passed() {
341 let report = Report::new(vec![]);
342 assert!(!report.passed, "an empty run set is not a pass");
343 assert_eq!(report.summary.runs, 0);
344 }
345
346 #[test]
347 fn new_aggregates_usage_across_runs() {
348 let report = Report::new(vec![
349 run(
350 "a",
351 true,
352 vec![bool_eval("x", true)],
353 Some(Usage {
354 input_tokens: Some(10),
355 output_tokens: Some(2),
356 cost_usd: Some(0.01),
357 }),
358 ),
359 run(
360 "b",
361 true,
362 vec![bool_eval("y", true)],
363 Some(Usage {
364 input_tokens: Some(5),
365 output_tokens: None,
366 cost_usd: Some(0.02),
367 }),
368 ),
369 ]);
370 let usage = report.summary.usage.unwrap();
371 assert_eq!(usage.input_tokens, Some(15));
372 assert_eq!(usage.output_tokens, Some(2));
373 assert!((usage.cost_usd.unwrap() - 0.03).abs() < 1e-9);
374 }
375
376 #[test]
377 fn to_json_round_trips() {
378 let report = Report::new(vec![run("a", true, vec![bool_eval("x", true)], None)]);
379 let json = report.to_json().unwrap();
380 let parsed: Report = serde_json::from_str(&json).unwrap();
381 assert_eq!(parsed, report);
382 }
383
384 #[test]
385 fn to_human_lists_runs_and_failed_evals() {
386 let numeric = EvalOutcome {
387 label: "warmth".to_string(),
388 passed: false,
389 detail: EvalDetail::Numeric {
390 value: 4.0,
391 threshold: 7.0,
392 comparator: Comparator::Gte,
393 },
394 reason: "too cold".to_string(),
395 };
396 let report = Report::new(vec![
397 run("greets", true, vec![bool_eval("names", true)], None),
398 run("warm", false, vec![numeric], None),
399 ]);
400 let human = report.to_human();
401 assert!(human.contains("PASS greets [claude-code/sonnet]"));
402 assert!(human.contains("FAIL warm"));
403 assert!(human.contains("warmth: 4 >= 7 (too cold)"), "got:\n{human}");
405 assert!(human.contains("1/2 runs passed"));
406 }
407
408 #[test]
409 fn to_human_renders_usage_line_variants() {
410 let full = Report::new(vec![run(
412 "a",
413 true,
414 vec![bool_eval("x", true)],
415 Some(Usage {
416 input_tokens: Some(100),
417 output_tokens: Some(50),
418 cost_usd: Some(0.1234),
419 }),
420 )]);
421 let human = full.to_human();
422 assert!(
423 human.contains("usage: $0.1234, 100 in / 50 out tokens"),
424 "got:\n{human}"
425 );
426
427 let partial = Report::new(vec![run(
429 "a",
430 true,
431 vec![bool_eval("x", true)],
432 Some(Usage {
433 input_tokens: Some(7),
434 output_tokens: None,
435 cost_usd: None,
436 }),
437 )]);
438 assert!(partial.to_human().contains("usage: 7 input tokens"));
439
440 let out_only = Report::new(vec![run(
442 "a",
443 true,
444 vec![bool_eval("x", true)],
445 Some(Usage {
446 input_tokens: None,
447 output_tokens: Some(9),
448 cost_usd: None,
449 }),
450 )]);
451 assert!(out_only.to_human().contains("usage: 9 output tokens"));
452 }
453
454 #[test]
455 fn to_human_without_usage_has_no_usage_line() {
456 let report = Report::new(vec![run("a", true, vec![bool_eval("x", true)], None)]);
457 assert!(!report.to_human().contains("usage:"));
458 }
459
460 #[test]
461 fn validation_report_new_and_json() {
462 use crate::skill::Finding;
463 let empty = ValidationReport::new(&[]);
464 assert!(empty.valid);
465 assert!(empty.findings.is_empty());
466
467 let findings = vec![
468 Finding {
469 skill: std::path::PathBuf::from("/tmp/a"),
470 message: "missing name".to_string(),
471 },
472 Finding {
473 skill: std::path::PathBuf::from("/tmp/b"),
474 message: "no body".to_string(),
475 },
476 ];
477 let report = ValidationReport::new(&findings);
478 assert!(!report.valid);
479 assert_eq!(report.findings.len(), 2);
480 assert_eq!(report.findings[0].skill, "/tmp/a");
481 let json = report.to_json().unwrap();
482 let parsed: ValidationReport = serde_json::from_str(&json).unwrap();
483 assert_eq!(parsed, report);
484 }
485
486 #[test]
487 fn report_error_from_classified_provider_error() {
488 let err = Error::provider_classified(
489 "oneharness:claude-code",
490 "harness run failed: deadline",
491 ProviderErrorKind::Timeout,
492 );
493 let structured = ReportError::from_error(&err);
494 assert_eq!(structured.code, ErrorCode::Provider);
495 assert_eq!(structured.kind, Some(ProviderErrorKind::Timeout));
496 assert_eq!(
497 structured.context.as_deref(),
498 Some("oneharness:claude-code")
499 );
500 assert!(structured.message.contains("deadline"));
501 let json = structured.to_json().unwrap();
503 assert!(json.contains("\"kind\": \"timeout\""));
504 let parsed: ReportError = serde_json::from_str(&json).unwrap();
505 assert_eq!(parsed, structured);
506 }
507
508 #[test]
509 fn report_error_from_unclassified_provider_error() {
510 let err = Error::provider("oneharness", "boom");
511 let structured = ReportError::from_error(&err);
512 assert_eq!(structured.code, ErrorCode::Provider);
513 assert_eq!(structured.kind, None);
514 assert_eq!(structured.context.as_deref(), Some("oneharness"));
515 }
516
517 #[test]
518 fn report_error_from_usage_error() {
519 let structured = ReportError::from_error(&Error::Invalid("bad case".into()));
520 assert_eq!(structured.code, ErrorCode::Usage);
521 assert_eq!(structured.kind, None);
522 assert!(structured.context.is_none());
523 assert!(structured.message.contains("bad case"));
524 }
525}