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::eval::EvalOutcome;
11use crate::mock::MockCall;
12use crate::provider::Usage;
13use crate::skill::Finding;
14
15/// The result of running one test case on one (platform, model) pair.
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
17pub struct CaseRun {
18    /// The test case name.
19    pub case: String,
20    /// Absolute-ish path to the skill that was exercised.
21    pub skill: String,
22    /// The harness platform this run used.
23    pub platform: String,
24    /// The model this run used.
25    pub model: String,
26    /// True iff every eval in this run passed.
27    pub passed: bool,
28    /// Number of assistant turns produced.
29    pub turns: usize,
30    /// Per-eval outcomes, in declaration order.
31    pub evals: Vec<EvalOutcome>,
32    /// The full conversation, for debugging and deterministic mix-in checks.
33    pub transcript: Transcript,
34    /// Aggregated token/cost usage across every provider call in this run
35    /// (skill turns + simulated-user turns + judge calls). Omitted when no
36    /// usage was reported (e.g. the fake provider or a harness that doesn't
37    /// surface usage).
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub usage: Option<Usage>,
40    /// Every tool call the mock/spy channel observed, in order, with the
41    /// original (pre-rewrite) input and the verdict applied. `null` when the
42    /// channel was off for this run (no `mocks`, no `spy`); an empty array
43    /// means the channel was on and the skill made no tool calls — SDKs use
44    /// that distinction so a spy on a channel-less run errs instead of reading
45    /// as "zero calls".
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub mock_calls: Option<Vec<MockCall>>,
48}
49
50/// Aggregate pass/fail counts for a report.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
52pub struct Summary {
53    /// Distinct test cases represented.
54    pub cases: usize,
55    /// Total (case × platform × model) runs.
56    pub runs: usize,
57    /// Runs that passed.
58    pub passed: usize,
59    /// Runs that failed.
60    pub failed: usize,
61    /// Aggregated token/cost usage across every run in the report. Omitted
62    /// when no run reported usage.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub usage: Option<Usage>,
65}
66
67/// The top-level report for a `skilltest run` invocation.
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
69pub struct Report {
70    /// True iff every run passed.
71    pub passed: bool,
72    /// Aggregate counts.
73    pub summary: Summary,
74    /// Every individual run.
75    pub runs: Vec<CaseRun>,
76}
77
78impl Report {
79    /// Build a report from runs, computing the summary and overall pass.
80    #[must_use]
81    pub fn new(runs: Vec<CaseRun>) -> Self {
82        let mut case_names: Vec<&str> = runs.iter().map(|r| r.case.as_str()).collect();
83        case_names.sort_unstable();
84        case_names.dedup();
85        let passed_runs = runs.iter().filter(|r| r.passed).count();
86        let mut total_usage = Usage::default();
87        for run in &runs {
88            if let Some(u) = &run.usage {
89                total_usage.add(u);
90            }
91        }
92        let usage = (!total_usage.is_empty()).then_some(total_usage);
93        let summary = Summary {
94            cases: case_names.len(),
95            runs: runs.len(),
96            passed: passed_runs,
97            failed: runs.len() - passed_runs,
98            usage,
99        };
100        Report {
101            passed: summary.failed == 0 && !runs.is_empty(),
102            summary,
103            runs,
104        }
105    }
106
107    /// Serialize to pretty JSON (the `--format json` output).
108    ///
109    /// # Errors
110    /// [`serde_json::Error`] only if a contained value cannot serialize, which
111    /// should not happen for these types.
112    pub fn to_json(&self) -> Result<String, serde_json::Error> {
113        serde_json::to_string_pretty(self)
114    }
115
116    /// A compact, human-readable summary line per run plus a total. Quiet by
117    /// design: this is context the next reader has to parse.
118    #[must_use]
119    pub fn to_human(&self) -> String {
120        let mut out = String::new();
121        for run in &self.runs {
122            let mark = if run.passed { "PASS" } else { "FAIL" };
123            out.push_str(&format!(
124                "{mark}  {} [{}/{}]\n",
125                run.case, run.platform, run.model
126            ));
127            for eval in &run.evals {
128                if !eval.passed {
129                    out.push_str(&format!(
130                        "      - {}: {} ({})\n",
131                        eval.label,
132                        eval.detail.summary(),
133                        eval.reason
134                    ));
135                }
136            }
137        }
138        out.push_str(&format!(
139            "{}/{} runs passed\n",
140            self.summary.passed, self.summary.runs
141        ));
142        if let Some(usage) = &self.summary.usage {
143            let mut parts = Vec::new();
144            if let Some(cost) = usage.cost_usd {
145                parts.push(format!("${cost:.4}"));
146            }
147            if let (Some(i), Some(o)) = (usage.input_tokens, usage.output_tokens) {
148                parts.push(format!("{} in / {} out tokens", i, o));
149            } else {
150                if let Some(i) = usage.input_tokens {
151                    parts.push(format!("{i} input tokens"));
152                }
153                if let Some(o) = usage.output_tokens {
154                    parts.push(format!("{o} output tokens"));
155                }
156            }
157            if !parts.is_empty() {
158                out.push_str(&format!("usage: {}\n", parts.join(", ")));
159            }
160        }
161        out
162    }
163}
164
165/// One problem found while validating a skill, as serialized in the
166/// `skilltest validate --format json` output.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
168pub struct ValidationFinding {
169    /// The skill directory the finding is about.
170    pub skill: String,
171    /// What is wrong and how to fix it.
172    pub message: String,
173}
174
175/// The top-level report for a `skilltest validate` invocation.
176#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
177pub struct ValidationReport {
178    /// True iff no findings were produced.
179    pub valid: bool,
180    /// Every finding, in discovery order.
181    pub findings: Vec<ValidationFinding>,
182}
183
184impl ValidationReport {
185    /// Build a validation report from raw findings.
186    #[must_use]
187    pub fn new(findings: &[Finding]) -> Self {
188        ValidationReport {
189            valid: findings.is_empty(),
190            findings: findings
191                .iter()
192                .map(|f| ValidationFinding {
193                    skill: f.skill.to_string_lossy().into_owned(),
194                    message: f.message.clone(),
195                })
196                .collect(),
197        }
198    }
199
200    /// Serialize to pretty JSON (the `--format json` output).
201    ///
202    /// # Errors
203    /// [`serde_json::Error`] only if a contained value cannot serialize, which
204    /// should not happen for these types.
205    pub fn to_json(&self) -> Result<String, serde_json::Error> {
206        serde_json::to_string_pretty(self)
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::conversation::Transcript;
214    use crate::eval::{Comparator, EvalDetail, EvalOutcome};
215
216    fn run(case: &str, passed: bool, evals: Vec<EvalOutcome>, usage: Option<Usage>) -> CaseRun {
217        CaseRun {
218            case: case.to_string(),
219            skill: "/tmp/skill".to_string(),
220            platform: "claude-code".to_string(),
221            model: "sonnet".to_string(),
222            passed,
223            turns: 1,
224            evals,
225            transcript: Transcript::from_input("hi"),
226            usage,
227            mock_calls: None,
228        }
229    }
230
231    fn bool_eval(label: &str, passed: bool) -> EvalOutcome {
232        EvalOutcome {
233            label: label.to_string(),
234            passed,
235            detail: EvalDetail::Boolean {
236                value: passed,
237                expected: true,
238            },
239            reason: "because".to_string(),
240        }
241    }
242
243    #[test]
244    fn new_computes_summary_and_dedups_cases() {
245        let report = Report::new(vec![
246            run("a", true, vec![bool_eval("x", true)], None),
247            run("a", false, vec![bool_eval("y", false)], None),
248            run("b", true, vec![bool_eval("z", true)], None),
249        ]);
250        // Two distinct cases, three runs, one failure -> overall fail.
251        assert_eq!(report.summary.cases, 2);
252        assert_eq!(report.summary.runs, 3);
253        assert_eq!(report.summary.passed, 2);
254        assert_eq!(report.summary.failed, 1);
255        assert!(!report.passed);
256        // No run reported usage, so the summary omits it.
257        assert!(report.summary.usage.is_none());
258    }
259
260    #[test]
261    fn empty_report_is_not_passed() {
262        let report = Report::new(vec![]);
263        assert!(!report.passed, "an empty run set is not a pass");
264        assert_eq!(report.summary.runs, 0);
265    }
266
267    #[test]
268    fn new_aggregates_usage_across_runs() {
269        let report = Report::new(vec![
270            run(
271                "a",
272                true,
273                vec![bool_eval("x", true)],
274                Some(Usage {
275                    input_tokens: Some(10),
276                    output_tokens: Some(2),
277                    cost_usd: Some(0.01),
278                }),
279            ),
280            run(
281                "b",
282                true,
283                vec![bool_eval("y", true)],
284                Some(Usage {
285                    input_tokens: Some(5),
286                    output_tokens: None,
287                    cost_usd: Some(0.02),
288                }),
289            ),
290        ]);
291        let usage = report.summary.usage.unwrap();
292        assert_eq!(usage.input_tokens, Some(15));
293        assert_eq!(usage.output_tokens, Some(2));
294        assert!((usage.cost_usd.unwrap() - 0.03).abs() < 1e-9);
295    }
296
297    #[test]
298    fn to_json_round_trips() {
299        let report = Report::new(vec![run("a", true, vec![bool_eval("x", true)], None)]);
300        let json = report.to_json().unwrap();
301        let parsed: Report = serde_json::from_str(&json).unwrap();
302        assert_eq!(parsed, report);
303    }
304
305    #[test]
306    fn to_human_lists_runs_and_failed_evals() {
307        let numeric = EvalOutcome {
308            label: "warmth".to_string(),
309            passed: false,
310            detail: EvalDetail::Numeric {
311                value: 4.0,
312                threshold: 7.0,
313                comparator: Comparator::Gte,
314            },
315            reason: "too cold".to_string(),
316        };
317        let report = Report::new(vec![
318            run("greets", true, vec![bool_eval("names", true)], None),
319            run("warm", false, vec![numeric], None),
320        ]);
321        let human = report.to_human();
322        assert!(human.contains("PASS  greets [claude-code/sonnet]"));
323        assert!(human.contains("FAIL  warm"));
324        // Only the failing eval is itemized, with its summary and reason.
325        assert!(human.contains("warmth: 4 >= 7 (too cold)"), "got:\n{human}");
326        assert!(human.contains("1/2 runs passed"));
327    }
328
329    #[test]
330    fn to_human_renders_usage_line_variants() {
331        // Cost + both token counts.
332        let full = Report::new(vec![run(
333            "a",
334            true,
335            vec![bool_eval("x", true)],
336            Some(Usage {
337                input_tokens: Some(100),
338                output_tokens: Some(50),
339                cost_usd: Some(0.1234),
340            }),
341        )]);
342        let human = full.to_human();
343        assert!(
344            human.contains("usage: $0.1234, 100 in / 50 out tokens"),
345            "got:\n{human}"
346        );
347
348        // Only an input-token count (no cost, no output) hits the singular branch.
349        let partial = Report::new(vec![run(
350            "a",
351            true,
352            vec![bool_eval("x", true)],
353            Some(Usage {
354                input_tokens: Some(7),
355                output_tokens: None,
356                cost_usd: None,
357            }),
358        )]);
359        assert!(partial.to_human().contains("usage: 7 input tokens"));
360
361        // Only an output-token count.
362        let out_only = Report::new(vec![run(
363            "a",
364            true,
365            vec![bool_eval("x", true)],
366            Some(Usage {
367                input_tokens: None,
368                output_tokens: Some(9),
369                cost_usd: None,
370            }),
371        )]);
372        assert!(out_only.to_human().contains("usage: 9 output tokens"));
373    }
374
375    #[test]
376    fn to_human_without_usage_has_no_usage_line() {
377        let report = Report::new(vec![run("a", true, vec![bool_eval("x", true)], None)]);
378        assert!(!report.to_human().contains("usage:"));
379    }
380
381    #[test]
382    fn validation_report_new_and_json() {
383        use crate::skill::Finding;
384        let empty = ValidationReport::new(&[]);
385        assert!(empty.valid);
386        assert!(empty.findings.is_empty());
387
388        let findings = vec![
389            Finding {
390                skill: std::path::PathBuf::from("/tmp/a"),
391                message: "missing name".to_string(),
392            },
393            Finding {
394                skill: std::path::PathBuf::from("/tmp/b"),
395                message: "no body".to_string(),
396            },
397        ];
398        let report = ValidationReport::new(&findings);
399        assert!(!report.valid);
400        assert_eq!(report.findings.len(), 2);
401        assert_eq!(report.findings[0].skill, "/tmp/a");
402        let json = report.to_json().unwrap();
403        let parsed: ValidationReport = serde_json::from_str(&json).unwrap();
404        assert_eq!(parsed, report);
405    }
406}