use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ComposeError {
#[error("payload is not self-describing: missing ucp.capabilities (response) or meta.profile (request)")]
NotSelfDescribing,
#[error("no capabilities declared in ucp.capabilities")]
EmptyCapabilities,
#[error("invalid JSONRPC envelope: {message}")]
InvalidEnvelope { message: String },
#[error("no root capability found (all capabilities have 'extends')")]
NoRootCapability,
#[error("multiple root capabilities found: {}", names.join(", "))]
MultipleRootCapabilities { names: Vec<String> },
#[error("extension '{extension}' references unknown parent '{parent}'")]
UnknownParent { extension: String, parent: String },
#[error("extension '{extension}' does not connect to root '{root}'")]
OrphanExtension { extension: String, root: String },
#[error("extension '{extension}' missing $defs entry for '{expected_key}'")]
MissingDefEntry {
extension: String,
expected_key: String,
},
#[error(
"extension '{extension}' does not mirror container capability '{capability}': \
its $defs['{capability}'] must contain a nested $defs of operation shapes"
)]
ContainerExtensionShape {
extension: String,
capability: String,
},
#[error("failed to fetch schema from {url}: {message}")]
SchemaFetch { url: String, message: String },
#[error("failed to fetch profile from {url}: {message}")]
ProfileFetch { url: String, message: String },
#[error("invalid capability '{name}': {message}")]
InvalidCapability { name: String, message: String },
#[error("invalid URL '{url}': {message}")]
InvalidUrl { url: String, message: String },
#[error("extension '{extension}' requires {target} {range} but found {actual}")]
VersionConstraintViolation {
extension: String,
target: String,
range: String,
actual: String,
},
}
impl ComposeError {
pub fn exit_code(&self) -> i32 {
match self {
Self::SchemaFetch { .. } | Self::ProfileFetch { .. } => 3, _ => 2, }
}
}
#[derive(Debug, Error)]
pub enum ResolveError {
#[error("file not found: {path}")]
FileNotFound { path: PathBuf },
#[error("cannot read {path}: {source}")]
ReadError {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[cfg(feature = "remote")]
#[error("failed to fetch {url}: {source}")]
NetworkError {
url: String,
#[source]
source: reqwest::Error,
},
#[error("invalid JSON: {source}")]
InvalidJson {
#[source]
source: serde_json::Error,
},
#[error("invalid annotation at {path}: expected string or object, got {actual}")]
InvalidAnnotationType { path: String, actual: String },
#[error("unknown visibility \"{value}\" at {path}: expected omit, required, or optional")]
UnknownVisibility { path: String, value: String },
#[error("invalid schema transition at {path}: {message}")]
InvalidSchemaTransition { path: String, message: String },
#[error(
"monotonicity violation at {path}: field \"{field}\" is {base_status} in base schema \
but extension sets it to \"{attempted}\""
)]
MonotonicityViolation {
path: String,
field: String,
base_status: String,
attempted: String,
},
#[error(
"type conflict at {path}: base declares \"{base_type}\" but extension declares \"{ext_type}\""
)]
TypeConflict {
path: String,
base_type: String,
ext_type: String,
},
#[error("invalid schema: {message}")]
InvalidSchema { message: String },
#[error(
"container schema has no operation shape '{key}' for this (op, direction); \
available operation shapes: [{available}]"
)]
OperationShapeNotFound { key: String, available: String },
#[error("schema has no $defs entry '{def}'; available: [{available}]")]
DefNotFound { def: String, available: String },
#[error("failed to bundle schema: {message}")]
BundleError { message: String },
}
#[derive(Debug, Error)]
pub enum ValidateError {
#[error(transparent)]
Resolve(#[from] ResolveError),
#[error("validation failed with {} error(s)", errors.len())]
Invalid { errors: Vec<SchemaError> },
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SchemaError {
pub path: String,
pub message: String,
}
impl std::fmt::Display for SchemaError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.path, self.message)
}
}
impl ResolveError {
pub fn exit_code(&self) -> i32 {
match self {
ResolveError::FileNotFound { .. } | ResolveError::ReadError { .. } => 3,
#[cfg(feature = "remote")]
ResolveError::NetworkError { .. } => 3,
_ => 2,
}
}
}
impl ValidateError {
pub fn exit_code(&self) -> i32 {
match self {
ValidateError::Resolve(e) => e.exit_code(),
ValidateError::Invalid { .. } => 1,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_error_exit_codes() {
let err = ResolveError::FileNotFound {
path: PathBuf::from("test.json"),
};
assert_eq!(err.exit_code(), 3);
let err = ResolveError::InvalidAnnotationType {
path: "/properties/id".into(),
actual: "number".into(),
};
assert_eq!(err.exit_code(), 2);
let err = ResolveError::UnknownVisibility {
path: "/properties/id".into(),
value: "readonly".into(),
};
assert_eq!(err.exit_code(), 2);
}
#[test]
fn validate_error_exit_codes() {
let err = ValidateError::Invalid {
errors: vec![SchemaError {
path: "/id".into(),
message: "missing required field".into(),
}],
};
assert_eq!(err.exit_code(), 1);
}
#[test]
fn schema_error_display() {
let err = SchemaError {
path: "/buyer/email".into(),
message: "expected string, got number".into(),
};
assert_eq!(err.to_string(), "/buyer/email: expected string, got number");
}
}