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}