Skip to main content

skilltest_core/
report.rs

1//! Run results and the JSON report. The serialized shape here is the **stable
2//! contract** the language SDKs parse. These types are the source of truth:
3//! their JSON Schemas (via `skilltest schema`, goldens in `schemas/`) are what
4//! the SDK contract tests compare their Pydantic/Zod models against.
5
6use 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/// The result of running one test case on one (platform, model) pair.
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
18pub struct CaseRun {
19    /// The test case name.
20    pub case: String,
21    /// Absolute-ish path to the skill that was exercised.
22    pub skill: String,
23    /// The harness platform this run used.
24    pub platform: String,
25    /// The model this run used.
26    pub model: String,
27    /// True iff every eval in this run passed.
28    pub passed: bool,
29    /// Number of assistant turns produced.
30    pub turns: usize,
31    /// Per-eval outcomes, in declaration order.
32    pub evals: Vec<EvalOutcome>,
33    /// The full conversation, for debugging and deterministic mix-in checks.
34    pub transcript: Transcript,
35    /// Aggregated token/cost usage across every provider call in this run
36    /// (skill turns + simulated-user turns + judge calls). Omitted when no
37    /// usage was reported (e.g. the fake provider or a harness that doesn't
38    /// surface usage).
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub usage: Option<Usage>,
41    /// Every tool call the mock/spy channel observed, in order, with the
42    /// original (pre-rewrite) input and the verdict applied. `null` when the
43    /// channel was off for this run (no `mocks`, no `spy`); an empty array
44    /// means the channel was on and the skill made no tool calls — SDKs use
45    /// that distinction so a spy on a channel-less run errs instead of reading
46    /// as "zero calls".
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub mock_calls: Option<Vec<MockCall>>,
49}
50
51/// Aggregate pass/fail counts for a report.
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
53pub struct Summary {
54    /// Distinct test cases represented.
55    pub cases: usize,
56    /// Total (case × platform × model) runs.
57    pub runs: usize,
58    /// Runs that passed.
59    pub passed: usize,
60    /// Runs that failed.
61    pub failed: usize,
62    /// Aggregated token/cost usage across every run in the report. Omitted
63    /// when no run reported usage.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub usage: Option<Usage>,
66}
67
68/// The top-level report for a `skilltest run` invocation.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
70pub struct Report {
71    /// True iff every run passed.
72    pub passed: bool,
73    /// Aggregate counts.
74    pub summary: Summary,
75    /// Every individual run.
76    pub runs: Vec<CaseRun>,
77}
78
79impl Report {
80    /// Build a report from runs, computing the summary and overall pass.
81    #[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    /// Serialize to pretty JSON (the `--format json` output).
109    ///
110    /// # Errors
111    /// [`serde_json::Error`] only if a contained value cannot serialize, which
112    /// should not happen for these types.
113    pub fn to_json(&self) -> Result<String, serde_json::Error> {
114        serde_json::to_string_pretty(self)
115    }
116
117    /// A compact, human-readable summary line per run plus a total. Quiet by
118    /// design: this is context the next reader has to parse.
119    #[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/// One problem found while validating a skill, as serialized in the
167/// `skilltest validate --format json` output.
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
169pub struct ValidationFinding {
170    /// The skill directory the finding is about.
171    pub skill: String,
172    /// What is wrong and how to fix it.
173    pub message: String,
174}
175
176/// The top-level report for a `skilltest validate` invocation.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
178pub struct ValidationReport {
179    /// True iff no findings were produced.
180    pub valid: bool,
181    /// Every finding, in discovery order.
182    pub findings: Vec<ValidationFinding>,
183}
184
185impl ValidationReport {
186    /// Build a validation report from raw findings.
187    #[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    /// Serialize to pretty JSON (the `--format json` output).
202    ///
203    /// # Errors
204    /// [`serde_json::Error`] only if a contained value cannot serialize, which
205    /// should not happen for these types.
206    pub fn to_json(&self) -> Result<String, serde_json::Error> {
207        serde_json::to_string_pretty(self)
208    }
209}
210
211/// Which class of failure a [`ReportError`] describes. Mirrors the process exit
212/// code so a JSON consumer gets the same coarse classification as a shell script
213/// branching on `$?`.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
215#[serde(rename_all = "snake_case")]
216pub enum ErrorCode {
217    /// Bad usage or input (exit 2): malformed config/case YAML, a missing file,
218    /// a semantically invalid test definition.
219    Usage,
220    /// A provider/environment failure (exit 3): the harness or judge could not
221    /// be reached or misbehaved.
222    Provider,
223}
224
225/// A structured error, emitted as the `--format json` / `json-stream` output
226/// when a `skilltest run` cannot produce a [`Report`].
227///
228/// This is the machine-readable counterpart to the human hint the CLI prints on
229/// stderr: it rides on stdout so SDK/plugin consumers get the [`ProviderErrorKind`]
230/// (and the `code`/`context`) for targeted handling — retry on
231/// [`ProviderErrorKind::Timeout`], fail fast on [`ProviderErrorKind::Auth`] —
232/// instead of matching substrings in the message. For `json` the object is
233/// emitted bare; for `json-stream` it is the terminal
234/// `{"type":"error","error":{…}}` line.
235#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
236pub struct ReportError {
237    /// The coarse failure class, matching the process exit code.
238    pub code: ErrorCode,
239    /// The structured provider-failure category, when skilltest could classify
240    /// it. Absent for usage errors and for unclassified provider failures.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub kind: Option<ProviderErrorKind>,
243    /// The provider context the failure came from (e.g. `oneharness:claude-code`,
244    /// `api-judge`). Absent for usage errors.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub context: Option<String>,
247    /// A human-readable description of what went wrong (the same text printed on
248    /// stderr, minus the suggested-action hint).
249    pub message: String,
250}
251
252impl ReportError {
253    /// Build the structured error for a core [`Error`]. The mapping mirrors the
254    /// CLI's error→exit-code mapping (`report_error`): [`Error::Provider`] is a
255    /// provider failure carrying its `kind`/`context`; everything else is a
256    /// usage error.
257    #[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    /// Serialize to pretty JSON (the bare `--format json` error output).
280    ///
281    /// # Errors
282    /// [`serde_json::Error`] only if a contained value cannot serialize, which
283    /// should not happen for these types.
284    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        // Two distinct cases, three runs, one failure -> overall fail.
330        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        // No run reported usage, so the summary omits it.
336        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        // Only the failing eval is itemized, with its summary and reason.
404        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        // Cost + both token counts.
411        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        // Only an input-token count (no cost, no output) hits the singular branch.
428        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        // Only an output-token count.
441        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        // Round-trips through the JSON contract, kind as a snake_case string.
502        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}