use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::conversation::Transcript;
use crate::error::{Error, ProviderErrorKind};
use crate::eval::EvalOutcome;
use crate::mock::MockCall;
use crate::provider::Usage;
use crate::skill::Finding;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct CaseRun {
pub case: String,
pub skill: String,
pub platform: String,
pub model: String,
pub passed: bool,
pub turns: usize,
pub evals: Vec<EvalOutcome>,
pub transcript: Transcript,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mock_calls: Option<Vec<MockCall>>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Summary {
pub cases: usize,
pub runs: usize,
pub passed: usize,
pub failed: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct Report {
pub passed: bool,
pub summary: Summary,
pub runs: Vec<CaseRun>,
}
impl Report {
#[must_use]
pub fn new(runs: Vec<CaseRun>) -> Self {
let mut case_names: Vec<&str> = runs.iter().map(|r| r.case.as_str()).collect();
case_names.sort_unstable();
case_names.dedup();
let passed_runs = runs.iter().filter(|r| r.passed).count();
let mut total_usage = Usage::default();
for run in &runs {
if let Some(u) = &run.usage {
total_usage.add(u);
}
}
let usage = (!total_usage.is_empty()).then_some(total_usage);
let summary = Summary {
cases: case_names.len(),
runs: runs.len(),
passed: passed_runs,
failed: runs.len() - passed_runs,
usage,
};
Report {
passed: summary.failed == 0 && !runs.is_empty(),
summary,
runs,
}
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
#[must_use]
pub fn to_human(&self) -> String {
let mut out = String::new();
for run in &self.runs {
let mark = if run.passed { "PASS" } else { "FAIL" };
out.push_str(&format!(
"{mark} {} [{}/{}]\n",
run.case, run.platform, run.model
));
for eval in &run.evals {
if !eval.passed {
out.push_str(&format!(
" - {}: {} ({})\n",
eval.label,
eval.detail.summary(),
eval.reason
));
}
}
}
out.push_str(&format!(
"{}/{} runs passed\n",
self.summary.passed, self.summary.runs
));
if let Some(usage) = &self.summary.usage {
let mut parts = Vec::new();
if let Some(cost) = usage.cost_usd {
parts.push(format!("${cost:.4}"));
}
if let (Some(i), Some(o)) = (usage.input_tokens, usage.output_tokens) {
parts.push(format!("{} in / {} out tokens", i, o));
} else {
if let Some(i) = usage.input_tokens {
parts.push(format!("{i} input tokens"));
}
if let Some(o) = usage.output_tokens {
parts.push(format!("{o} output tokens"));
}
}
if !parts.is_empty() {
out.push_str(&format!("usage: {}\n", parts.join(", ")));
}
}
out
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ValidationFinding {
pub skill: String,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ValidationReport {
pub valid: bool,
pub findings: Vec<ValidationFinding>,
}
impl ValidationReport {
#[must_use]
pub fn new(findings: &[Finding]) -> Self {
ValidationReport {
valid: findings.is_empty(),
findings: findings
.iter()
.map(|f| ValidationFinding {
skill: f.skill.to_string_lossy().into_owned(),
message: f.message.clone(),
})
.collect(),
}
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ErrorCode {
Usage,
Provider,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ReportError {
pub code: ErrorCode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<ProviderErrorKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
pub message: String,
}
impl ReportError {
#[must_use]
pub fn from_error(err: &Error) -> Self {
match err {
Error::Provider {
context,
message,
kind,
} => ReportError {
code: ErrorCode::Provider,
kind: *kind,
context: Some(context.clone()),
message: message.clone(),
},
other => ReportError {
code: ErrorCode::Usage,
kind: None,
context: None,
message: other.to_string(),
},
}
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conversation::Transcript;
use crate::eval::{Comparator, EvalDetail, EvalOutcome};
fn run(case: &str, passed: bool, evals: Vec<EvalOutcome>, usage: Option<Usage>) -> CaseRun {
CaseRun {
case: case.to_string(),
skill: "/tmp/skill".to_string(),
platform: "claude-code".to_string(),
model: "sonnet".to_string(),
passed,
turns: 1,
evals,
transcript: Transcript::from_input("hi"),
usage,
mock_calls: None,
}
}
fn bool_eval(label: &str, passed: bool) -> EvalOutcome {
EvalOutcome {
label: label.to_string(),
passed,
detail: EvalDetail::Boolean {
value: passed,
expected: true,
},
reason: "because".to_string(),
}
}
#[test]
fn new_computes_summary_and_dedups_cases() {
let report = Report::new(vec![
run("a", true, vec![bool_eval("x", true)], None),
run("a", false, vec![bool_eval("y", false)], None),
run("b", true, vec![bool_eval("z", true)], None),
]);
assert_eq!(report.summary.cases, 2);
assert_eq!(report.summary.runs, 3);
assert_eq!(report.summary.passed, 2);
assert_eq!(report.summary.failed, 1);
assert!(!report.passed);
assert!(report.summary.usage.is_none());
}
#[test]
fn empty_report_is_not_passed() {
let report = Report::new(vec![]);
assert!(!report.passed, "an empty run set is not a pass");
assert_eq!(report.summary.runs, 0);
}
#[test]
fn new_aggregates_usage_across_runs() {
let report = Report::new(vec![
run(
"a",
true,
vec![bool_eval("x", true)],
Some(Usage {
input_tokens: Some(10),
output_tokens: Some(2),
cost_usd: Some(0.01),
}),
),
run(
"b",
true,
vec![bool_eval("y", true)],
Some(Usage {
input_tokens: Some(5),
output_tokens: None,
cost_usd: Some(0.02),
}),
),
]);
let usage = report.summary.usage.unwrap();
assert_eq!(usage.input_tokens, Some(15));
assert_eq!(usage.output_tokens, Some(2));
assert!((usage.cost_usd.unwrap() - 0.03).abs() < 1e-9);
}
#[test]
fn to_json_round_trips() {
let report = Report::new(vec![run("a", true, vec![bool_eval("x", true)], None)]);
let json = report.to_json().unwrap();
let parsed: Report = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, report);
}
#[test]
fn to_human_lists_runs_and_failed_evals() {
let numeric = EvalOutcome {
label: "warmth".to_string(),
passed: false,
detail: EvalDetail::Numeric {
value: 4.0,
threshold: 7.0,
comparator: Comparator::Gte,
},
reason: "too cold".to_string(),
};
let report = Report::new(vec![
run("greets", true, vec![bool_eval("names", true)], None),
run("warm", false, vec![numeric], None),
]);
let human = report.to_human();
assert!(human.contains("PASS greets [claude-code/sonnet]"));
assert!(human.contains("FAIL warm"));
assert!(human.contains("warmth: 4 >= 7 (too cold)"), "got:\n{human}");
assert!(human.contains("1/2 runs passed"));
}
#[test]
fn to_human_renders_usage_line_variants() {
let full = Report::new(vec![run(
"a",
true,
vec![bool_eval("x", true)],
Some(Usage {
input_tokens: Some(100),
output_tokens: Some(50),
cost_usd: Some(0.1234),
}),
)]);
let human = full.to_human();
assert!(
human.contains("usage: $0.1234, 100 in / 50 out tokens"),
"got:\n{human}"
);
let partial = Report::new(vec![run(
"a",
true,
vec![bool_eval("x", true)],
Some(Usage {
input_tokens: Some(7),
output_tokens: None,
cost_usd: None,
}),
)]);
assert!(partial.to_human().contains("usage: 7 input tokens"));
let out_only = Report::new(vec![run(
"a",
true,
vec![bool_eval("x", true)],
Some(Usage {
input_tokens: None,
output_tokens: Some(9),
cost_usd: None,
}),
)]);
assert!(out_only.to_human().contains("usage: 9 output tokens"));
}
#[test]
fn to_human_without_usage_has_no_usage_line() {
let report = Report::new(vec![run("a", true, vec![bool_eval("x", true)], None)]);
assert!(!report.to_human().contains("usage:"));
}
#[test]
fn validation_report_new_and_json() {
use crate::skill::Finding;
let empty = ValidationReport::new(&[]);
assert!(empty.valid);
assert!(empty.findings.is_empty());
let findings = vec![
Finding {
skill: std::path::PathBuf::from("/tmp/a"),
message: "missing name".to_string(),
},
Finding {
skill: std::path::PathBuf::from("/tmp/b"),
message: "no body".to_string(),
},
];
let report = ValidationReport::new(&findings);
assert!(!report.valid);
assert_eq!(report.findings.len(), 2);
assert_eq!(report.findings[0].skill, "/tmp/a");
let json = report.to_json().unwrap();
let parsed: ValidationReport = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, report);
}
#[test]
fn report_error_from_classified_provider_error() {
let err = Error::provider_classified(
"oneharness:claude-code",
"harness run failed: deadline",
ProviderErrorKind::Timeout,
);
let structured = ReportError::from_error(&err);
assert_eq!(structured.code, ErrorCode::Provider);
assert_eq!(structured.kind, Some(ProviderErrorKind::Timeout));
assert_eq!(
structured.context.as_deref(),
Some("oneharness:claude-code")
);
assert!(structured.message.contains("deadline"));
let json = structured.to_json().unwrap();
assert!(json.contains("\"kind\": \"timeout\""));
let parsed: ReportError = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, structured);
}
#[test]
fn report_error_from_unclassified_provider_error() {
let err = Error::provider("oneharness", "boom");
let structured = ReportError::from_error(&err);
assert_eq!(structured.code, ErrorCode::Provider);
assert_eq!(structured.kind, None);
assert_eq!(structured.context.as_deref(), Some("oneharness"));
}
#[test]
fn report_error_from_usage_error() {
let structured = ReportError::from_error(&Error::Invalid("bad case".into()));
assert_eq!(structured.code, ErrorCode::Usage);
assert_eq!(structured.kind, None);
assert!(structured.context.is_none());
assert!(structured.message.contains("bad case"));
}
}