Skip to main content

linesmith_core/data_context/
error.rs

1//! Error types for lazy [`DataContext`](super::DataContext) sources.
2//!
3//! Each source's real error variants land with its owning epic (e.g.
4//! [`UsageError`] is real under the lsm-y6m epic; `GitError` lands
5//! with lsm-8jl). Sources still in stub form return the
6//! `NotImplemented` variant so the plugin runtime can expose a
7//! uniform error surface to scripts.
8//!
9//! **`NotImplemented` is temporary.** When an epic lands real variants
10//! for a given error enum, the `NotImplemented` variant is removed in
11//! the same commit. Because each enum is `#[non_exhaustive]`, adding
12//! new variants is non-breaking; *removing* `NotImplemented` is a
13//! breaking change for any external code that matches on it. v0.1
14//! treats that window as acceptable — no external consumers exist
15//! yet, and the stub's whole purpose is to signal "not wired up."
16
17macro_rules! stub_error {
18    ($name:ident, $doc:literal) => {
19        #[doc = $doc]
20        #[derive(Debug, Clone, PartialEq, Eq)]
21        #[non_exhaustive]
22        pub enum $name {
23            /// Source not yet implemented. Real variants land with the
24            /// epic that owns this source.
25            NotImplemented,
26        }
27
28        impl $name {
29            /// Short plugin-facing error tag per
30            /// `docs/specs/plugin-api.md` §ctx shape exposed to rhai.
31            /// Rendered in the rhai tagged-map mirror as `error:
32            /// "<code>"`.
33            #[must_use]
34            pub fn code(&self) -> &'static str {
35                match self {
36                    Self::NotImplemented => "NotImplemented",
37                }
38            }
39        }
40
41        impl std::fmt::Display for $name {
42            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43                f.write_str(self.code())
44            }
45        }
46
47        impl std::error::Error for $name {}
48    };
49}
50
51use std::path::PathBuf;
52
53stub_error!(
54    SettingsError,
55    "Errors from reading `~/.claude/settings.json` + overlays."
56);
57stub_error!(ClaudeJsonError, "Errors from reading `~/.claude.json`.");
58stub_error!(SessionError, "Errors from the live sessions directory.");
59
60/// Errors from `gix` repo inspection. `CorruptRepo` covers
61/// `gix::open` failures; `WalkFailed` covers HEAD / revwalk
62/// failures once the repo is open. Inner causes are stringified at
63/// the construction boundary so the enum stays
64/// `Clone + PartialEq + Eq` — the `Arc<Result<...>>` memoization
65/// boundary requires it. See `docs/specs/git-segments.md` §Change
66/// log (v0.1.1) for the full rationale.
67#[derive(Debug, Clone, PartialEq, Eq)]
68#[non_exhaustive]
69pub enum GitError {
70    /// `gix::discover` or `gix::open` rejected the path.
71    CorruptRepo { path: PathBuf, message: String },
72    /// HEAD resolution or any post-open walk failed.
73    WalkFailed { path: PathBuf, message: String },
74}
75
76impl GitError {
77    /// Short plugin-facing tag per `docs/specs/plugin-api.md` §ctx
78    /// shape exposed to rhai.
79    #[must_use]
80    pub fn code(&self) -> &'static str {
81        match self {
82            Self::CorruptRepo { .. } => "CorruptRepo",
83            Self::WalkFailed { .. } => "WalkFailed",
84        }
85    }
86}
87
88impl std::fmt::Display for GitError {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        match self {
91            Self::CorruptRepo { path, message } => {
92                write!(f, "gix failed to open {}: {message}", path.display())
93            }
94            Self::WalkFailed { path, message } => {
95                write!(f, "gix walk at {} failed: {message}", path.display())
96            }
97        }
98    }
99}
100
101impl std::error::Error for GitError {}
102
103// `CredentialError` and `JsonlError` are real types from their own
104// modules — re-exported at the data_context module root so
105// `pub use error::{CredentialError, JsonlError}` still resolves.
106// When other error types graduate, follow the same pattern.
107pub use super::credentials::CredentialError;
108pub use super::jsonl::JsonlError;
109
110// --- UsageError (real, not stub) ---------------------------------------
111//
112// Variants mirror `docs/specs/rate-limit-segments.md` §Error message
113// table; segment renderers map each variant (and the inner variants of
114// `Credentials` + `Jsonl`) to the user-facing bracketed strings listed
115// in that table. Adding variants is non-breaking (`#[non_exhaustive]`).
116
117use std::time::Duration;
118
119/// Errors from the OAuth `/api/oauth/usage` endpoint plus the cache
120/// stack, credential, and JSONL-fallback layers that feed it. Rendered
121/// to the user via the segment error table in
122/// `docs/specs/rate-limit-segments.md`.
123///
124/// `PartialEq` is not derived: inner types (`io::Error`,
125/// `serde_json::Error`) don't support it. `CredentialError` has a
126/// lossy `Clone` impl so the cascade can preserve variant-level
127/// detail across `Arc<Result<_, CredentialError>>` boundaries;
128/// `UsageError` itself isn't `Clone` because `DataContext` memoizes
129/// it behind `Arc<Result<_, UsageError>>` and cross-segment sharing
130/// clones the `Arc`.
131#[derive(Debug)]
132#[non_exhaustive]
133pub enum UsageError {
134    /// No OAuth token reachable from any cascade path. Rendered
135    /// `[No credentials]`.
136    NoCredentials,
137
138    /// Credentials-layer failure. Segment code matches on the inner
139    /// variant to render `[Keychain error]` / `[Credentials
140    /// unreadable]` / `[No credentials]` per the error table.
141    Credentials(CredentialError),
142
143    /// Endpoint took longer than the configured timeout. Rendered
144    /// `[Timeout]`.
145    Timeout,
146
147    /// Endpoint returned `429 Too Many Requests`. `retry_after` is the
148    /// parsed `Retry-After` header (integer seconds or HTTP-date per
149    /// ADR-0011 §Cache stack); `None` means the header was absent and
150    /// the default 300s backoff applies. Rendered `[Rate limited]`.
151    RateLimited { retry_after: Option<Duration> },
152
153    /// Connection failed, DNS failure, TLS failure, or any other
154    /// network-level error. Rendered `[Network error]`.
155    NetworkError,
156
157    /// Endpoint returned malformed JSON. Rendered `[Parse error]`.
158    ParseError,
159
160    /// Endpoint returned `401 Unauthorized`. Token is expired or
161    /// revoked; Claude Code refreshes on its next request. Rendered
162    /// `[Unauthorized]`.
163    Unauthorized,
164
165    /// JSONL-fallback-layer failure. Surfaces when the endpoint path
166    /// recorded an error AND the JSONL aggregator also yielded
167    /// nothing.
168    Jsonl(JsonlError),
169}
170
171impl UsageError {
172    /// Short plugin-facing error tag per `docs/specs/plugin-api.md`
173    /// §ctx shape exposed to rhai. Rendered in the rhai tagged-map
174    /// mirror as `error: "<code>"`. Wrapping variants (`Credentials`,
175    /// `Jsonl`) delegate to the inner error's `code()` so plugins see
176    /// a flat taxonomy. Today those inner types are stubs that return
177    /// `"NotImplemented"`; once the credential and JSONL layers land
178    /// the delegation surfaces the full spec set (`"NoCredentials"`,
179    /// `"SubprocessFailed"`, `"IoError"`, `"ParseError"`, etc.).
180    #[must_use]
181    pub fn code(&self) -> &'static str {
182        match self {
183            Self::NoCredentials => "NoCredentials",
184            Self::Credentials(inner) => inner.code(),
185            Self::Timeout => "Timeout",
186            Self::RateLimited { .. } => "RateLimited",
187            Self::NetworkError => "NetworkError",
188            Self::ParseError => "ParseError",
189            Self::Unauthorized => "Unauthorized",
190            Self::Jsonl(inner) => inner.code(),
191        }
192    }
193}
194
195impl std::fmt::Display for UsageError {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        match self {
198            Self::NoCredentials => f.write_str("no OAuth credentials found"),
199            Self::Credentials(inner) => write!(f, "credentials error: {inner}"),
200            Self::Timeout => f.write_str("endpoint request timed out"),
201            Self::RateLimited {
202                retry_after: Some(d),
203            } => write!(f, "endpoint rate-limited; retry after {}s", d.as_secs()),
204            Self::RateLimited { retry_after: None } => {
205                f.write_str("endpoint rate-limited (no Retry-After)")
206            }
207            Self::NetworkError => f.write_str("network error"),
208            Self::ParseError => f.write_str("endpoint response failed to parse"),
209            Self::Unauthorized => f.write_str("endpoint returned 401 Unauthorized"),
210            Self::Jsonl(inner) => write!(f, "JSONL fallback failed: {inner}"),
211        }
212    }
213}
214
215impl std::error::Error for UsageError {
216    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
217        match self {
218            Self::Credentials(inner) => Some(inner),
219            Self::Jsonl(inner) => Some(inner),
220            _ => None,
221        }
222    }
223}
224
225#[cfg(test)]
226mod usage_error_tests {
227    use super::*;
228
229    #[test]
230    fn display_covers_every_variant() {
231        let cases: &[(UsageError, &str)] = &[
232            (UsageError::NoCredentials, "no OAuth credentials found"),
233            (
234                UsageError::Credentials(CredentialError::NoCredentials),
235                "credentials error: no OAuth credentials found",
236            ),
237            (UsageError::Timeout, "endpoint request timed out"),
238            (
239                UsageError::RateLimited {
240                    retry_after: Some(Duration::from_secs(42)),
241                },
242                "endpoint rate-limited; retry after 42s",
243            ),
244            (
245                UsageError::RateLimited { retry_after: None },
246                "endpoint rate-limited (no Retry-After)",
247            ),
248            (UsageError::NetworkError, "network error"),
249            (UsageError::ParseError, "endpoint response failed to parse"),
250            (
251                UsageError::Unauthorized,
252                "endpoint returned 401 Unauthorized",
253            ),
254            (
255                UsageError::Jsonl(JsonlError::NoEntries),
256                "JSONL fallback failed: Claude Code project directory has no JSONL entries",
257            ),
258        ];
259        for (err, expected) in cases {
260            assert_eq!(err.to_string(), *expected);
261        }
262    }
263
264    #[test]
265    fn code_flattens_wrapping_variants_via_delegation() {
266        // Plugin-facing contract: ctx.usage.error is a flat tag string.
267        // Wrapping variants delegate to inner code() so a rhai switch
268        // on "NoCredentials" | "Timeout" | ... works uniformly.
269        assert_eq!(UsageError::NoCredentials.code(), "NoCredentials");
270        assert_eq!(UsageError::Timeout.code(), "Timeout");
271        assert_eq!(
272            UsageError::RateLimited { retry_after: None }.code(),
273            "RateLimited",
274        );
275        assert_eq!(UsageError::NetworkError.code(), "NetworkError");
276        assert_eq!(UsageError::ParseError.code(), "ParseError");
277        assert_eq!(UsageError::Unauthorized.code(), "Unauthorized");
278
279        // Credentials + Jsonl delegation surfaces real inner codes.
280        assert_eq!(
281            UsageError::Credentials(CredentialError::NoCredentials).code(),
282            "NoCredentials",
283        );
284        assert_eq!(UsageError::Jsonl(JsonlError::NoEntries).code(), "NoEntries",);
285    }
286
287    #[test]
288    fn source_chains_through_wrapping_variants() {
289        use std::error::Error;
290
291        // Wrapping variants always expose the inner as their source,
292        // regardless of whether that inner itself wraps an io::Error.
293        // The full chain terminates at the leaf variant.
294        let wrapped = UsageError::Credentials(CredentialError::IoError {
295            path: std::path::PathBuf::from("/x"),
296            cause: std::io::Error::other("boom"),
297        });
298        let source = wrapped.source().unwrap();
299        // wrapped → CredentialError::IoError → io::Error
300        assert!(source.source().is_some());
301
302        // `Credentials(NoCredentials)` wraps a leaf — chain has 1 step.
303        let credless = UsageError::Credentials(CredentialError::NoCredentials);
304        let source = credless.source().unwrap();
305        assert!(source.source().is_none());
306
307        // Jsonl(NoEntries) wraps a leaf — Source exists but has no
308        // inner Source of its own.
309        let wrapped_jsonl = UsageError::Jsonl(JsonlError::NoEntries);
310        assert!(wrapped_jsonl.source().is_some());
311
312        let bare = UsageError::NoCredentials;
313        assert!(bare.source().is_none());
314    }
315}