Skip to main content

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;