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