use std::fmt;
use std::time::Duration;
use crate::chain::ProviderId;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ChainViewError {
#[error("provider {provider}: {source}")]
Provider {
provider: ProviderId,
#[source]
source: ProviderError,
},
#[error("result bundle: {0}")]
Bundle(#[from] BundleError),
#[error("config: {0}")]
Config(#[from] ConfigError),
#[error("provider registry: {0}")]
Registry(#[from] RegistryError),
#[error("terminal: {0}")]
Terminal(String),
}
impl ChainViewError {
#[cold]
#[inline(never)]
#[must_use]
pub fn provider(provider: ProviderId, source: ProviderError) -> Self {
Self::Provider { provider, source }
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum RegistryError {
#[error("provider id `{0}` is reserved for a built-in adapter")]
ReservedId(ProviderId),
#[error("provider id `{0}` is already registered")]
DuplicateId(ProviderId),
#[error(
"provider id `{0}` is a gated built-in and cannot be enabled while its security gate holds"
)]
Gated(ProviderId),
#[error("no providers registered")]
Empty,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum BundleError {
#[error("missing table: {0}")]
MissingTable(String),
#[error("bundle io: {0}")]
Io(String),
#[error("unsupported schema: {0}")]
UnsupportedSchema(String),
#[error("invariant violated: {0}")]
Invariant(String),
#[error("bundle schema mismatch: {0}")]
Schema(String),
#[error("resource ceiling: {0}")]
Config(#[from] ConfigError),
#[error("bundle too large: {0}")]
TooLarge(String),
#[error("parquet: {0}")]
Parquet(String),
#[error("bundle load cancelled")]
Cancelled,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ConfigError {
#[error("missing credential for provider {0}")]
MissingCredential(ProviderId),
#[error("unknown provider: {0}")]
UnknownProvider(String),
#[error("invalid value for {field}: {reason}")]
InvalidValue {
field: String,
reason: String,
},
}
#[derive(Debug)]
#[non_exhaustive]
pub enum OverlayError {
SpecMismatch {
contract: String,
field: &'static str,
source: String,
overlay: String,
},
MissingAlias {
contract: String,
provider: ProviderId,
},
}
impl fmt::Display for OverlayError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::SpecMismatch {
contract,
field,
source,
overlay,
} => write!(
f,
"overlay spec mismatch on {field} for {contract}: \
source `{source}` vs overlay `{overlay}`"
),
Self::MissingAlias { contract, provider } => write!(
f,
"overlay gate unchecked for {contract}: \
feed `{provider}` has no alias for the leg"
),
}
}
}
impl std::error::Error for OverlayError {}
pub trait Redacted: fmt::Display + fmt::Debug + Send + Sync {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum TransportKind {
Timeout,
Closed,
Tls,
Http,
Decode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TransportDetail {
pub kind: TransportKind,
pub http_status: Option<u16>,
}
impl TransportDetail {
#[cold]
#[inline(never)]
#[must_use]
pub fn new(kind: TransportKind, http_status: Option<u16>) -> Self {
Self { kind, http_status }
}
}
impl fmt::Display for TransportDetail {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let kind = match self.kind {
TransportKind::Timeout => "timeout",
TransportKind::Closed => "closed",
TransportKind::Tls => "tls",
TransportKind::Http => "http",
TransportKind::Decode => "decode",
};
match self.http_status {
Some(status) => write!(f, "transport: {kind} {status}"),
None => write!(f, "transport: {kind}"),
}
}
}
impl Redacted for TransportDetail {}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ProviderError {
#[error("not supported by this provider: {0}")]
Unsupported(&'static str),
#[error("authentication failed")]
Auth,
#[error("upstream transport: {0}")]
Transport(Box<dyn Redacted>),
#[error("normalize: {kind}")]
Normalize {
kind: NormalizeKind,
},
#[error("rate limited; retry after {0:?}")]
RateLimited(Option<Duration>),
#[error("no chain for {underlying} @ {expiration}")]
NoChain {
underlying: String,
expiration: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum NormalizeKind {
MissingField(&'static str),
OutOfRange(&'static str),
NonFinite(&'static str),
UnparseableExpiry,
UnknownStyle,
LimitExceeded(&'static str),
}
impl fmt::Display for NormalizeKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingField(field) => write!(f, "missing field `{field}`"),
Self::OutOfRange(field) => write!(f, "out-of-range field `{field}`"),
Self::NonFinite(field) => write!(f, "non-finite field `{field}`"),
Self::UnparseableExpiry => f.write_str("unparseable expiry"),
Self::UnknownStyle => f.write_str("unknown option style"),
Self::LimitExceeded(cap) => write!(f, "limit exceeded: {cap}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[track_caller]
fn provider_id(id: &str) -> ProviderId {
match ProviderId::new(id) {
Ok(p) => p,
Err(e) => panic!("expected a valid provider id `{id}`, got: {e}"),
}
}
#[test]
fn test_transport_detail_display_emits_category_and_status_only() {
let detail = TransportDetail::new(TransportKind::Http, Some(503));
assert_eq!(detail.to_string(), "transport: http 503");
}
#[test]
fn test_transport_detail_display_omits_status_when_absent() {
let detail = TransportDetail::new(TransportKind::Timeout, None);
assert_eq!(detail.to_string(), "transport: timeout");
}
#[test]
fn test_transport_detail_display_never_emits_url_or_body() {
for kind in [
TransportKind::Timeout,
TransportKind::Closed,
TransportKind::Tls,
TransportKind::Http,
TransportKind::Decode,
] {
let rendered = TransportDetail::new(kind, Some(418)).to_string();
assert!(rendered.starts_with("transport: "));
assert!(!rendered.contains("http://"));
assert!(!rendered.contains("https://"));
assert!(!rendered.contains("token"));
}
}
#[test]
fn test_provider_error_transport_accepts_only_redacted_detail() {
let detail: Box<dyn Redacted> =
Box::new(TransportDetail::new(TransportKind::Http, Some(500)));
let err = ProviderError::Transport(detail);
assert_eq!(err.to_string(), "upstream transport: transport: http 500");
}
#[test]
fn test_normalize_kind_display_names_field_not_value() {
let kind = NormalizeKind::MissingField("strike");
assert_eq!(kind.to_string(), "missing field `strike`");
}
#[test]
fn test_provider_error_normalize_display_names_field() {
let err = ProviderError::Normalize {
kind: NormalizeKind::OutOfRange("delta"),
};
assert_eq!(err.to_string(), "normalize: out-of-range field `delta`");
}
#[test]
fn test_normalize_kind_limit_exceeded_display_names_cap_not_value() {
let kind = NormalizeKind::LimitExceeded("discovery page cap");
assert_eq!(kind.to_string(), "limit exceeded: discovery page cap");
let err = ProviderError::Normalize { kind };
assert_eq!(
err.to_string(),
"normalize: limit exceeded: discovery page cap"
);
}
#[test]
fn test_provider_error_no_chain_display_uses_normalized_values() {
let err = ProviderError::NoChain {
underlying: "BTC".to_owned(),
expiration: "2025-06-27T08:00:00Z".to_owned(),
};
assert_eq!(err.to_string(), "no chain for BTC @ 2025-06-27T08:00:00Z");
}
#[test]
fn test_provider_error_rate_limited_display_carries_only_delay() {
let err = ProviderError::RateLimited(Some(Duration::from_secs(5)));
let rendered = err.to_string();
assert!(rendered.starts_with("rate limited; retry after "));
assert!(!rendered.contains("token"));
}
#[test]
fn test_provider_error_unsupported_display_is_static_message() {
let err = ProviderError::Unsupported("chain discovery");
assert_eq!(
err.to_string(),
"not supported by this provider: chain discovery"
);
}
#[test]
fn test_config_error_missing_credential_display_names_provider() {
let err = ConfigError::MissingCredential(provider_id("deribit"));
let rendered = err.to_string();
assert_eq!(rendered, "missing credential for provider deribit");
assert!(!rendered.to_lowercase().contains("password"));
assert!(!rendered.to_lowercase().contains("secret"));
assert!(!rendered.to_lowercase().contains("key"));
}
#[test]
fn test_config_error_invalid_value_display_names_field_and_reason() {
let err = ConfigError::InvalidValue {
field: "provider id".to_owned(),
reason: "must match ^[a-z][a-z0-9_-]{1,31}$".to_owned(),
};
assert_eq!(
err.to_string(),
"invalid value for provider id: must match ^[a-z][a-z0-9_-]{1,31}$"
);
}
#[test]
fn test_registry_error_reserved_id_display_names_id() {
let err = RegistryError::ReservedId(provider_id("deribit"));
assert_eq!(
err.to_string(),
"provider id `deribit` is reserved for a built-in adapter"
);
}
#[test]
fn test_registry_error_duplicate_id_display_names_id() {
let err = RegistryError::DuplicateId(provider_id("mybroker"));
assert_eq!(
err.to_string(),
"provider id `mybroker` is already registered"
);
}
#[test]
fn test_registry_error_gated_display_names_id() {
let err = RegistryError::Gated(provider_id("tastytrade"));
assert_eq!(
err.to_string(),
"provider id `tastytrade` is a gated built-in and cannot be enabled while its security gate holds"
);
}
#[test]
fn test_registry_error_empty_display_is_category_message() {
assert_eq!(RegistryError::Empty.to_string(), "no providers registered");
}
#[test]
fn test_bundle_error_display_is_category_prefixed() {
let err = BundleError::MissingTable("fills.parquet".to_owned());
assert_eq!(err.to_string(), "missing table: fills.parquet");
}
#[test]
fn test_bundle_error_io_display_is_category_prefixed() {
let err = BundleError::Io("open positions.parquet: permission denied".to_owned());
assert_eq!(
err.to_string(),
"bundle io: open positions.parquet: permission denied"
);
}
#[test]
fn test_bundle_error_cancelled_display_is_category_message() {
assert_eq!(BundleError::Cancelled.to_string(), "bundle load cancelled");
}
#[test]
fn test_bundle_error_schema_display_is_category_prefixed() {
let err = BundleError::Schema("fills: required column `quantity` is absent".to_owned());
assert_eq!(
err.to_string(),
"bundle schema mismatch: fills: required column `quantity` is absent"
);
}
#[test]
fn test_chain_view_error_from_bundle_error_converts() {
let err: ChainViewError = BundleError::UnsupportedSchema("bogus.v9".to_owned()).into();
assert_eq!(
err.to_string(),
"result bundle: unsupported schema: bogus.v9"
);
assert!(matches!(err, ChainViewError::Bundle(_)));
}
#[test]
fn test_chain_view_error_from_config_error_converts() {
let err: ChainViewError = ConfigError::UnknownProvider("nope".to_owned()).into();
assert_eq!(err.to_string(), "config: unknown provider: nope");
assert!(matches!(err, ChainViewError::Config(_)));
}
#[test]
fn test_chain_view_error_from_registry_error_converts() {
let err: ChainViewError = RegistryError::Empty.into();
assert_eq!(
err.to_string(),
"provider registry: no providers registered"
);
assert!(matches!(err, ChainViewError::Registry(_)));
}
#[test]
fn test_chain_view_error_provider_helper_wraps_source() {
let err = ChainViewError::provider(provider_id("deribit"), ProviderError::Auth);
assert_eq!(err.to_string(), "provider deribit: authentication failed");
assert!(matches!(
err,
ChainViewError::Provider {
source: ProviderError::Auth,
..
}
));
}
#[test]
fn test_overlay_error_spec_mismatch_field_is_static_str() {
let err = OverlayError::SpecMismatch {
contract: "BTC-27JUN25-60000-C".to_owned(),
field: "contract_multiplier",
source: "100".to_owned(),
overlay: "1".to_owned(),
};
assert_eq!(
err.to_string(),
"overlay spec mismatch on contract_multiplier for BTC-27JUN25-60000-C: \
source `100` vs overlay `1`"
);
let field: &'static str = match err {
OverlayError::SpecMismatch { field, .. } => field,
OverlayError::MissingAlias { .. } => panic!("constructed a SpecMismatch"),
};
assert_eq!(field, "contract_multiplier");
}
#[test]
fn test_overlay_error_missing_alias_display_names_contract_and_feed() {
let err = OverlayError::MissingAlias {
contract: "BTC-27JUN25-60000-C".to_owned(),
provider: provider_id("dxlink"),
};
assert_eq!(
err.to_string(),
"overlay gate unchecked for BTC-27JUN25-60000-C: \
feed `dxlink` has no alias for the leg"
);
}
}