Skip to main content

skilltest_core/
error.rs

1//! Error type for the core library. The mapping from these errors to process
2//! exit codes lives in the CLI (see `exit.rs` for the documented codes).
3
4use std::path::PathBuf;
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8
9/// Result alias used throughout the core library.
10pub type Result<T> = std::result::Result<T, Error>;
11
12/// Structured classification of a provider failure.
13///
14/// This is the machine-readable category SDK/plugin consumers branch on instead
15/// of matching substrings in the human message (the reason this type exists: a
16/// timeout is [`ProviderErrorKind::Timeout`], not `message.contains("timed
17/// out")`). It is part of the JSON error contract — it serializes as a
18/// snake_case string and is re-exported through every SDK's generated models, so
19/// the vocabulary here is the vocabulary consumers see.
20///
21/// The set is closed on purpose so the generated SDK types are exhaustive
22/// unions; a failure skilltest cannot map to a specific category (e.g. a
23/// provider or oneharness failure label added upstream that this version does
24/// not recognize) becomes [`ProviderErrorKind::Other`] rather than a new,
25/// unhandled string.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
27#[serde(rename_all = "snake_case")]
28pub enum ProviderErrorKind {
29    /// Authentication/authorization failed (missing or rejected credentials).
30    Auth,
31    /// The provider rate-limited the call; a backoff-and-retry may succeed.
32    RateLimit,
33    /// The harness/vendor does not recognize the requested model.
34    ModelNotFound,
35    /// The account's quota or billing limit is exhausted.
36    Quota,
37    /// A transient server-side overload; retried internally where possible.
38    Overloaded,
39    /// The call exceeded its deadline (harness `--timeout`, curl `--max-time`).
40    Timeout,
41    /// The provider process could not be started (binary missing, not runnable).
42    Spawn,
43    /// The provider ran but produced output that violated the protocol
44    /// (unparseable envelope, missing results, malformed verdict).
45    Protocol,
46    /// A classified failure that does not map to a more specific category. The
47    /// human `message` carries the specifics.
48    Other,
49}
50
51impl ProviderErrorKind {
52    /// Map a raw failure label — an oneharness `failure_kind`, or a vendor error
53    /// class skilltest already normalized to one of these snake_case strings — to
54    /// a category. Unrecognized labels become [`ProviderErrorKind::Other`].
55    #[must_use]
56    pub fn classify(raw: &str) -> Self {
57        match raw {
58            "auth" => Self::Auth,
59            "rate_limit" => Self::RateLimit,
60            "model_not_found" => Self::ModelNotFound,
61            "quota" => Self::Quota,
62            "overloaded" => Self::Overloaded,
63            "timeout" => Self::Timeout,
64            "spawn" => Self::Spawn,
65            "protocol" => Self::Protocol,
66            _ => Self::Other,
67        }
68    }
69
70    /// The stable snake_case wire string for this kind (matches serde).
71    #[must_use]
72    pub fn as_str(self) -> &'static str {
73        match self {
74            Self::Auth => "auth",
75            Self::RateLimit => "rate_limit",
76            Self::ModelNotFound => "model_not_found",
77            Self::Quota => "quota",
78            Self::Overloaded => "overloaded",
79            Self::Timeout => "timeout",
80            Self::Spawn => "spawn",
81            Self::Protocol => "protocol",
82            Self::Other => "other",
83        }
84    }
85}
86
87impl std::fmt::Display for ProviderErrorKind {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.write_str(self.as_str())
90    }
91}
92
93/// Everything that can go wrong while loading configuration, parsing skill or
94/// test-case definitions, talking to a provider, or running evals.
95///
96/// Variants are grouped so the CLI can map them onto stable exit codes: input
97/// problems (`Config`, `Yaml`, `Skill`, `Validation`) are the user's to fix;
98/// `Provider` problems are environmental.
99#[derive(Debug, thiserror::Error)]
100pub enum Error {
101    /// A file the user pointed us at could not be read.
102    #[error("could not read {path}: {source}")]
103    Io {
104        path: PathBuf,
105        #[source]
106        source: std::io::Error,
107    },
108
109    /// A YAML document (config or test case) failed to parse.
110    #[error("invalid YAML in {path}: {source}")]
111    Yaml {
112        path: PathBuf,
113        #[source]
114        source: serde_yaml::Error,
115    },
116
117    /// A test case or config was syntactically valid YAML but semantically
118    /// wrong (e.g. a numeric eval with `min > max`).
119    #[error("invalid test definition: {0}")]
120    Invalid(String),
121
122    /// The provider command could not be spawned or did not behave. `kind`, when
123    /// set, classifies the failure (see [`ProviderErrorKind`]) so consumers can
124    /// distinguish a broken environment from a broken skill without parsing
125    /// `message`.
126    #[error("provider error ({context}): {message}")]
127    Provider {
128        context: String,
129        message: String,
130        kind: Option<ProviderErrorKind>,
131    },
132
133    /// A skill definition failed validation. Carries the human-readable
134    /// findings so the CLI can print them.
135    #[error("skill validation failed with {} finding(s)", .0.len())]
136    Validation(Vec<String>),
137}
138
139impl Error {
140    /// Construct a [`Error::Provider`] with no classification.
141    pub fn provider(context: impl Into<String>, message: impl std::fmt::Display) -> Self {
142        Error::Provider {
143            context: context.into(),
144            message: message.to_string(),
145            kind: None,
146        }
147    }
148
149    /// Construct a classified [`Error::Provider`] (e.g.
150    /// `kind = ProviderErrorKind::Auth`).
151    pub fn provider_classified(
152        context: impl Into<String>,
153        message: impl std::fmt::Display,
154        kind: ProviderErrorKind,
155    ) -> Self {
156        Error::Provider {
157            context: context.into(),
158            message: message.to_string(),
159            kind: Some(kind),
160        }
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    /// Every classified kind's `as_str`, its serde string, its `Display`, and
169    /// `classify` (the inverse) must agree — this is the vocabulary the JSON
170    /// error contract exposes to SDK consumers, so the three views can't drift.
171    #[test]
172    fn provider_error_kind_string_forms_agree() {
173        let all = [
174            ProviderErrorKind::Auth,
175            ProviderErrorKind::RateLimit,
176            ProviderErrorKind::ModelNotFound,
177            ProviderErrorKind::Quota,
178            ProviderErrorKind::Overloaded,
179            ProviderErrorKind::Timeout,
180            ProviderErrorKind::Spawn,
181            ProviderErrorKind::Protocol,
182            ProviderErrorKind::Other,
183        ];
184        for kind in all {
185            let wire = kind.as_str();
186            // serde serializes to the same bare string.
187            assert_eq!(serde_json::to_value(kind).unwrap(), serde_json::json!(wire));
188            // Display matches as_str.
189            assert_eq!(kind.to_string(), wire);
190            // classify is the inverse for known labels.
191            assert_eq!(ProviderErrorKind::classify(wire), kind);
192        }
193    }
194
195    #[test]
196    fn classify_maps_unknown_labels_to_other() {
197        assert_eq!(
198            ProviderErrorKind::classify("brand_new_upstream_kind"),
199            ProviderErrorKind::Other
200        );
201    }
202}