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}