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 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub history_command: Option<String>,
56}
57
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
60pub struct Summary {
61 pub cases: usize,
63 pub runs: usize,
65 pub passed: usize,
67 pub failed: usize,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub usage: Option<Usage>,
73}
74
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
77pub struct Report {
78 pub passed: bool,
80 pub summary: Summary,
82 pub runs: Vec<CaseRun>,
84}
85
86impl Report {
87 #[must_use]
89 pub fn new(runs: Vec<CaseRun>) -> Self {
90 let mut case_names: Vec<&str> = runs.iter().map(|r| r.case.as_str()).collect();
91 case_names.sort_unstable();
92 case_names.dedup();
93 let passed_runs = runs.iter().filter(|r| r.passed).count();
94 let mut total_usage = Usage::default();
95 for run in &runs {
96 if let Some(u) = &run.usage {
97 total_usage.add(u);
98 }
99 }
100 let usage = (!total_usage.is_empty()).then_some(total_usage);
101 let summary = Summary {
102 cases: case_names.len(),
103 runs: runs.len(),
104 passed: passed_runs,
105 failed: runs.len() - passed_runs,
106 usage,
107 };
108 Report {
109 passed: summary.failed == 0 && !runs.is_empty(),
110 summary,
111 runs,
112 }
113 }
114
115 pub fn to_json(&self) -> Result<String, serde_json::Error> {
121 serde_json::to_string_pretty(self)
122 }
123
124 #[must_use]
127 pub fn to_human(&self) -> String {
128 let mut out = String::new();
129 for run in &self.runs {
130 let mark = if run.passed { "PASS" } else { "FAIL" };
131 out.push_str(&format!(
132 "{mark} {} [{}/{}]\n",
133 run.case, run.platform, run.model
134 ));
135 for eval in &run.evals {
136 if !eval.passed {
137 out.push_str(&format!(
138 " - {}: {} ({})\n",
139 eval.label,
140 eval.detail.summary(),
141 eval.reason
142 ));
143 }
144 }
145 if let Some(cmd) = &run.history_command {
148 out.push_str(&format!(" history: {cmd}\n"));
149 }
150 }
151 out.push_str(&format!(
152 "{}/{} runs passed\n",
153 self.summary.passed, self.summary.runs
154 ));
155 if let Some(usage) = &self.summary.usage {
156 let mut parts = Vec::new();
157 if let Some(cost) = usage.cost_usd {
158 parts.push(format!("${cost:.4}"));
159 }
160 if let (Some(i), Some(o)) = (usage.input_tokens, usage.output_tokens) {
161 parts.push(format!("{} in / {} out tokens", i, o));
162 } else {
163 if let Some(i) = usage.input_tokens {
164 parts.push(format!("{i} input tokens"));
165 }
166 if let Some(o) = usage.output_tokens {
167 parts.push(format!("{o} output tokens"));
168 }
169 }
170 if !parts.is_empty() {
171 out.push_str(&format!("usage: {}\n", parts.join(", ")));
172 }
173 }
174 out
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
181pub struct ValidationFinding {
182 pub skill: String,
184 pub message: String,
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
190pub struct ValidationReport {
191 pub valid: bool,
193 pub findings: Vec<ValidationFinding>,
195}
196
197impl ValidationReport {
198 #[must_use]
200 pub fn new(findings: &[Finding]) -> Self {
201 ValidationReport {
202 valid: findings.is_empty(),
203 findings: findings
204 .iter()
205 .map(|f| ValidationFinding {
206 skill: f.skill.to_string_lossy().into_owned(),
207 message: f.message.clone(),
208 })
209 .collect(),
210 }
211 }
212
213 pub fn to_json(&self) -> Result<String, serde_json::Error> {
219 serde_json::to_string_pretty(self)
220 }
221}
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
227#[serde(rename_all = "snake_case")]
228pub enum ErrorCode {
229 Usage,
232 Provider,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
248pub struct ReportError {
249 pub code: ErrorCode,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub kind: Option<ProviderErrorKind>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub context: Option<String>,
259 pub message: String,
262}
263
264impl ReportError {
265 #[must_use]
270 pub fn from_error(err: &Error) -> Self {
271 match err {
272 Error::Provider {
273 context,
274 message,
275 kind,
276 } => ReportError {
277 code: ErrorCode::Provider,
278 kind: *kind,
279 context: Some(context.clone()),
280 message: message.clone(),
281 },
282 other => ReportError {
283 code: ErrorCode::Usage,
284 kind: None,
285 context: None,
286 message: other.to_string(),
287 },
288 }
289 }
290
291 pub fn to_json(&self) -> Result<String, serde_json::Error> {
297 serde_json::to_string_pretty(self)
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use crate::conversation::Transcript;
305 use crate::eval::{Comparator, EvalDetail, EvalOutcome};
306
307 fn run(case: &str, passed: bool, evals: Vec<EvalOutcome>, usage: Option<Usage>) -> CaseRun {
308 CaseRun {
309 case: case.to_string(),
310 skill: "/tmp/skill".to_string(),
311 platform: "claude-code".to_string(),
312 model: "sonnet".to_string(),
313 passed,
314 turns: 1,
315 evals,
316 transcript: Transcript::from_input("hi"),
317 usage,
318 mock_calls: None,
319 history_command: None,
320 }
321 }
322
323 fn bool_eval(label: &str, passed: bool) -> EvalOutcome {
324 EvalOutcome {
325 label: label.to_string(),
326 passed,
327 detail: EvalDetail::Boolean {
328 value: passed,
329 expected: true,
330 },
331 reason: "because".to_string(),
332 }
333 }
334
335 #[test]
336 fn new_computes_summary_and_dedups_cases() {
337 let report = Report::new(vec![
338 run("a", true, vec![bool_eval("x", true)], None),
339 run("a", false, vec![bool_eval("y", false)], None),
340 run("b", true, vec![bool_eval("z", true)], None),
341 ]);
342 assert_eq!(report.summary.cases, 2);
344 assert_eq!(report.summary.runs, 3);
345 assert_eq!(report.summary.passed, 2);
346 assert_eq!(report.summary.failed, 1);
347 assert!(!report.passed);
348 assert!(report.summary.usage.is_none());
350 }
351
352 #[test]
353 fn empty_report_is_not_passed() {
354 let report = Report::new(vec![]);
355 assert!(!report.passed, "an empty run set is not a pass");
356 assert_eq!(report.summary.runs, 0);
357 }
358
359 #[test]
360 fn new_aggregates_usage_across_runs() {
361 let report = Report::new(vec![
362 run(
363 "a",
364 true,
365 vec![bool_eval("x", true)],
366 Some(Usage {
367 input_tokens: Some(10),
368 output_tokens: Some(2),
369 cost_usd: Some(0.01),
370 }),
371 ),
372 run(
373 "b",
374 true,
375 vec![bool_eval("y", true)],
376 Some(Usage {
377 input_tokens: Some(5),
378 output_tokens: None,
379 cost_usd: Some(0.02),
380 }),
381 ),
382 ]);
383 let usage = report.summary.usage.unwrap();
384 assert_eq!(usage.input_tokens, Some(15));
385 assert_eq!(usage.output_tokens, Some(2));
386 assert!((usage.cost_usd.unwrap() - 0.03).abs() < 1e-9);
387 }
388
389 #[test]
390 fn to_json_round_trips() {
391 let report = Report::new(vec![run("a", true, vec![bool_eval("x", true)], None)]);
392 let json = report.to_json().unwrap();
393 let parsed: Report = serde_json::from_str(&json).unwrap();
394 assert_eq!(parsed, report);
395 }
396
397 #[test]
398 fn to_human_lists_runs_and_failed_evals() {
399 let numeric = EvalOutcome {
400 label: "warmth".to_string(),
401 passed: false,
402 detail: EvalDetail::Numeric {
403 value: 4.0,
404 threshold: 7.0,
405 comparator: Comparator::Gte,
406 },
407 reason: "too cold".to_string(),
408 };
409 let report = Report::new(vec![
410 run("greets", true, vec![bool_eval("names", true)], None),
411 run("warm", false, vec![numeric], None),
412 ]);
413 let human = report.to_human();
414 assert!(human.contains("PASS greets [claude-code/sonnet]"));
415 assert!(human.contains("FAIL warm"));
416 assert!(human.contains("warmth: 4 >= 7 (too cold)"), "got:\n{human}");
418 assert!(human.contains("1/2 runs passed"));
419 }
420
421 #[test]
422 fn to_human_renders_usage_line_variants() {
423 let full = Report::new(vec![run(
425 "a",
426 true,
427 vec![bool_eval("x", true)],
428 Some(Usage {
429 input_tokens: Some(100),
430 output_tokens: Some(50),
431 cost_usd: Some(0.1234),
432 }),
433 )]);
434 let human = full.to_human();
435 assert!(
436 human.contains("usage: $0.1234, 100 in / 50 out tokens"),
437 "got:\n{human}"
438 );
439
440 let partial = Report::new(vec![run(
442 "a",
443 true,
444 vec![bool_eval("x", true)],
445 Some(Usage {
446 input_tokens: Some(7),
447 output_tokens: None,
448 cost_usd: None,
449 }),
450 )]);
451 assert!(partial.to_human().contains("usage: 7 input tokens"));
452
453 let out_only = Report::new(vec![run(
455 "a",
456 true,
457 vec![bool_eval("x", true)],
458 Some(Usage {
459 input_tokens: None,
460 output_tokens: Some(9),
461 cost_usd: None,
462 }),
463 )]);
464 assert!(out_only.to_human().contains("usage: 9 output tokens"));
465 }
466
467 #[test]
468 fn to_human_without_usage_has_no_usage_line() {
469 let report = Report::new(vec![run("a", true, vec![bool_eval("x", true)], None)]);
470 assert!(!report.to_human().contains("usage:"));
471 }
472
473 #[test]
474 fn to_human_shows_history_command_when_present() {
475 let mut with = run("a", true, vec![bool_eval("x", true)], None);
476 with.history_command = Some("oneharness history show sess --history-dir /h".into());
477 let report = Report::new(vec![with]);
478 let human = report.to_human();
479 assert!(
480 human.contains("history: oneharness history show sess --history-dir /h"),
481 "got:\n{human}"
482 );
483 let without = Report::new(vec![run("b", true, vec![bool_eval("x", true)], None)]);
485 assert!(!without.to_human().contains("history:"));
486 }
487
488 #[test]
489 fn validation_report_new_and_json() {
490 use crate::skill::Finding;
491 let empty = ValidationReport::new(&[]);
492 assert!(empty.valid);
493 assert!(empty.findings.is_empty());
494
495 let findings = vec![
496 Finding {
497 skill: std::path::PathBuf::from("/tmp/a"),
498 message: "missing name".to_string(),
499 },
500 Finding {
501 skill: std::path::PathBuf::from("/tmp/b"),
502 message: "no body".to_string(),
503 },
504 ];
505 let report = ValidationReport::new(&findings);
506 assert!(!report.valid);
507 assert_eq!(report.findings.len(), 2);
508 assert_eq!(report.findings[0].skill, "/tmp/a");
509 let json = report.to_json().unwrap();
510 let parsed: ValidationReport = serde_json::from_str(&json).unwrap();
511 assert_eq!(parsed, report);
512 }
513
514 #[test]
515 fn report_error_from_classified_provider_error() {
516 let err = Error::provider_classified(
517 "oneharness:claude-code",
518 "harness run failed: deadline",
519 ProviderErrorKind::Timeout,
520 );
521 let structured = ReportError::from_error(&err);
522 assert_eq!(structured.code, ErrorCode::Provider);
523 assert_eq!(structured.kind, Some(ProviderErrorKind::Timeout));
524 assert_eq!(
525 structured.context.as_deref(),
526 Some("oneharness:claude-code")
527 );
528 assert!(structured.message.contains("deadline"));
529 let json = structured.to_json().unwrap();
531 assert!(json.contains("\"kind\": \"timeout\""));
532 let parsed: ReportError = serde_json::from_str(&json).unwrap();
533 assert_eq!(parsed, structured);
534 }
535
536 #[test]
537 fn report_error_from_unclassified_provider_error() {
538 let err = Error::provider("oneharness", "boom");
539 let structured = ReportError::from_error(&err);
540 assert_eq!(structured.code, ErrorCode::Provider);
541 assert_eq!(structured.kind, None);
542 assert_eq!(structured.context.as_deref(), Some("oneharness"));
543 }
544
545 #[test]
546 fn report_error_from_usage_error() {
547 let structured = ReportError::from_error(&Error::Invalid("bad case".into()));
548 assert_eq!(structured.code, ErrorCode::Usage);
549 assert_eq!(structured.kind, None);
550 assert!(structured.context.is_none());
551 assert!(structured.message.contains("bad case"));
552 }
553}