chainview 0.1.2

Terminal UI for option chains, Greeks and volatility - real-time market data and backtest replay in your terminal.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
//! Boundary error types for ChainView.
//!
//! [`ChainViewError`] is the shared boundary every provider, bundle, config,
//! registry, and terminal error converts into — no upstream error type ever
//! reaches a widget (`docs/01-domain-model.md` §11).
//!
//! The binding property is **redaction-safe by construction**: a secret cannot
//! be interpolated into any `Display` here, not by author discipline but by the
//! shape of the types. [`ProviderError`] carries **no free-form `String` from
//! adapter internals** — transport detail is a [`Redacted`] trait object (a
//! category plus a masked summary, e.g. `"transport: http 503"`, never a URL or
//! a body), and a normalize failure is the closed [`NormalizeKind`] enum naming
//! a **field**, never a value (`docs/03-data-providers.md` §6). The credential
//! guarantee these shapes enforce is stated in `docs/SECURITY.md` §1.

use std::fmt;
use std::time::Duration;

use crate::chain::ProviderId;

/// The single boundary error every ChainView layer converts into.
///
/// Each sub-boundary maps in either through `#[from]` (where the mapping is
/// unambiguous — `Bundle`/`Config`/`Registry`) or through the explicit
/// [`ChainViewError::provider`] helper (the `Provider` variant additionally
/// carries the `ProviderId`, so the conversion is deliberately not a blanket
/// `From`). No variant carries a raw upstream string or a credential.
///
/// `#[non_exhaustive]` (issue #116): every top-level public error enum carries
/// the same v1.0 freeze discipline as [`BundleError`] — a future variant lands
/// as a **source-compatible minor**, never a major, so a downstream match must
/// carry a wildcard arm. In-crate matches still exhaustiveness-check.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ChainViewError {
    /// A provider adapter failed. Carries the provider identity alongside the
    /// typed, redaction-safe [`ProviderError`]. Built via
    /// [`ChainViewError::provider`], never a blanket `From`, so the call site
    /// names the responsible provider.
    #[error("provider {provider}: {source}")]
    Provider {
        /// Which provider raised the error — its public, non-secret id.
        provider: ProviderId,
        /// The typed, redaction-safe provider failure.
        #[source]
        source: ProviderError,
    },
    /// A result-bundle read or validation failed (replay mode).
    #[error("result bundle: {0}")]
    Bundle(#[from] BundleError),
    /// Configuration was missing or invalid. Names the provider/field, never a
    /// credential value.
    #[error("config: {0}")]
    Config(#[from] ConfigError),
    /// The provider registry rejected an assembly (reserved/duplicate id or an
    /// empty set).
    #[error("provider registry: {0}")]
    Registry(#[from] RegistryError),
    /// A terminal-backend operation failed (raw mode, alternate screen, draw).
    /// The detail is a non-secret, ChainView-authored string.
    #[error("terminal: {0}")]
    Terminal(String),
}

impl ChainViewError {
    /// Wrap a [`ProviderError`] with the identity of the provider that raised
    /// it, producing a [`ChainViewError::Provider`].
    ///
    /// This is the deliberate replacement for a blanket `From<ProviderError>`:
    /// the `Provider` variant carries the `ProviderId`, so the conversion is
    /// explicit at every call site rather than an ambiguous auto-conversion.
    #[cold]
    #[inline(never)]
    #[must_use]
    pub fn provider(provider: ProviderId, source: ProviderError) -> Self {
        Self::Provider { provider, source }
    }
}

/// Raised while assembling the provider registry at startup
/// (`docs/02-tui-architecture.md` §11, ADR-0006). A collision is a typed error,
/// never a panic or a silent last-writer-wins. Every variant names only a
/// public provider id — never a credential.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum RegistryError {
    /// An external registration reused one of `RESERVED_PROVIDER_IDS`.
    #[error("provider id `{0}` is reserved for a built-in adapter")]
    ReservedId(ProviderId),
    /// Two registrations share the same id.
    #[error("provider id `{0}` is already registered")]
    DuplicateId(ProviderId),
    /// A security-**gated** built-in adapter was requested via
    /// `with_gated_builtin` while its upstream credential-logging gate still
    /// holds (`docs/SECURITY.md` §2.3–§2.4). A gated adapter can never be enabled
    /// silently — this is a typed startup failure, never a panic — and names only
    /// the public provider id, never a credential. This is the concrete typed
    /// error `docs/02-tui-architecture.md` §11 refers to (superseding the earlier
    /// unattached `ProviderGated` sketch): the whole registry family surfaces as
    /// [`ChainViewError::Registry`].
    #[error(
        "provider id `{0}` is a gated built-in and cannot be enabled while its security gate holds"
    )]
    Gated(ProviderId),
    /// No providers were registered before startup.
    #[error("no providers registered")]
    Empty,
}

/// A failure reading or validating an IronCondor result bundle (replay mode).
///
/// Messages are **non-secret** — a bundle is trusted-but-verified local data,
/// never a credential source (`docs/04-replay-mode.md` §5). Most are
/// ChainView-authored; the one exception is
/// [`UnsupportedSchema`](Self::UnsupportedSchema), which echoes the
/// `manifest.schema` tag. That tag is **attacker-supplied but non-secret**,
/// **clamped to a bounded length at construction** (so a length-unbounded junk
/// tag cannot bloat the message), and further sanitized at the render edge before
/// it reaches the terminal.
///
/// `#[non_exhaustive]`: with the bundle contract frozen at
/// `ironcondor.bundle.v1` (issue #56, `docs/04-replay-mode.md` §2), a future
/// reject reason (a new schema tag, a new integrity check) must be able to land
/// as a **source-compatible addition** post-1.0. In-crate match sites still
/// exhaustiveness-check; any downstream match must carry a wildcard arm.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum BundleError {
    /// A required Parquet table (or manifest) was absent from the bundle
    /// directory.
    #[error("missing table: {0}")]
    MissingTable(String),
    /// A filesystem operation on the bundle failed for a reason other than a
    /// cleanly-absent table — a stat, directory read, or file open that errored
    /// (e.g. a permission failure, or a path that is not a directory). The
    /// detail is a **non-secret**, ChainView-authored summary naming the
    /// operation and the bundle-relative path only — never an environment value
    /// (`docs/04-replay-mode.md` §5).
    #[error("bundle io: {0}")]
    Io(String),
    /// The `manifest.schema` tag is not a supported bundle version. The echoed
    /// tag is attacker-supplied-but-non-secret and **clamped to a bounded length
    /// at construction** (a valid tag is ~20 chars); it is further sanitized at
    /// the render edge.
    #[error("unsupported schema: {0}")]
    UnsupportedSchema(String),
    /// A cross-table or domain invariant was violated on load.
    #[error("invariant violated: {0}")]
    Invariant(String),
    /// A Parquet column did not match the bundle's declared table schema during
    /// the typed per-column decode (#31, `docs/04-replay-mode.md` §2.2/§5): a
    /// **required column is absent**, or a column is present at the **wrong
    /// Arrow/Parquet type**. The detail is ChainView-authored and non-secret — it
    /// names the table, the column, and (for a type mismatch) the expected vs
    /// actual Arrow type; it carries **no cell value or bundle payload**, and any
    /// echoed dynamic string is length-clamped at construction. An unknown
    /// **extra** column is never an error — the reader is permissive toward a
    /// newer minor of the same schema tag (`docs/04-replay-mode.md` §3).
    #[error("bundle schema mismatch: {0}")]
    Schema(String),
    /// The operator-supplied [`ResourceCeilings`](crate::ResourceCeilings)
    /// failed validation on the **enforcement path**
    /// (`BundleReader::open_with_ceilings`). A misconfigured ceiling is surfaced
    /// as a typed bundle error — never a silent open with a disabled guard —
    /// carrying the underlying [`ConfigError`] via `#[from]` so the offending knob
    /// is named. The message is ChainView-authored and non-secret.
    #[error("resource ceiling: {0}")]
    Config(#[from] ConfigError),
    /// A resource ceiling was exceeded before materialisation
    /// (`docs/04-replay-mode.md` §3).
    #[error("bundle too large: {0}")]
    TooLarge(String),
    /// A Parquet decode error, summarized without leaking file internals.
    #[error("parquet: {0}")]
    Parquet(String),
    /// The load was aborted at a batch boundary via the caller's cancellation
    /// probe (the app shutdown token, `docs/02-tui-architecture.md` §12) before
    /// the bundle finished decoding. It is **not** a data-integrity failure — the
    /// bundle may be perfectly valid — so callers distinguish a user-driven abort
    /// from the malformed-bundle variants and never surface it as a bad-bundle
    /// error.
    #[error("bundle load cancelled")]
    Cancelled,
}

/// A configuration failure surfaced at startup.
///
/// [`ConfigError::MissingCredential`] names the **provider**, never the key or
/// the secret itself — the credential guarantee (`docs/SECURITY.md` §1) is why
/// no variant here can carry secret material.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ConfigError {
    /// A provider that requires authentication has no credential configured.
    /// Names the provider only — never the missing key or its value.
    #[error("missing credential for provider {0}")]
    MissingCredential(ProviderId),
    /// A configured provider id is not a known/registered provider.
    #[error("unknown provider: {0}")]
    UnknownProvider(String),
    /// A configuration value failed validation. `field` names the setting and
    /// `reason` explains why — neither carries a credential.
    #[error("invalid value for {field}: {reason}")]
    InvalidValue {
        /// The configuration field that failed validation.
        field: String,
        /// Why the value was rejected — a non-secret explanation.
        reason: String,
    },
}

/// Raised when a **cross-provider** overlay merge is refused because feed
/// identity does not prove contract equivalence (`docs/01-domain-model.md` §4
/// economic-equivalence gate).
///
/// It is a **per-leg, non-fatal** outcome: the offending overlay leg is dropped
/// (not merged), the source leg is kept, and the leg is badged overlay-refused
/// — it never aborts the app or blanks the screen, so it is deliberately **not**
/// a [`ChainViewError`] variant. It names the disagreeing spec dimension, never
/// a raw credential or payload.
///
/// `Display`/`Error` are hand-implemented rather than derived via `thiserror`:
/// the `source` field name (fixed by `docs/01-domain-model.md` §11) is reserved
/// by `thiserror` for the error-source chain, which would require `String:
/// Error`. Hand-implementing preserves the documented public field names exactly
/// while still yielding a typed [`std::error::Error`].
#[derive(Debug)]
#[non_exhaustive]
pub enum OverlayError {
    /// A fingerprint dimension (multiplier / settlement / exercise / quote
    /// currency / venue product code) disagreed between the source and overlay
    /// feeds for one contract.
    SpecMismatch {
        /// The normalized contract label (non-secret).
        contract: String,
        /// The fingerprint dimension that disagreed. `&'static str` — a
        /// compile-time dimension name, so runtime data can never occupy this
        /// slot.
        field: &'static str,
        /// The source feed's value for that dimension.
        source: String,
        /// The overlay feed's value for that dimension.
        overlay: String,
    },
    /// The equivalence gate could **not** be checked: a feed's alias for the
    /// leg is absent, so there is no fingerprint pair to compare. The gate fails
    /// **CLOSED** — an unverified overlay is refused, never admitted — so the
    /// store never merges an unchecked leg. Names the contract and the feed
    /// whose alias was missing (a non-secret provider id), never a payload.
    MissingAlias {
        /// The normalized contract label (non-secret).
        contract: String,
        /// The feed whose alias for the leg was absent — its public,
        /// non-secret id.
        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 {}

/// A redaction-safe detail attached to a transport failure.
///
/// Its `Display`/`Debug` output is what reaches a ChainView log or the UI, so
/// it **must** be safe: the contract is "emit a category and a masked summary,
/// never raw upstream text". ChainView provides the safe [`TransportDetail`]
/// implementation; an external adapter may implement `Redacted` for its own
/// detail and is contractually barred from interpolating a secret
/// (`docs/SECURITY.md` §5). The trait has no methods — it is a marker plus the
/// `Display + Debug + Send + Sync` bound that makes the detail loggable and
/// thread-safe.
pub trait Redacted: fmt::Display + fmt::Debug + Send + Sync {}

/// The category of a transport failure. A small, stable, closed set — safe to
/// render because it is a fixed vocabulary, never venue-controlled text.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum TransportKind {
    /// The request or connection timed out.
    Timeout,
    /// The connection was closed by the peer or dropped.
    Closed,
    /// A TLS/handshake failure.
    Tls,
    /// An HTTP-level failure (see the optional status).
    Http,
    /// A response could not be decoded at the transport layer.
    Decode,
}

/// Opaque, redaction-safe transport detail.
///
/// Built from a small closed set of causes plus an optional HTTP status —
/// **never** from `format!(upstream_err)`. Its `Display` emits only a category
/// and a status (e.g. `"transport: http 503"`); it has no field that could hold
/// a URL, a request body, or a token, so it cannot leak one by construction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TransportDetail {
    /// The failure category.
    pub kind: TransportKind,
    /// The HTTP status code when the failure was HTTP-level — the status only,
    /// never the response body.
    pub http_status: Option<u16>,
}

impl TransportDetail {
    /// Construct a redaction-safe transport detail from a category and an
    /// optional HTTP status.
    #[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 {}

/// The typed, redaction-safe failure an adapter raises.
///
/// It carries **no free-form `String` from adapter internals**: transport
/// detail is a [`Redacted`] trait object and a normalize failure is the closed
/// [`NormalizeKind`] enum — so a token, an authenticated URL, or a raw payload
/// cannot reach displayed error text or a log (`docs/03-data-providers.md` §6).
/// Convert into [`ChainViewError`] via [`ChainViewError::provider`].
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ProviderError {
    /// The provider does not support the requested operation. The message is a
    /// compile-time `&'static str`, never runtime data.
    #[error("not supported by this provider: {0}")]
    Unsupported(&'static str),
    /// Authentication failed. Never carries the credential.
    #[error("authentication failed")]
    Auth,
    /// An upstream transport failure, described by a redaction-safe
    /// [`Redacted`] detail — never a raw upstream string.
    #[error("upstream transport: {0}")]
    Transport(Box<dyn Redacted>),
    /// A payload would not map to the chain model. Carries the closed
    /// [`NormalizeKind`] reason (naming a field, not a value).
    #[error("normalize: {kind}")]
    Normalize {
        /// Why normalization failed — a closed set, no free-form payload text.
        kind: NormalizeKind,
    },
    /// The provider rate-limited the request. Carries the suggested retry delay
    /// when the upstream supplies one.
    #[error("rate limited; retry after {0:?}")]
    RateLimited(Option<Duration>),
    /// No chain exists for the requested underlying and expiration. Both are
    /// already-normalized, non-secret values.
    #[error("no chain for {underlying} @ {expiration}")]
    NoChain {
        /// The normalized underlying ticker.
        underlying: String,
        /// The normalized expiration label.
        expiration: String,
    },
}

/// Why a payload would not map to the chain model — a closed set, so a rejected
/// payload's raw bytes never ride along in the error.
///
/// `#[non_exhaustive]`: a new reason is a source-compatible addition; in-crate
/// match sites still exhaustiveness-check. Every data-bearing variant names a
/// **field** via a compile-time `&'static str`, never the offending value.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum NormalizeKind {
    /// A required field was absent. Names the field, not the value.
    MissingField(&'static str),
    /// A field was present but outside its valid range. Names the field.
    OutOfRange(&'static str),
    /// A numeric field was NaN or infinite. Names the field.
    NonFinite(&'static str),
    /// An expiry could not be parsed to a single absolute UTC instant.
    UnparseableExpiry,
    /// An option style could not be resolved to call or put.
    UnknownStyle,
    /// A discovery/response ceiling was reached with more data still pending, so
    /// a COMPLETE chain cannot be proven — a bounded-memory guard hit its limit
    /// while the venue still had a next-page token or an omitted contract. Names
    /// the cap via a compile-time `&'static str` (e.g. a page or contract cap),
    /// never a value or the venue payload. Reaching a cap with data outstanding is
    /// an honest failure, never a silently truncated chain returned as complete.
    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() {
        // Regardless of category/status, Display carries only the category and
        // the status — there is no field that could hold a URL, a body, or a
        // token, so a leak is unrepresentable.
        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() {
        // `Transport` is constructible only from a `Redacted` detail, never a
        // raw upstream string. This compiles precisely because the argument is
        // typed `Box<dyn Redacted>`.
        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() {
        // The cap name rides along (a compile-time `&'static str`); no venue value
        // or payload can occupy the slot.
        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`"
        );
        // The `field` slot is `&'static str`: destructuring it back out at that
        // exact type (no coercion) proves statically that a runtime value can
        // never occupy it.
        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"
        );
    }
}