linesmith_core/input/mod.rs
1//! `StatusContext` is the canonical, tool-agnostic model parsed from a
2//! statusline JSON payload (Claude Code today; per-tool normalizers are
3//! added as other tools wire in). Rate-limit windows live on
4//! `DataContext::usage()` and are not parsed from stdin; see
5//! `docs/specs/input-schema.md` for the full contract.
6
7use std::borrow::Cow;
8use std::path::PathBuf;
9use std::sync::Arc;
10
11/// The canonical, tool-agnostic input to the rendering pipeline. `Arc`
12/// around `raw` keeps `StatusContext::clone` at O(1) when segments cache.
13///
14/// The stdin-payload `rate_limits` field is deliberately NOT parsed:
15/// `ctx.usage()` (OAuth endpoint + JSONL fallback) is strictly richer,
16/// per `docs/specs/rate-limit-segments.md`.
17#[derive(Debug, Clone)]
18#[non_exhaustive]
19pub struct StatusContext {
20 pub tool: Tool,
21 /// Per ADR-0014: `None` when the `model` wrapper is missing or
22 /// malformed. Segments that depend on it hide.
23 pub model: Option<ModelInfo>,
24 /// Per ADR-0014: `None` when the `workspace` wrapper is missing or
25 /// malformed (including a missing/null `project_dir`). Segments
26 /// that depend on it hide.
27 pub workspace: Option<WorkspaceInfo>,
28 pub context_window: Option<ContextWindow>,
29 pub cost: Option<CostMetrics>,
30 pub effort: Option<EffortLevel>,
31 pub vim: Option<VimMode>,
32 pub output_style: Option<OutputStyle>,
33 /// Active sub-agent name (collapsed from `agent.name` per ADR-0008).
34 /// **Invariant:** `Some(s)` always carries a non-empty `s`; the
35 /// parser folds null/missing/empty to `None`. See `lsm-srvz` for the
36 /// follow-up to lift this into the type via a `NonEmptyString`.
37 pub agent_name: Option<String>,
38 /// Tool CLI version string from the top-level `version` field
39 /// (e.g. Claude Code emits `"2.1.90"`). Trimmed; folds
40 /// null/missing/empty/whitespace-only to `None`. Per
41 /// `docs/specs/input-schema.md`, both Claude Code 2.x and Qwen
42 /// Code emit this; it is no longer a tool-detection discriminator.
43 pub version: Option<String>,
44 pub raw: Arc<serde_json::Value>,
45}
46
47/// `Tool::Other(s)` is intentionally NOT canonicalized: it compares
48/// unequal to a known variant even when `s.eq_ignore_ascii_case("claude")`.
49/// Supply runtime-detected tool names through the public entry points
50/// ([`parse_with_opts`] with [`ParseOpts::with_tool`], or the
51/// `LINESMITH_TOOL` env var) — the internal alias table folds known
52/// names into canonical variants before reaching `Other`, so direct
53/// `Tool::Other("claude")`-style construction is a contract violation.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum Tool {
56 ClaudeCode,
57 QwenCode,
58 CodexCli,
59 CopilotCli,
60 /// Unknown tool; structure is parsed best-effort and tool-specific
61 /// fields remain accessible via `StatusContext::raw`.
62 Other(Cow<'static, str>),
63}
64
65impl std::fmt::Display for Tool {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 match self {
68 Self::ClaudeCode => f.write_str("claude"),
69 Self::QwenCode => f.write_str("qwen"),
70 Self::CodexCli => f.write_str("codex"),
71 Self::CopilotCli => f.write_str("copilot"),
72 Self::Other(name) => f.write_str(name),
73 }
74 }
75}
76
77#[derive(Debug, Clone)]
78pub struct ModelInfo {
79 pub display_name: String,
80}
81
82#[derive(Debug, Clone)]
83pub struct WorkspaceInfo {
84 pub project_dir: PathBuf,
85 pub git_worktree: Option<GitWorktree>,
86}
87
88#[derive(Debug, Clone)]
89pub struct GitWorktree {
90 pub name: String,
91 pub path: PathBuf,
92}
93
94#[derive(Debug, Clone)]
95pub struct ContextWindow {
96 /// Used percentage. `remaining()` derives from this. Per ADR-0014,
97 /// `None` when CC emits `used_percentage: null` (the pre-first-API-
98 /// call window, see `docs/research/context-window-correctness.md`)
99 /// or the leaf is otherwise malformed.
100 pub used: Option<Percent>,
101 /// Context-window size in tokens. `u32` matches ADR-0014's Shape
102 /// section; values outside the u32 range degrade to `None`.
103 pub size: Option<u32>,
104 pub total_input_tokens: Option<u64>,
105 pub total_output_tokens: Option<u64>,
106 /// Tokens consumed by the most recent API call; `None` before the
107 /// first call in a session. Distinct from `total_*_tokens` above,
108 /// which are cumulative across the whole session.
109 pub current_usage: Option<TurnUsage>,
110}
111
112impl ContextWindow {
113 /// Percentage remaining; always consistent with `used`. Returns
114 /// `None` when `used` is `None` (per-leaf Option per ADR-0014).
115 #[must_use]
116 pub fn remaining(&self) -> Option<Percent> {
117 self.used.map(Percent::complement)
118 }
119}
120
121/// Per-turn token breakdown from `context_window.current_usage`. All
122/// counts are for the most recent API call only — use `ContextWindow`'s
123/// `total_*_tokens` for cumulative session values.
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125#[non_exhaustive]
126pub struct TurnUsage {
127 pub input_tokens: u64,
128 pub output_tokens: u64,
129 pub cache_creation_input_tokens: u64,
130 pub cache_read_input_tokens: u64,
131}
132
133#[derive(Debug, Clone, Copy)]
134#[non_exhaustive]
135pub struct CostMetrics {
136 /// Per ADR-0014, leaves degrade independently. `total_cost_usd:
137 /// None` means the leaf was missing, null, or wrong-typed;
138 /// segments hide the affected metric and unrelated cost leaves
139 /// still render.
140 pub total_cost_usd: Option<f64>,
141 pub total_duration_ms: Option<u64>,
142 pub total_api_duration_ms: Option<u64>,
143 /// Session lines added; `u64` to match the JSON wire width and avoid
144 /// silent truncation on sessions with very large aggregated counts.
145 pub total_lines_added: Option<u64>,
146 pub total_lines_removed: Option<u64>,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum EffortLevel {
151 Low,
152 Medium,
153 High,
154 Max,
155 XHigh,
156}
157
158impl EffortLevel {
159 #[must_use]
160 pub fn as_str(self) -> &'static str {
161 match self {
162 Self::Low => "low",
163 Self::Medium => "medium",
164 Self::High => "high",
165 Self::Max => "max",
166 Self::XHigh => "xhigh",
167 }
168 }
169}
170
171impl std::str::FromStr for EffortLevel {
172 type Err = ();
173
174 fn from_str(s: &str) -> Result<Self, Self::Err> {
175 match s {
176 "low" => Ok(Self::Low),
177 "medium" => Ok(Self::Medium),
178 "high" => Ok(Self::High),
179 "max" => Ok(Self::Max),
180 "xhigh" => Ok(Self::XHigh),
181 _ => Err(()),
182 }
183 }
184}
185
186/// Vim editing mode reflected from Claude Code's `vim.mode` field.
187/// `Command` is Vim's `:`-prefix command-line buffer, not "a command was
188/// run".
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190#[non_exhaustive]
191pub enum VimMode {
192 Normal,
193 Insert,
194 Visual,
195 Command,
196 Replace,
197}
198
199impl VimMode {
200 #[must_use]
201 pub fn as_str(self) -> &'static str {
202 match self {
203 Self::Normal => "normal",
204 Self::Insert => "insert",
205 Self::Visual => "visual",
206 Self::Command => "command",
207 Self::Replace => "replace",
208 }
209 }
210}
211
212impl std::str::FromStr for VimMode {
213 type Err = ();
214
215 fn from_str(s: &str) -> Result<Self, Self::Err> {
216 match s {
217 "normal" => Ok(Self::Normal),
218 "insert" => Ok(Self::Insert),
219 "visual" => Ok(Self::Visual),
220 "command" => Ok(Self::Command),
221 "replace" => Ok(Self::Replace),
222 _ => Err(()),
223 }
224 }
225}
226
227/// Active output style. Kept as a struct (rather than collapsing to
228/// `Option<String>`) so `name` can later evolve to an enum with a
229/// `Custom(String)` variant without breaking downstream type signatures.
230/// See ADR-0008.
231///
232/// **Invariant:** `name` is never empty. The Claude normalizer collapses
233/// empty/null/missing names to `Option::None` at the parser boundary, so
234/// every `Some(OutputStyle)` reaching a segment carries a non-empty name.
235/// In-crate constructors should preserve this contract; lsm-srvz tracks
236/// lifting it into the type system via a constructor.
237#[derive(Debug, Clone, PartialEq, Eq)]
238#[non_exhaustive]
239pub struct OutputStyle {
240 pub name: String,
241}
242
243/// Percentage in `0.0..=100.0`. Construction outside that range returns
244/// `None` so normalizers can translate to `ParseError::InvalidValue`.
245#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, serde::Serialize)]
246pub struct Percent(f32);
247
248impl Percent {
249 #[must_use]
250 pub fn new(value: f32) -> Option<Self> {
251 if (0.0..=100.0).contains(&value) {
252 Some(Self(value))
253 } else {
254 None
255 }
256 }
257
258 /// Construct from an `f64` (JSON's native number width). Range check
259 /// runs before narrowing, so values like `100.0000001` that would
260 /// round down to `100.0` in the cast are rejected rather than silently
261 /// accepted.
262 #[must_use]
263 pub fn from_f64(value: f64) -> Option<Self> {
264 if (0.0..=100.0).contains(&value) {
265 Some(Self(value as f32))
266 } else {
267 None
268 }
269 }
270
271 /// Construct from an `f64`, clamping finite out-of-range values into
272 /// `0.0..=100.0`. Returns `None` only for NaN. Use this when a field's
273 /// upstream producer is known to emit values slightly past 100 (e.g.
274 /// Claude Code's `context_window.used_percentage` post-`/compact`,
275 /// see claude-code#37163). Callers that want visibility into the
276 /// clamp should compare the raw value against the range before
277 /// invoking and emit a diagnostic — this helper is silent.
278 #[must_use]
279 pub fn from_f64_clamped(value: f64) -> Option<Self> {
280 if value.is_nan() {
281 return None;
282 }
283 Some(Self(value.clamp(0.0, 100.0) as f32))
284 }
285
286 #[must_use]
287 pub fn value(self) -> f32 {
288 self.0
289 }
290
291 /// `100.0 - self`, always in-range.
292 #[must_use]
293 pub fn complement(self) -> Self {
294 Self(100.0 - self.0)
295 }
296}
297
298// --- Parse entry + error taxonomy -------------------------------------
299
300/// Caller-side hooks for [`parse_with_opts`].
301///
302/// Only `tool` is wired (overrides heuristic detection). Marked
303/// `#[non_exhaustive]` so adding more knobs later (per-tool feature
304/// toggles, sample-rate caps, etc.) is non-breaking.
305#[derive(Debug, Clone, Default)]
306#[non_exhaustive]
307pub struct ParseOpts {
308 /// Force the detected tool. Skips both the `LINESMITH_TOOL` env
309 /// override and the shape-based heuristic. `None` runs the full
310 /// detection precedence per `docs/specs/input-schema.md`.
311 pub tool: Option<Tool>,
312}
313
314impl ParseOpts {
315 /// Set the explicit tool override.
316 #[must_use]
317 pub fn with_tool(mut self, tool: Tool) -> Self {
318 self.tool = Some(tool);
319 self
320 }
321}
322
323/// Parse a statusline JSON payload into a [`StatusContext`].
324///
325/// Equivalent to [`parse_with_opts`] with [`ParseOpts::default`]. Use
326/// this for the common case; pass opts when you need to force a tool
327/// (tests, plugin harnesses, sample fixtures).
328///
329/// # Errors
330///
331/// See [`parse_with_opts`].
332pub fn parse(input: &[u8]) -> Result<StatusContext, ParseError> {
333 parse_with_opts(input, &ParseOpts::default())
334}
335
336/// Parse a statusline JSON payload into a [`StatusContext`] with caller
337/// hooks. Tool detection follows the precedence in
338/// `docs/specs/input-schema.md` §"Heuristic detection": opts override
339/// → `LINESMITH_TOOL` env → shape heuristic → Fallback (ClaudeCode).
340///
341/// # Errors
342///
343/// Per ADR-0014, sub-field failures degrade to [`Option::None`] with
344/// `lsm_warn!` rather than propagating through `Result`. Returns `Err`
345/// only for catastrophic failures:
346/// [`ParseError::InvalidJson`] on malformed JSON,
347/// [`ParseError::TypeMismatch`] when the root is not a JSON object,
348/// and [`ParseError::InvalidValue`] for a `used_percentage` < 0
349/// (carve-out for undocumented CC corruption signals; NaN is rejected
350/// upstream by `serde_json` as `InvalidJson`).
351pub fn parse_with_opts(input: &[u8], opts: &ParseOpts) -> Result<StatusContext, ParseError> {
352 let raw_value: serde_json::Value =
353 serde_json::from_slice(input).map_err(|err| ParseError::InvalidJson {
354 message: err.to_string(),
355 // serde_json returns 0/0 for non-positional errors (e.g. EOF
356 // before any content); only carry a position when it's real.
357 location: (err.line() > 0).then(|| SourcePos {
358 line: err.line(),
359 column: err.column(),
360 }),
361 })?;
362
363 let raw = Arc::new(raw_value);
364 normalizers::dispatch(raw, opts)
365}
366
367#[derive(Debug)]
368#[non_exhaustive]
369pub enum ParseError {
370 InvalidJson {
371 message: String,
372 location: Option<SourcePos>,
373 },
374 /// **Reserved variant — not currently constructed by any parser
375 /// path.** Per ADR-0014, missing leaves degrade to `Option::None`
376 /// with `lsm_warn!`, never `Err`. The variant stays declared so
377 /// re-introducing a strict required-field policy in a future ADR
378 /// is non-breaking; today it cannot fire and pattern-matching for
379 /// it as a distinct case is dead code.
380 MissingField {
381 tool: Tool,
382 path: String,
383 },
384 /// The JSON kind at `path` didn't match what the normalizer expected.
385 /// Used strictly for JSON-shape mismatches; value-domain failures
386 /// (e.g. out-of-range percentage) use `InvalidValue`.
387 TypeMismatch {
388 tool: Tool,
389 path: String,
390 expected: JsonType,
391 got: JsonType,
392 },
393 /// JSON kind matched but the value violates a canonical-model
394 /// invariant (e.g. a percentage field was NaN or below 0, or an
395 /// enum-like string carried an unknown variant).
396 InvalidValue {
397 tool: Tool,
398 path: String,
399 reason: &'static str,
400 },
401 NormalizerError {
402 tool: Tool,
403 message: String,
404 },
405}
406
407#[derive(Debug, Clone, Copy)]
408pub struct SourcePos {
409 /// 1-indexed line (matches serde_json and editor conventions).
410 pub line: usize,
411 /// 1-indexed column (matches serde_json).
412 pub column: usize,
413}
414
415#[derive(Debug, Clone, Copy, PartialEq, Eq)]
416pub enum JsonType {
417 Object,
418 Array,
419 String,
420 Number,
421 Bool,
422 Null,
423}
424
425impl JsonType {
426 #[must_use]
427 pub fn of(value: &serde_json::Value) -> Self {
428 match value {
429 serde_json::Value::Object(_) => Self::Object,
430 serde_json::Value::Array(_) => Self::Array,
431 serde_json::Value::String(_) => Self::String,
432 serde_json::Value::Number(_) => Self::Number,
433 serde_json::Value::Bool(_) => Self::Bool,
434 serde_json::Value::Null => Self::Null,
435 }
436 }
437}
438
439impl std::fmt::Display for JsonType {
440 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
441 let name = match self {
442 Self::Object => "object",
443 Self::Array => "array",
444 Self::String => "string",
445 Self::Number => "number",
446 Self::Bool => "bool",
447 Self::Null => "null",
448 };
449 f.write_str(name)
450 }
451}
452
453impl std::fmt::Display for ParseError {
454 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
455 match self {
456 Self::InvalidJson { message, location } => match location {
457 Some(pos) => write!(f, "invalid JSON at {}:{}: {message}", pos.line, pos.column),
458 None => write!(f, "invalid JSON: {message}"),
459 },
460 Self::MissingField { tool, path } => {
461 write!(f, "missing field {} for {tool}", display_path(path))
462 }
463 Self::TypeMismatch {
464 tool,
465 path,
466 expected,
467 got,
468 } => {
469 write!(
470 f,
471 "type mismatch at {} for {tool}: expected {expected}, got {got}",
472 display_path(path)
473 )
474 }
475 Self::InvalidValue { tool, path, reason } => {
476 write!(
477 f,
478 "invalid value at {} for {tool}: {reason}",
479 display_path(path)
480 )
481 }
482 Self::NormalizerError { tool, message } => {
483 write!(f, "normalizer error for {tool}: {message}")
484 }
485 }
486 }
487}
488
489fn display_path(path: &str) -> String {
490 if path.is_empty() {
491 "<root>".to_string()
492 } else {
493 format!("{path:?}")
494 }
495}
496
497impl std::error::Error for ParseError {}
498
499mod normalizers;
500
501#[cfg(test)]
502mod tests;