Skip to main content

ucp_schema/
error.rs

1//! Error types for UCP schema resolution and validation.
2
3use std::path::PathBuf;
4use thiserror::Error;
5
6/// Errors during schema composition from UCP capability metadata.
7#[derive(Debug, Error)]
8pub enum ComposeError {
9    #[error("payload is not self-describing: missing ucp.capabilities (response) or meta.profile (request)")]
10    NotSelfDescribing,
11
12    #[error("no capabilities declared in ucp.capabilities")]
13    EmptyCapabilities,
14
15    #[error("invalid JSONRPC envelope: {message}")]
16    InvalidEnvelope { message: String },
17
18    #[error("no root capability found (all capabilities have 'extends')")]
19    NoRootCapability,
20
21    #[error("multiple root capabilities found: {}", names.join(", "))]
22    MultipleRootCapabilities { names: Vec<String> },
23
24    #[error("extension '{extension}' references unknown parent '{parent}'")]
25    UnknownParent { extension: String, parent: String },
26
27    #[error("extension '{extension}' does not connect to root '{root}'")]
28    OrphanExtension { extension: String, root: String },
29
30    #[error("extension '{extension}' missing $defs entry for '{expected_key}'")]
31    MissingDefEntry {
32        extension: String,
33        expected_key: String,
34    },
35
36    /// A container-shaped capability (request/response shapes live under
37    /// `$defs/{op}_{direction}`) is extended by a schema whose `$defs[<capability>]`
38    /// is not itself a container of operation shapes. Container extensions MUST
39    /// mirror the base's operation keys (e.g. `{ "$defs": { "search_response": ... } }`).
40    #[error(
41        "extension '{extension}' does not mirror container capability '{capability}': \
42         its $defs['{capability}'] must contain a nested $defs of operation shapes"
43    )]
44    ContainerExtensionShape {
45        extension: String,
46        capability: String,
47    },
48
49    #[error("failed to fetch schema from {url}: {message}")]
50    SchemaFetch { url: String, message: String },
51
52    #[error("failed to fetch profile from {url}: {message}")]
53    ProfileFetch { url: String, message: String },
54
55    #[error("invalid capability '{name}': {message}")]
56    InvalidCapability { name: String, message: String },
57
58    #[error("invalid URL '{url}': {message}")]
59    InvalidUrl { url: String, message: String },
60
61    #[error("extension '{extension}' requires {target} {range} but found {actual}")]
62    VersionConstraintViolation {
63        extension: String,
64        target: String,
65        range: String,
66        actual: String,
67    },
68}
69
70impl ComposeError {
71    /// Returns the exit code for this error type.
72    pub fn exit_code(&self) -> i32 {
73        match self {
74            Self::SchemaFetch { .. } | Self::ProfileFetch { .. } => 3, // IO
75            _ => 2,                                                    // Schema/composition error
76        }
77    }
78}
79
80/// Errors during schema resolution.
81#[derive(Debug, Error)]
82pub enum ResolveError {
83    // IO errors (exit code 3)
84    #[error("file not found: {path}")]
85    FileNotFound { path: PathBuf },
86
87    #[error("cannot read {path}: {source}")]
88    ReadError {
89        path: PathBuf,
90        #[source]
91        source: std::io::Error,
92    },
93
94    #[cfg(feature = "remote")]
95    #[error("failed to fetch {url}: {source}")]
96    NetworkError {
97        url: String,
98        #[source]
99        source: reqwest::Error,
100    },
101
102    // Parse errors (exit code 2)
103    #[error("invalid JSON: {source}")]
104    InvalidJson {
105        #[source]
106        source: serde_json::Error,
107    },
108
109    // Schema errors (exit code 2)
110    #[error("invalid annotation at {path}: expected string or object, got {actual}")]
111    InvalidAnnotationType { path: String, actual: String },
112
113    #[error("unknown visibility \"{value}\" at {path}: expected omit, required, or optional")]
114    UnknownVisibility { path: String, value: String },
115
116    #[error("invalid schema transition at {path}: {message}")]
117    InvalidSchemaTransition { path: String, message: String },
118
119    /// allOf extension tries to weaken a field that base declares as required.
120    /// Monotonicity rule: extensions can narrow (optional→omit) or strengthen
121    /// (optional→required) but never weaken required fields.
122    #[error(
123        "monotonicity violation at {path}: field \"{field}\" is {base_status} in base schema \
124         but extension sets it to \"{attempted}\""
125    )]
126    MonotonicityViolation {
127        path: String,
128        field: String,
129        base_status: String,
130        attempted: String,
131    },
132
133    /// allOf branches declare contradictory types on the same property.
134    #[error(
135        "type conflict at {path}: base declares \"{base_type}\" but extension declares \"{ext_type}\""
136    )]
137    TypeConflict {
138        path: String,
139        base_type: String,
140        ext_type: String,
141    },
142
143    #[error("invalid schema: {message}")]
144    InvalidSchema { message: String },
145
146    /// A container-shaped capability schema has no message body for the
147    /// requested `(op, direction)`. The body lives at `$defs/{op}_{direction}`;
148    /// because a container root has no body of its own, an absent key is a hard
149    /// error rather than a fall-through to an unconstrained root.
150    #[error(
151        "container schema has no operation shape '{key}' for this (op, direction); \
152         available operation shapes: [{available}]"
153    )]
154    OperationShapeNotFound { key: String, available: String },
155
156    /// An explicit `--def` / `def_name` selector names a `$defs` entry that the
157    /// resolved schema does not contain. Used for non-derivable shapes (transport
158    /// message types, host views, sub-types) where the name is authored, not
159    /// computed from `(op, direction)`.
160    #[error("schema has no $defs entry '{def}'; available: [{available}]")]
161    DefNotFound { def: String, available: String },
162
163    #[error("failed to bundle schema: {message}")]
164    BundleError { message: String },
165}
166
167/// Errors during validation.
168#[derive(Debug, Error)]
169pub enum ValidateError {
170    #[error(transparent)]
171    Resolve(#[from] ResolveError),
172
173    #[error("validation failed with {} error(s)", errors.len())]
174    Invalid { errors: Vec<SchemaError> },
175}
176
177/// Single validation error with path context.
178#[derive(Debug, Clone, serde::Serialize)]
179pub struct SchemaError {
180    /// JSON Pointer (RFC 6901) to the invalid field.
181    pub path: String,
182    /// Human-readable error message.
183    pub message: String,
184}
185
186impl std::fmt::Display for SchemaError {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        write!(f, "{}: {}", self.path, self.message)
189    }
190}
191
192impl ResolveError {
193    /// Returns the exit code for this error type.
194    pub fn exit_code(&self) -> i32 {
195        match self {
196            ResolveError::FileNotFound { .. } | ResolveError::ReadError { .. } => 3,
197            #[cfg(feature = "remote")]
198            ResolveError::NetworkError { .. } => 3,
199            _ => 2,
200        }
201    }
202}
203
204impl ValidateError {
205    /// Returns the exit code for this error type.
206    pub fn exit_code(&self) -> i32 {
207        match self {
208            ValidateError::Resolve(e) => e.exit_code(),
209            ValidateError::Invalid { .. } => 1,
210        }
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn resolve_error_exit_codes() {
220        let err = ResolveError::FileNotFound {
221            path: PathBuf::from("test.json"),
222        };
223        assert_eq!(err.exit_code(), 3);
224
225        let err = ResolveError::InvalidAnnotationType {
226            path: "/properties/id".into(),
227            actual: "number".into(),
228        };
229        assert_eq!(err.exit_code(), 2);
230
231        let err = ResolveError::UnknownVisibility {
232            path: "/properties/id".into(),
233            value: "readonly".into(),
234        };
235        assert_eq!(err.exit_code(), 2);
236    }
237
238    #[test]
239    fn validate_error_exit_codes() {
240        let err = ValidateError::Invalid {
241            errors: vec![SchemaError {
242                path: "/id".into(),
243                message: "missing required field".into(),
244            }],
245        };
246        assert_eq!(err.exit_code(), 1);
247    }
248
249    #[test]
250    fn schema_error_display() {
251        let err = SchemaError {
252            path: "/buyer/email".into(),
253            message: "expected string, got number".into(),
254        };
255        assert_eq!(err.to_string(), "/buyer/email: expected string, got number");
256    }
257}