Skip to main content

linesmith_core/data_context/
mod.rs

1//! Shared data-access context threaded through every segment's
2//! [`render`](crate::segments::Segment::render) call.
3//!
4//! [`DataContext`] owns the eagerly-parsed stdin payload
5//! ([`StatusContext`](crate::input::StatusContext) at `ctx.status`) plus
6//! lazy [`OnceCell`](std::cell::OnceCell) accessors for every other
7//! source (settings, `~/.claude.json`, JSONL transcripts, OAuth usage,
8//! credentials, live sessions, git).
9//!
10//! This module ships the v0.1 skeleton: the struct shape, the accessor
11//! surface, and stub [`NotImplemented`](error::SettingsError::NotImplemented)
12//! errors. Real source implementations arrive with their owning epics
13//! (lsm-y6m for usage, lsm-8jl for git, etc.). Plugin scripts see a
14//! uniform `{ kind: "error", error: "NotImplemented" }` shape until
15//! those land.
16//!
17//! Canonical definition: `docs/specs/data-fetching.md` §DataContext.
18
19pub mod cache;
20pub mod cascade;
21pub mod credentials;
22pub mod deps;
23pub mod error;
24pub mod fetcher;
25pub mod git;
26pub mod jsonl;
27pub mod usage;
28pub mod xdg;
29
30use std::cell::OnceCell;
31use std::path::PathBuf;
32use std::sync::Arc;
33
34use crate::input::StatusContext;
35
36pub use credentials::{CredentialSource, Credentials};
37pub use deps::DataDep;
38pub use error::{
39    ClaudeJsonError, CredentialError, GitError, JsonlError, SessionError, SettingsError, UsageError,
40};
41pub use git::{DirtyCounts, DirtyState, GitContext, Head, RepoKind, UpstreamState};
42pub use jsonl::{FiveHourBlock, JsonlAggregate, TokenCounts};
43pub use usage::{
44    EndpointUsage, ExtraUsage, FiveHourWindow, JsonlUsage, SevenDayWindow, UsageApiResponse,
45    UsageBucket, UsageData,
46};
47
48// --- Stub source types ---------------------------------------------------
49//
50// Each gets real fields when its epic lands. Defined here as opaque
51// `#[non_exhaustive]` marker structs so `Arc<Result<T, E>>` types
52// compile today. Braced-empty (`{}`) form is deliberate: unit structs
53// would force a breaking `Foo` → `Foo { ... }` migration at every
54// construction site when fields land. `Default` is intentionally NOT
55// derived — we want real construction sites to surface in review
56// when each epic populates its fields; a `UsageData::default()` that
57// silently returns a zero-token record would render a misleading
58// statusline.
59
60/// Parsed `~/.claude/settings.json` + overlays. Stub.
61#[derive(Debug, Clone)]
62#[non_exhaustive]
63pub struct Settings {}
64
65/// Parsed `~/.claude.json` per-user state. Stub.
66#[derive(Debug, Clone)]
67#[non_exhaustive]
68pub struct ClaudeJson {}
69
70/// Snapshot of `~/.claude/sessions/{pid}.json` entries. Stub.
71#[derive(Debug, Clone)]
72#[non_exhaustive]
73pub struct LiveSessions {}
74
75// --- DataContext ---------------------------------------------------------
76
77/// Bundle of every source a segment may read during a single render
78/// invocation. `status` is populated eagerly from the stdin payload;
79/// all other sources lazy-init on first access and cache their
80/// `Result` (including errors) for the lifetime of this context.
81///
82/// Accessors return `Arc<Result<T, E>>` so segments can hold the data
83/// across calls without tying lifetimes to `&self`. The `Result` shape
84/// preserves failure info for the plugin runtime's tagged-map mirror
85/// (`#{ kind: "ok"|"error", ... }`) per `plugin-api.md` §ctx shape.
86pub struct DataContext {
87    /// Eagerly-parsed stdin payload.
88    pub status: StatusContext,
89
90    /// cwd used for git repo discovery. `None` means the process had
91    /// no accessible cwd when the context was constructed and the git
92    /// accessor will return `Ok(None)`.
93    cwd: Option<PathBuf>,
94
95    settings: OnceCell<Arc<Result<Settings, SettingsError>>>,
96    claude_json: OnceCell<Arc<Result<ClaudeJson, ClaudeJsonError>>>,
97    jsonl: OnceCell<Arc<Result<JsonlAggregate, JsonlError>>>,
98    usage: OnceCell<Arc<Result<UsageData, UsageError>>>,
99    credentials: OnceCell<Arc<Result<Credentials, CredentialError>>>,
100    sessions: OnceCell<Arc<Result<LiveSessions, SessionError>>>,
101    git: OnceCell<Arc<Result<Option<GitContext>, GitError>>>,
102}
103
104impl DataContext {
105    /// Wrap a parsed [`StatusContext`] with lazy accessors for every
106    /// other data source. cwd is `None`, so [`Self::git`] returns
107    /// `Ok(None)` unless the caller switches to [`Self::with_cwd`].
108    #[must_use]
109    pub fn new(status: StatusContext) -> Self {
110        Self::with_cwd(status, None)
111    }
112
113    /// Construct with an explicit cwd that seeds gix discovery. The
114    /// CLI passes `std::env::current_dir().ok()`; a fixture path
115    /// pins discovery to a known repo.
116    #[must_use]
117    pub fn with_cwd(status: StatusContext, cwd: Option<PathBuf>) -> Self {
118        Self {
119            status,
120            cwd,
121            settings: OnceCell::new(),
122            claude_json: OnceCell::new(),
123            jsonl: OnceCell::new(),
124            usage: OnceCell::new(),
125            credentials: OnceCell::new(),
126            sessions: OnceCell::new(),
127            git: OnceCell::new(),
128        }
129    }
130
131    /// `~/.claude/settings.json` + overlays.
132    #[must_use]
133    pub fn settings(&self) -> Arc<Result<Settings, SettingsError>> {
134        self.settings
135            .get_or_init(|| Arc::new(Err(SettingsError::NotImplemented)))
136            .clone()
137    }
138
139    /// `~/.claude.json` per-user state.
140    #[must_use]
141    pub fn claude_json(&self) -> Arc<Result<ClaudeJson, ClaudeJsonError>> {
142        self.claude_json
143            .get_or_init(|| Arc::new(Err(ClaudeJsonError::NotImplemented)))
144            .clone()
145    }
146
147    /// Aggregated JSONL transcript state.
148    ///
149    /// Invokes [`jsonl::aggregate_jsonl`] on first call and memoizes
150    /// the `Arc<Result<...>>` for the process lifetime. Scans the
151    /// project-root cascade once per process.
152    #[must_use]
153    pub fn jsonl(&self) -> Arc<Result<JsonlAggregate, JsonlError>> {
154        self.jsonl
155            .get_or_init(|| Arc::new(jsonl::aggregate_jsonl()))
156            .clone()
157    }
158
159    /// OAuth usage endpoint data (shared across rate-limit segments).
160    ///
161    /// Runs the full fallback cascade on first call and memoizes the
162    /// result for the process lifetime. See
163    /// [`cascade::resolve_usage`] for the step order and
164    /// `docs/specs/data-fetching.md` §OAuth fallback cascade for
165    /// rationale. A fresh disk-cache hit short-circuits before any
166    /// Keychain subprocess or HTTP call.
167    #[must_use]
168    pub fn usage(&self) -> Arc<Result<UsageData, UsageError>> {
169        self.usage
170            .get_or_init(|| Arc::new(self.resolve_usage_default()))
171            .clone()
172    }
173
174    fn resolve_usage_default(&self) -> Result<UsageData, UsageError> {
175        let root = cache::default_root();
176        let cache_store = root.clone().map(cache::CacheStore::new);
177        let lock_store = root.map(cache::LockStore::new);
178        let transport = fetcher::UreqTransport::new();
179        let config = cascade::UsageCascadeConfig::default();
180        cascade::resolve_usage(
181            cache_store.as_ref(),
182            lock_store.as_ref(),
183            &transport,
184            &|| self.credentials(),
185            // Delegate to the memoized aggregator. The closure fires
186            // only on endpoint-failure paths, so a fresh disk cache
187            // short-circuits before we touch transcripts. The cascade
188            // discards the specific `JsonlError` variant — every
189            // `Err` collapses to `None` in `build_jsonl_usage` — and
190            // `JsonlError` isn't `Clone` (io::Error / serde_json::Error
191            // inners), so error variants collapse to `NoEntries` here.
192            // Systemic `IoError` (EACCES / ENOSPC / corrupt fs across
193            // the transcripts dir) would otherwise vanish entirely —
194            // the user would see the endpoint-path error `[Timeout]` /
195            // `[Network error]` with no hint that JSONL was unreachable.
196            // Warn here so the real cause leaves a trace without
197            // requiring debug logs.
198            &|| match &*self.jsonl() {
199                Ok(agg) => Ok(agg.clone()),
200                Err(JsonlError::IoError { path, cause }) => {
201                    crate::lsm_warn!(
202                        "cascade: JSONL aggregator IoError at {}: {} ({cause}); surfacing the original endpoint error since JSONL is unavailable",
203                        path.display(),
204                        cause.kind(),
205                    );
206                    Err(JsonlError::NoEntries)
207                }
208                Err(JsonlError::ParseError { path, line, cause }) => {
209                    crate::lsm_warn!(
210                        "cascade: JSONL aggregator ParseError at {}:{line}: {cause}; surfacing the original endpoint error since JSONL is unavailable",
211                        path.display(),
212                    );
213                    Err(JsonlError::NoEntries)
214                }
215                Err(_) => Err(JsonlError::NoEntries),
216            },
217            &jiff::Timestamp::now,
218            &config,
219        )
220    }
221
222    /// macOS Keychain / `.credentials.json` OAuth credentials.
223    ///
224    /// Invokes [`credentials::resolve_credentials`] on first call and
225    /// memoizes the `Arc<Result<...>>` for the process lifetime per
226    /// `docs/specs/credentials.md` §Non-functional. On macOS this may
227    /// trigger a `security` subprocess and a one-time Keychain access
228    /// prompt; on Linux/Windows it's a file-cascade read.
229    #[must_use]
230    pub fn credentials(&self) -> Arc<Result<Credentials, CredentialError>> {
231        self.credentials
232            .get_or_init(|| Arc::new(credentials::resolve_credentials()))
233            .clone()
234    }
235
236    /// Pre-populate the `usage` result so [`Self::usage`] returns it
237    /// without running the fallback cascade (which would otherwise
238    /// touch the real Keychain, network, and JSONL transcripts).
239    /// Mirrors [`OnceCell::set`]'s semantics: returns `Err` with the
240    /// already-stored value if the cell was already populated by a
241    /// prior `ctx.usage()` read.
242    pub fn preseed_usage(
243        &self,
244        result: Result<UsageData, UsageError>,
245    ) -> Result<(), Arc<Result<UsageData, UsageError>>> {
246        self.usage.set(Arc::new(result))
247    }
248
249    /// `~/.claude/sessions/{pid}.json` live process snapshot.
250    #[must_use]
251    pub fn sessions(&self) -> Arc<Result<LiveSessions, SessionError>> {
252        self.sessions
253            .get_or_init(|| Arc::new(Err(SessionError::NotImplemented)))
254            .clone()
255    }
256
257    /// Git repo inspection via `gix`. `Ok(None)` means cwd is not
258    /// inside a git repo; `Ok(Some(_))` covers main checkouts, linked
259    /// worktrees, and bare repos; `Err` is a gix failure.
260    ///
261    /// Runs [`git::resolve_repo`] against [`Self::cwd`] on first call
262    /// and memoizes the result. An unset cwd resolves to `Ok(None)`.
263    /// On the first `Err`, writes the cause to stderr with the
264    /// `linesmith:` prefix so every consumer inherits the log without
265    /// having to re-emit — the `docs/specs/git-segments.md` §Data
266    /// dependency contract that each segment's render path relies on.
267    #[must_use]
268    pub fn git(&self) -> Arc<Result<Option<GitContext>, GitError>> {
269        // `gix::Repository` is not `Sync`, which trips the
270        // `arc_with_non_send_sync` lint. The render path is
271        // single-threaded (`OnceCell`, not `OnceLock`) and the `Arc`
272        // only ref-counts on one thread — a `Mutex` wrapper would
273        // buy nothing.
274        #[allow(clippy::arc_with_non_send_sync)]
275        self.git
276            .get_or_init(|| {
277                let result = match &self.cwd {
278                    Some(cwd) => git::resolve_repo(cwd),
279                    None => Ok(None),
280                };
281                if let Err(err) = &result {
282                    crate::lsm_warn!("git discovery failed: {err}");
283                }
284                Arc::new(result)
285            })
286            .clone()
287    }
288
289    /// Pre-populate the `git` result so [`Self::git`] returns it
290    /// without running `gix::discover` (which would otherwise touch
291    /// the real filesystem). Mirrors [`OnceCell::set`]'s semantics:
292    /// returns `Err` with the already-stored value if the cell was
293    /// already populated by a prior `ctx.git()` read.
294    // Same Arc-not-Sync rationale as `git()` above — render path and
295    // test harness are single-threaded.
296    #[allow(clippy::arc_with_non_send_sync)]
297    pub fn preseed_git(
298        &self,
299        result: Result<Option<GitContext>, GitError>,
300    ) -> Result<(), Arc<Result<Option<GitContext>, GitError>>> {
301        self.git.set(Arc::new(result))
302    }
303}