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#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum Tool {
49    ClaudeCode,
50    QwenCode,
51    CodexCli,
52    CopilotCli,
53    /// Unknown tool; structure is parsed best-effort and tool-specific
54    /// fields remain accessible via `StatusContext::raw`.
55    Other(Cow<'static, str>),
56}
57
58#[derive(Debug, Clone)]
59pub struct ModelInfo {
60    pub display_name: String,
61}
62
63#[derive(Debug, Clone)]
64pub struct WorkspaceInfo {
65    pub project_dir: PathBuf,
66    pub git_worktree: Option<GitWorktree>,
67}
68
69#[derive(Debug, Clone)]
70pub struct GitWorktree {
71    pub name: String,
72    pub path: PathBuf,
73}
74
75#[derive(Debug, Clone)]
76pub struct ContextWindow {
77    /// Used percentage. `remaining()` derives from this. Per ADR-0014,
78    /// `None` when CC emits `used_percentage: null` (the pre-first-API-
79    /// call window, see `docs/research/context-window-correctness.md`)
80    /// or the leaf is otherwise malformed.
81    pub used: Option<Percent>,
82    /// Context-window size in tokens. `u32` matches ADR-0014's Shape
83    /// section; values outside the u32 range degrade to `None`.
84    pub size: Option<u32>,
85    pub total_input_tokens: Option<u64>,
86    pub total_output_tokens: Option<u64>,
87    /// Tokens consumed by the most recent API call; `None` before the
88    /// first call in a session. Distinct from `total_*_tokens` above,
89    /// which are cumulative across the whole session.
90    pub current_usage: Option<TurnUsage>,
91}
92
93impl ContextWindow {
94    /// Percentage remaining; always consistent with `used`. Returns
95    /// `None` when `used` is `None` (per-leaf Option per ADR-0014).
96    #[must_use]
97    pub fn remaining(&self) -> Option<Percent> {
98        self.used.map(Percent::complement)
99    }
100}
101
102/// Per-turn token breakdown from `context_window.current_usage`. All
103/// counts are for the most recent API call only — use `ContextWindow`'s
104/// `total_*_tokens` for cumulative session values.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106#[non_exhaustive]
107pub struct TurnUsage {
108    pub input_tokens: u64,
109    pub output_tokens: u64,
110    pub cache_creation_input_tokens: u64,
111    pub cache_read_input_tokens: u64,
112}
113
114#[derive(Debug, Clone, Copy)]
115#[non_exhaustive]
116pub struct CostMetrics {
117    /// Per ADR-0014, leaves degrade independently. `total_cost_usd:
118    /// None` means the leaf was missing, null, or wrong-typed;
119    /// segments hide the affected metric and unrelated cost leaves
120    /// still render.
121    pub total_cost_usd: Option<f64>,
122    pub total_duration_ms: Option<u64>,
123    pub total_api_duration_ms: Option<u64>,
124    /// Session lines added; `u64` to match the JSON wire width and avoid
125    /// silent truncation on sessions with very large aggregated counts.
126    pub total_lines_added: Option<u64>,
127    pub total_lines_removed: Option<u64>,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum EffortLevel {
132    Low,
133    Medium,
134    High,
135    Max,
136    XHigh,
137}
138
139impl EffortLevel {
140    #[must_use]
141    pub fn as_str(self) -> &'static str {
142        match self {
143            Self::Low => "low",
144            Self::Medium => "medium",
145            Self::High => "high",
146            Self::Max => "max",
147            Self::XHigh => "xhigh",
148        }
149    }
150}
151
152impl std::str::FromStr for EffortLevel {
153    type Err = ();
154
155    fn from_str(s: &str) -> Result<Self, Self::Err> {
156        match s {
157            "low" => Ok(Self::Low),
158            "medium" => Ok(Self::Medium),
159            "high" => Ok(Self::High),
160            "max" => Ok(Self::Max),
161            "xhigh" => Ok(Self::XHigh),
162            _ => Err(()),
163        }
164    }
165}
166
167/// Vim editing mode reflected from Claude Code's `vim.mode` field.
168/// `Command` is Vim's `:`-prefix command-line buffer, not "a command was
169/// run".
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171#[non_exhaustive]
172pub enum VimMode {
173    Normal,
174    Insert,
175    Visual,
176    Command,
177    Replace,
178}
179
180impl VimMode {
181    #[must_use]
182    pub fn as_str(self) -> &'static str {
183        match self {
184            Self::Normal => "normal",
185            Self::Insert => "insert",
186            Self::Visual => "visual",
187            Self::Command => "command",
188            Self::Replace => "replace",
189        }
190    }
191}
192
193impl std::str::FromStr for VimMode {
194    type Err = ();
195
196    fn from_str(s: &str) -> Result<Self, Self::Err> {
197        match s {
198            "normal" => Ok(Self::Normal),
199            "insert" => Ok(Self::Insert),
200            "visual" => Ok(Self::Visual),
201            "command" => Ok(Self::Command),
202            "replace" => Ok(Self::Replace),
203            _ => Err(()),
204        }
205    }
206}
207
208/// Active output style. Kept as a struct (rather than collapsing to
209/// `Option<String>`) so `name` can later evolve to an enum with a
210/// `Custom(String)` variant without breaking downstream type signatures.
211/// See ADR-0008.
212///
213/// **Invariant:** `name` is never empty. The Claude normalizer collapses
214/// empty/null/missing names to `Option::None` at the parser boundary, so
215/// every `Some(OutputStyle)` reaching a segment carries a non-empty name.
216/// In-crate constructors should preserve this contract; lsm-srvz tracks
217/// lifting it into the type system via a constructor.
218#[derive(Debug, Clone, PartialEq, Eq)]
219#[non_exhaustive]
220pub struct OutputStyle {
221    pub name: String,
222}
223
224/// Percentage in `0.0..=100.0`. Construction outside that range returns
225/// `None` so normalizers can translate to `ParseError::InvalidValue`.
226#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, serde::Serialize)]
227pub struct Percent(f32);
228
229impl Percent {
230    #[must_use]
231    pub fn new(value: f32) -> Option<Self> {
232        if (0.0..=100.0).contains(&value) {
233            Some(Self(value))
234        } else {
235            None
236        }
237    }
238
239    /// Construct from an `f64` (JSON's native number width). Range check
240    /// runs before narrowing, so values like `100.0000001` that would
241    /// round down to `100.0` in the cast are rejected rather than silently
242    /// accepted.
243    #[must_use]
244    pub fn from_f64(value: f64) -> Option<Self> {
245        if (0.0..=100.0).contains(&value) {
246            Some(Self(value as f32))
247        } else {
248            None
249        }
250    }
251
252    /// Construct from an `f64`, clamping finite out-of-range values into
253    /// `0.0..=100.0`. Returns `None` only for NaN. Use this when a field's
254    /// upstream producer is known to emit values slightly past 100 (e.g.
255    /// Claude Code's `context_window.used_percentage` post-`/compact`,
256    /// see claude-code#37163). Callers that want visibility into the
257    /// clamp should compare the raw value against the range before
258    /// invoking and emit a diagnostic — this helper is silent.
259    #[must_use]
260    pub fn from_f64_clamped(value: f64) -> Option<Self> {
261        if value.is_nan() {
262            return None;
263        }
264        Some(Self(value.clamp(0.0, 100.0) as f32))
265    }
266
267    #[must_use]
268    pub fn value(self) -> f32 {
269        self.0
270    }
271
272    /// `100.0 - self`, always in-range.
273    #[must_use]
274    pub fn complement(self) -> Self {
275        Self(100.0 - self.0)
276    }
277}
278
279// --- Parse entry + error taxonomy -------------------------------------
280
281/// Parse a Claude Code statusline JSON payload into a `StatusContext`.
282///
283/// Currently dispatches to the Claude normalizer unconditionally;
284/// tool-detection heuristics are added when a second tool is wired in.
285///
286/// # Errors
287///
288/// Per ADR-0014, sub-field failures degrade to `Option::None` with
289/// `lsm_warn!` rather than propagating through `Result`. `parse` only
290/// returns `Err` for catastrophic failures: `ParseError::InvalidJson`
291/// on malformed JSON, `TypeMismatch` when the root is not a JSON
292/// object, and `InvalidValue` for a `used_percentage` < 0 (carve-out
293/// for undocumented CC corruption signals; NaN is rejected upstream
294/// by `serde_json` as `InvalidJson`).
295pub fn parse(input: &[u8]) -> Result<StatusContext, ParseError> {
296    let raw_value: serde_json::Value =
297        serde_json::from_slice(input).map_err(|err| ParseError::InvalidJson {
298            message: err.to_string(),
299            // serde_json returns 0/0 for non-positional errors (e.g. EOF
300            // before any content); only carry a position when it's real.
301            location: (err.line() > 0).then(|| SourcePos {
302                line: err.line(),
303                column: err.column(),
304            }),
305        })?;
306
307    let raw = Arc::new(raw_value);
308    claude::normalize(raw)
309}
310
311#[derive(Debug)]
312#[non_exhaustive]
313pub enum ParseError {
314    InvalidJson {
315        message: String,
316        location: Option<SourcePos>,
317    },
318    /// **Reserved variant — not currently constructed by any parser
319    /// path.** Per ADR-0014, missing leaves degrade to `Option::None`
320    /// with `lsm_warn!`, never `Err`. The variant stays declared so
321    /// re-introducing a strict required-field policy in a future ADR
322    /// is non-breaking; today it cannot fire and pattern-matching for
323    /// it as a distinct case is dead code.
324    MissingField {
325        tool: Tool,
326        path: String,
327    },
328    /// The JSON kind at `path` didn't match what the normalizer expected.
329    /// Used strictly for JSON-shape mismatches; value-domain failures
330    /// (e.g. out-of-range percentage) use `InvalidValue`.
331    TypeMismatch {
332        tool: Tool,
333        path: String,
334        expected: JsonType,
335        got: JsonType,
336    },
337    /// JSON kind matched but the value violates a canonical-model
338    /// invariant (e.g. a percentage field was NaN or below 0, or an
339    /// enum-like string carried an unknown variant).
340    InvalidValue {
341        tool: Tool,
342        path: String,
343        reason: &'static str,
344    },
345    NormalizerError {
346        tool: Tool,
347        message: String,
348    },
349}
350
351#[derive(Debug, Clone, Copy)]
352pub struct SourcePos {
353    /// 1-indexed line (matches serde_json and editor conventions).
354    pub line: usize,
355    /// 1-indexed column (matches serde_json).
356    pub column: usize,
357}
358
359#[derive(Debug, Clone, Copy, PartialEq, Eq)]
360pub enum JsonType {
361    Object,
362    Array,
363    String,
364    Number,
365    Bool,
366    Null,
367}
368
369impl JsonType {
370    #[must_use]
371    pub fn of(value: &serde_json::Value) -> Self {
372        match value {
373            serde_json::Value::Object(_) => Self::Object,
374            serde_json::Value::Array(_) => Self::Array,
375            serde_json::Value::String(_) => Self::String,
376            serde_json::Value::Number(_) => Self::Number,
377            serde_json::Value::Bool(_) => Self::Bool,
378            serde_json::Value::Null => Self::Null,
379        }
380    }
381}
382
383impl std::fmt::Display for JsonType {
384    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
385        let name = match self {
386            Self::Object => "object",
387            Self::Array => "array",
388            Self::String => "string",
389            Self::Number => "number",
390            Self::Bool => "bool",
391            Self::Null => "null",
392        };
393        f.write_str(name)
394    }
395}
396
397impl std::fmt::Display for ParseError {
398    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399        match self {
400            Self::InvalidJson { message, location } => match location {
401                Some(pos) => write!(f, "invalid JSON at {}:{}: {message}", pos.line, pos.column),
402                None => write!(f, "invalid JSON: {message}"),
403            },
404            Self::MissingField { tool, path } => {
405                write!(f, "missing field {} for {tool:?}", display_path(path))
406            }
407            Self::TypeMismatch {
408                tool,
409                path,
410                expected,
411                got,
412            } => {
413                write!(
414                    f,
415                    "type mismatch at {} for {tool:?}: expected {expected}, got {got}",
416                    display_path(path)
417                )
418            }
419            Self::InvalidValue { tool, path, reason } => {
420                write!(
421                    f,
422                    "invalid value at {} for {tool:?}: {reason}",
423                    display_path(path)
424                )
425            }
426            Self::NormalizerError { tool, message } => {
427                write!(f, "normalizer error for {tool:?}: {message}")
428            }
429        }
430    }
431}
432
433fn display_path(path: &str) -> String {
434    if path.is_empty() {
435        "<root>".to_string()
436    } else {
437        format!("{path:?}")
438    }
439}
440
441impl std::error::Error for ParseError {}
442
443// --- Claude normalizer -----------------------------------------------
444//
445// One normalizer today, inline. This module moves to
446// `input/normalizers/claude.rs` alongside siblings when a second tool
447// lands.
448
449mod claude {
450    use super::{
451        ContextWindow, CostMetrics, EffortLevel, GitWorktree, JsonType, ModelInfo, OutputStyle,
452        ParseError, Percent, StatusContext, Tool, TurnUsage, VimMode, WorkspaceInfo,
453    };
454    use std::path::PathBuf;
455    use std::sync::Arc;
456
457    const TOOL: Tool = Tool::ClaudeCode;
458
459    pub(super) fn normalize(raw: Arc<serde_json::Value>) -> Result<StatusContext, ParseError> {
460        let root = expect_object(&raw, "")?;
461
462        let model = parse_model(root);
463        let workspace = parse_workspace(root);
464        let context_window = parse_context_window(root)?;
465        let cost = parse_cost(root)?;
466        let effort = parse_effort(root)?;
467        let vim = parse_vim(root)?;
468        let output_style = parse_output_style(root)?;
469        let agent_name = parse_agent_name(root)?;
470        let version = parse_version(root)?;
471
472        Ok(StatusContext {
473            tool: TOOL,
474            model,
475            workspace,
476            context_window,
477            cost,
478            effort,
479            vim,
480            output_style,
481            agent_name,
482            version,
483            raw,
484        })
485    }
486
487    /// ADR-0014: any sub-field failure (missing wrapper, non-object,
488    /// missing/null/non-string `display_name`) downgrades the field
489    /// to `None` with an `lsm_warn!` carrying the JSON path. Segments
490    /// hide; unrelated segments still render.
491    fn parse_model(root: &serde_json::Map<String, serde_json::Value>) -> Option<ModelInfo> {
492        let value = root.get("model")?;
493        if value.is_null() {
494            return None;
495        }
496        let model = match value.as_object() {
497            Some(o) => o,
498            None => {
499                crate::lsm_warn!(
500                    "model: expected object, got {:?}; degrading to None (possible CC schema drift)",
501                    JsonType::of(value)
502                );
503                return None;
504            }
505        };
506        let Some(name_value) = model.get("display_name") else {
507            crate::lsm_warn!("model.display_name: missing; degrading to None");
508            return None;
509        };
510        if name_value.is_null() {
511            return None;
512        }
513        let Some(display_name) = name_value.as_str() else {
514            crate::lsm_warn!(
515                "model.display_name: expected string, got {:?}; degrading to None",
516                JsonType::of(name_value)
517            );
518            return None;
519        };
520        Some(ModelInfo {
521            display_name: display_name.to_owned(),
522        })
523    }
524
525    /// ADR-0014: any sub-field failure downgrades to `None` + warn.
526    /// `git_worktree` still degrades independently — a malformed
527    /// worktree shouldn't hide the project_dir basename.
528    fn parse_workspace(root: &serde_json::Map<String, serde_json::Value>) -> Option<WorkspaceInfo> {
529        let value = root.get("workspace")?;
530        if value.is_null() {
531            return None;
532        }
533        let workspace = match value.as_object() {
534            Some(o) => o,
535            None => {
536                crate::lsm_warn!(
537                    "workspace: expected object, got {:?}; degrading to None (possible CC schema drift)",
538                    JsonType::of(value)
539                );
540                return None;
541            }
542        };
543        let Some(dir_value) = workspace.get("project_dir") else {
544            crate::lsm_warn!("workspace.project_dir: missing; degrading to None");
545            return None;
546        };
547        if dir_value.is_null() {
548            return None;
549        }
550        let Some(project_dir_str) = dir_value.as_str() else {
551            crate::lsm_warn!(
552                "workspace.project_dir: expected string, got {:?}; degrading to None",
553                JsonType::of(dir_value)
554            );
555            return None;
556        };
557
558        let git_worktree = match workspace.get("git_worktree") {
559            Some(serde_json::Value::Null) | None => None,
560            Some(serde_json::Value::Object(obj)) => parse_git_worktree(obj),
561            Some(other) => {
562                crate::lsm_warn!(
563                    "workspace.git_worktree: expected object, got {:?}; degrading to None (worktree only)",
564                    JsonType::of(other)
565                );
566                None
567            }
568        };
569
570        Some(WorkspaceInfo {
571            project_dir: PathBuf::from(project_dir_str),
572            git_worktree,
573        })
574    }
575
576    fn parse_git_worktree(obj: &serde_json::Map<String, serde_json::Value>) -> Option<GitWorktree> {
577        let name = string_leaf(obj, "workspace.git_worktree.name")?;
578        let path = string_leaf(obj, "workspace.git_worktree.path")?;
579        // Empty strings are silent: CC sometimes emits `""` for
580        // unset fields. A non-string drift already warned in
581        // `string_leaf` before reaching here.
582        if name.is_empty() || path.is_empty() {
583            return None;
584        }
585        Some(GitWorktree {
586            name: name.to_owned(),
587            path: PathBuf::from(path),
588        })
589    }
590
591    /// Tolerant string reader. Missing or null → silent `None`
592    /// (documented "field unset" shape). Non-string → `lsm_warn!` +
593    /// `None` to surface schema drift.
594    fn string_leaf<'a>(
595        obj: &'a serde_json::Map<String, serde_json::Value>,
596        path: &'static str,
597    ) -> Option<&'a str> {
598        let value = obj.get(path_tail(path))?;
599        if value.is_null() {
600            return None;
601        }
602        match value.as_str() {
603            Some(s) => Some(s),
604            None => {
605                crate::lsm_warn!(
606                    "{path}: expected string, got {:?}; degrading to None",
607                    JsonType::of(value)
608                );
609                None
610            }
611        }
612    }
613
614    fn parse_context_window(
615        root: &serde_json::Map<String, serde_json::Value>,
616    ) -> Result<Option<ContextWindow>, ParseError> {
617        let Some(value) = root.get("context_window") else {
618            return Ok(None);
619        };
620        if value.is_null() {
621            return Ok(None);
622        }
623        let Some(cw) = value.as_object() else {
624            crate::lsm_warn!(
625                "context_window: expected object, got {:?}; degrading to None",
626                JsonType::of(value)
627            );
628            return Ok(None);
629        };
630
631        // ADR-0014: each leaf degrades independently. A null
632        // `used_percentage` (the documented pre-first-API-call shape)
633        // no longer hides peers like `current_usage` or `size`.
634        let used = parse_used_percentage(cw)?;
635        let size = parse_size(cw);
636        let total_input_tokens = try_u64_required(cw, "context_window.total_input_tokens");
637        let total_output_tokens = try_u64_required(cw, "context_window.total_output_tokens");
638        let current_usage = parse_current_usage(cw)?;
639
640        let window = ContextWindow {
641            used,
642            size,
643            total_input_tokens,
644            total_output_tokens,
645            current_usage,
646        };
647        // Collapse to `None` when every leaf failed so the plugin
648        // contract `ctx.status.context_window != ()` round-trips: a
649        // non-`()` map must always have at least one readable leaf.
650        if context_window_is_empty(&window) {
651            return Ok(None);
652        }
653        Ok(Some(window))
654    }
655
656    fn context_window_is_empty(cw: &ContextWindow) -> bool {
657        cw.used.is_none()
658            && cw.size.is_none()
659            && cw.total_input_tokens.is_none()
660            && cw.total_output_tokens.is_none()
661            && cw.current_usage.is_none()
662    }
663
664    /// Parse `context_window.used_percentage` into `Option<Percent>`.
665    /// - missing / null → `Ok(None)`, silent (documented pre-first-
666    ///   API-call shape; warning would spam at every fresh session)
667    /// - non-number → `Ok(None)` + warn (schema drift)
668    /// - in-range value → `Ok(Some(_))`
669    /// - >100 → clamp to 100 + warn (claude-code#37163)
670    /// - <0 or NaN → `Err(InvalidValue)`. Carve-out from ADR-0014's
671    ///   warn-and-degrade default: a negative is undocumented and
672    ///   most likely a corrupted payload; surfacing loud catches
673    ///   real upstream breakage that "Some(0)" or warn+None would mask.
674    fn parse_used_percentage(
675        cw: &serde_json::Map<String, serde_json::Value>,
676    ) -> Result<Option<Percent>, ParseError> {
677        let Some(value) = cw.get("used_percentage") else {
678            return Ok(None);
679        };
680        if value.is_null() {
681            return Ok(None);
682        }
683        let Some(used_raw) = value.as_f64() else {
684            crate::lsm_warn!(
685                "context_window.used_percentage: expected number, got {:?}; degrading leaf to None",
686                JsonType::of(value)
687            );
688            return Ok(None);
689        };
690        if used_raw > 100.0 {
691            crate::lsm_warn!("context_window.used_percentage = {used_raw} > 100; clamping to 100");
692            return Ok(Some(
693                Percent::from_f64_clamped(used_raw)
694                    .expect("non-NaN value > 100 clamps successfully"),
695            ));
696        }
697        match Percent::from_f64(used_raw) {
698            Some(p) => Ok(Some(p)),
699            None => Err(invalid_value(
700                "context_window.used_percentage",
701                "percentage must be a number in [0, 100]",
702            )),
703        }
704    }
705
706    /// Parse `context_window_size` into `Option<u32>`. Narrows `u64`
707    /// to ADR-0014's `u32`; warns and degrades on overflow rather
708    /// than wrapping. No real CC context window comes anywhere close.
709    fn parse_size(cw: &serde_json::Map<String, serde_json::Value>) -> Option<u32> {
710        let raw = try_u64_required(cw, "context_window.context_window_size")?;
711        match u32::try_from(raw) {
712            Ok(n) => Some(n),
713            Err(_) => {
714                crate::lsm_warn!(
715                    "context_window.context_window_size = {raw} exceeds u32::MAX; degrading leaf to None"
716                );
717                None
718            }
719        }
720    }
721
722    /// Tolerant `u64` reader for *contracted* leaves (CC contract
723    /// guarantees the key is present and non-null). Missing or null
724    /// here is schema drift; warn so the channel surfaces upstream
725    /// changes. Use `try_u64_optional` for documented "may be absent"
726    /// fields like `current_usage.*`.
727    fn try_u64_required(
728        obj: &serde_json::Map<String, serde_json::Value>,
729        path: &'static str,
730    ) -> Option<u64> {
731        let Some(value) = obj.get(path_tail(path)) else {
732            crate::lsm_warn!("{path}: missing; degrading leaf to None (possible CC schema drift)");
733            return None;
734        };
735        if value.is_null() {
736            crate::lsm_warn!("{path}: null; degrading leaf to None (possible CC schema drift)");
737            return None;
738        }
739        match value.as_u64() {
740            Some(n) => Some(n),
741            None => {
742                crate::lsm_warn!(
743                    "{path}: expected unsigned integer, got {:?}; degrading leaf to None",
744                    JsonType::of(value)
745                );
746                None
747            }
748        }
749    }
750
751    /// Tolerant `u64` reader for *optional* leaves where missing/null
752    /// is documented (e.g. `current_usage.*` before the first API
753    /// call). Silent on absence; warn on type drift.
754    fn try_u64_optional(
755        obj: &serde_json::Map<String, serde_json::Value>,
756        path: &'static str,
757    ) -> Option<u64> {
758        let value = obj.get(path_tail(path))?;
759        if value.is_null() {
760            return None;
761        }
762        match value.as_u64() {
763            Some(n) => Some(n),
764            None => {
765                crate::lsm_warn!(
766                    "{path}: expected unsigned integer, got {:?}; degrading leaf to None",
767                    JsonType::of(value)
768                );
769                None
770            }
771        }
772    }
773
774    /// Tolerant `f64` reader for *contracted* leaves. Mirrors
775    /// `try_u64_required`. JSON syntax can't represent NaN or ±Inf
776    /// (`serde_json` rejects them as `InvalidJson` at parse time),
777    /// so a non-finite check here would be unreachable through
778    /// `parse()` — omitted intentionally.
779    fn try_f64_required(
780        obj: &serde_json::Map<String, serde_json::Value>,
781        path: &'static str,
782    ) -> Option<f64> {
783        let Some(value) = obj.get(path_tail(path)) else {
784            crate::lsm_warn!("{path}: missing; degrading leaf to None (possible CC schema drift)");
785            return None;
786        };
787        if value.is_null() {
788            crate::lsm_warn!("{path}: null; degrading leaf to None (possible CC schema drift)");
789            return None;
790        }
791        let Some(n) = value.as_f64() else {
792            crate::lsm_warn!(
793                "{path}: expected number, got {:?}; degrading leaf to None",
794                JsonType::of(value)
795            );
796            return None;
797        };
798        Some(n)
799    }
800
801    fn parse_current_usage(
802        cw: &serde_json::Map<String, serde_json::Value>,
803    ) -> Result<Option<TurnUsage>, ParseError> {
804        // Claude Code emits `current_usage: null` before the first API
805        // call in a session (see docs/research/claude-code-statusline-api.md).
806        // The key's presence isn't guaranteed by the schema either, so
807        // tolerate outright omission as defense-in-depth; both map to
808        // Option::None.
809        let Some(value) = cw.get("current_usage") else {
810            return Ok(None);
811        };
812        if value.is_null() {
813            return Ok(None);
814        }
815        let Some(obj) = value.as_object() else {
816            crate::lsm_warn!(
817                "context_window.current_usage: expected object, got {:?}; degrading to None",
818                JsonType::of(value)
819            );
820            return Ok(None);
821        };
822        // ADR-0014: TurnUsage's leaves are non-Option, so any leaf
823        // failure collapses the whole TurnUsage to None. Type drift
824        // warns via `try_u64_optional`; missing/null leaves stay
825        // silent because `current_usage` itself is documented to be
826        // null pre-first-API-call.
827        let Some(input_tokens) = try_u64_optional(obj, "context_window.current_usage.input_tokens")
828        else {
829            return Ok(None);
830        };
831        let Some(output_tokens) =
832            try_u64_optional(obj, "context_window.current_usage.output_tokens")
833        else {
834            return Ok(None);
835        };
836        let Some(cache_creation_input_tokens) = try_u64_optional(
837            obj,
838            "context_window.current_usage.cache_creation_input_tokens",
839        ) else {
840            return Ok(None);
841        };
842        let Some(cache_read_input_tokens) =
843            try_u64_optional(obj, "context_window.current_usage.cache_read_input_tokens")
844        else {
845            return Ok(None);
846        };
847        Ok(Some(TurnUsage {
848            input_tokens,
849            output_tokens,
850            cache_creation_input_tokens,
851            cache_read_input_tokens,
852        }))
853    }
854
855    fn parse_cost(
856        root: &serde_json::Map<String, serde_json::Value>,
857    ) -> Result<Option<CostMetrics>, ParseError> {
858        let Some(value) = root.get("cost") else {
859            return Ok(None);
860        };
861        if value.is_null() {
862            return Ok(None);
863        }
864        let Some(cost) = value.as_object() else {
865            crate::lsm_warn!(
866                "cost: expected object, got {:?}; degrading to None",
867                JsonType::of(value)
868            );
869            return Ok(None);
870        };
871
872        // Per ADR-0014, leaves degrade independently. CC contract
873        // guarantees these keys when `cost` is present, so missing/null
874        // is schema drift and warns at the leaf.
875        let metrics = CostMetrics {
876            total_cost_usd: try_f64_required(cost, "cost.total_cost_usd"),
877            total_duration_ms: try_u64_required(cost, "cost.total_duration_ms"),
878            total_api_duration_ms: try_u64_required(cost, "cost.total_api_duration_ms"),
879            total_lines_added: try_u64_required(cost, "cost.total_lines_added"),
880            total_lines_removed: try_u64_required(cost, "cost.total_lines_removed"),
881        };
882        // If every leaf failed, collapse to `None` so the plugin
883        // contract `ctx.status.cost != ()` (per plugin-api.md) round-
884        // trips correctly: a non-`()` cost map must always have at
885        // least one readable leaf.
886        if cost_is_empty(&metrics) {
887            return Ok(None);
888        }
889        Ok(Some(metrics))
890    }
891
892    fn cost_is_empty(c: &CostMetrics) -> bool {
893        c.total_cost_usd.is_none()
894            && c.total_duration_ms.is_none()
895            && c.total_api_duration_ms.is_none()
896            && c.total_lines_added.is_none()
897            && c.total_lines_removed.is_none()
898    }
899
900    fn parse_effort(
901        root: &serde_json::Map<String, serde_json::Value>,
902    ) -> Result<Option<EffortLevel>, ParseError> {
903        let Some(value) = root.get("effort") else {
904            return Ok(None);
905        };
906        if value.is_null() {
907            return Ok(None);
908        }
909        // Canonical CC 2.1.x emits `effort: { level: "xhigh" }`. A bare
910        // string is tolerated for forward/backward compat. Per ADR-0014,
911        // every failure path warn-and-degrades — symmetric with parse_vim.
912        let (raw, path): (&str, &'static str) = match value {
913            serde_json::Value::Object(obj) => {
914                let Some(level) = obj.get("level") else {
915                    crate::lsm_warn!(
916                        "effort: wrapper present but `level` missing; degrading to None (possible CC schema drift)"
917                    );
918                    return Ok(None);
919                };
920                if level.is_null() {
921                    return Ok(None);
922                }
923                let Some(s) = level.as_str() else {
924                    crate::lsm_warn!(
925                        "effort.level: expected string, got {:?}; degrading to None",
926                        JsonType::of(level)
927                    );
928                    return Ok(None);
929                };
930                (s, "effort.level")
931            }
932            serde_json::Value::String(s) => (s.as_str(), "effort"),
933            other => {
934                crate::lsm_warn!(
935                    "effort: expected object or string, got {:?}; degrading to None",
936                    JsonType::of(other)
937                );
938                return Ok(None);
939            }
940        };
941        match raw.parse::<EffortLevel>() {
942            Ok(level) => Ok(Some(level)),
943            Err(()) => {
944                crate::lsm_warn!(
945                    "effort: unknown level {raw:?} at {path}; degrading to None (possible CC schema drift — known: low, medium, high, max, xhigh)"
946                );
947                Ok(None)
948            }
949        }
950    }
951
952    fn parse_vim(
953        root: &serde_json::Map<String, serde_json::Value>,
954    ) -> Result<Option<VimMode>, ParseError> {
955        let Some(value) = root.get("vim") else {
956            return Ok(None);
957        };
958        if value.is_null() {
959            return Ok(None);
960        }
961        // Canonical CC shape is `vim: { mode: "<name>" }` per
962        // research/claude-code-statusline-api.md. A bare string is
963        // tolerated for forward/backward compat. Per ADR-0014, every
964        // failure path warn-and-degrades.
965        let (raw, path): (&str, &'static str) = match value {
966            serde_json::Value::Object(obj) => {
967                let Some(mode) = obj.get("mode") else {
968                    crate::lsm_warn!(
969                        "vim: wrapper present but `mode` missing; degrading to None (possible CC schema drift)"
970                    );
971                    return Ok(None);
972                };
973                if mode.is_null() {
974                    return Ok(None);
975                }
976                let Some(s) = mode.as_str() else {
977                    crate::lsm_warn!(
978                        "vim.mode: expected string, got {:?}; degrading to None",
979                        JsonType::of(mode)
980                    );
981                    return Ok(None);
982                };
983                (s, "vim.mode")
984            }
985            serde_json::Value::String(s) => {
986                // Bare-string is a tolerated forward/backward-compat
987                // shape; canonical CC emits `vim: { mode: "..." }`.
988                // Log when the fallback fires so it leaves a trail
989                // whether CC drifts to bare-string or a non-canonical
990                // producer slips in.
991                crate::lsm_debug!(
992                    "vim: accepted bare-string compat shape {:?}; canonical is {{ mode }}",
993                    s
994                );
995                (s.as_str(), "vim")
996            }
997            other => {
998                crate::lsm_warn!(
999                    "vim: expected object or string, got {:?}; degrading to None",
1000                    JsonType::of(other)
1001                );
1002                return Ok(None);
1003            }
1004        };
1005        // Unknown vim modes degrade the segment, not the whole render:
1006        // `vim` is opt-in and informational, so a future CC mode (e.g.
1007        // `select`, `terminal`) shouldn't blank the statusline.
1008        match raw.parse::<VimMode>() {
1009            Ok(mode) => Ok(Some(mode)),
1010            Err(()) => {
1011                crate::lsm_warn!(
1012                    "vim: unknown mode {raw:?} at {path}; degrading to None (possible CC schema drift — known: normal, insert, visual, command, replace)"
1013                );
1014                Ok(None)
1015            }
1016        }
1017    }
1018
1019    fn parse_output_style(
1020        root: &serde_json::Map<String, serde_json::Value>,
1021    ) -> Result<Option<OutputStyle>, ParseError> {
1022        let Some(value) = root.get("output_style") else {
1023            return Ok(None);
1024        };
1025        if value.is_null() {
1026            return Ok(None);
1027        }
1028        let Some(obj) = value.as_object() else {
1029            crate::lsm_warn!(
1030                "output_style: expected object, got {:?}; degrading to None",
1031                JsonType::of(value)
1032            );
1033            return Ok(None);
1034        };
1035        // Tolerate a missing `name` as None so the schema can grow,
1036        // but warn: the wrapper is present, so a future CC rename of
1037        // `name` would otherwise dark-ship this segment without a
1038        // diagnostic trail.
1039        let Some(name_value) = obj.get("name") else {
1040            crate::lsm_warn!(
1041                "output_style: wrapper present but `name` field missing; degrading to None (possible CC schema drift)"
1042            );
1043            return Ok(None);
1044        };
1045        if name_value.is_null() {
1046            return Ok(None);
1047        }
1048        let Some(name) = name_value.as_str() else {
1049            crate::lsm_warn!(
1050                "output_style.name: expected string, got {:?}; degrading to None",
1051                JsonType::of(name_value)
1052            );
1053            return Ok(None);
1054        };
1055        if name.is_empty() {
1056            return Ok(None);
1057        }
1058        Ok(Some(OutputStyle {
1059            name: name.to_owned(),
1060        }))
1061    }
1062
1063    fn parse_version(
1064        root: &serde_json::Map<String, serde_json::Value>,
1065    ) -> Result<Option<String>, ParseError> {
1066        let Some(value) = root.get("version") else {
1067            return Ok(None);
1068        };
1069        if value.is_null() {
1070            return Ok(None);
1071        }
1072        let Some(raw) = value.as_str() else {
1073            crate::lsm_warn!(
1074                "version: expected string, got {:?}; degrading to None",
1075                JsonType::of(value)
1076            );
1077            return Ok(None);
1078        };
1079        // Trim and fold whitespace-only / empty to None — the
1080        // empty-payload contract should treat `"  "` the same as `""`
1081        // and `null` rather than rendering a blank-looking version.
1082        let trimmed = raw.trim();
1083        if trimmed.is_empty() {
1084            return Ok(None);
1085        }
1086        Ok(Some(trimmed.to_owned()))
1087    }
1088
1089    fn parse_agent_name(
1090        root: &serde_json::Map<String, serde_json::Value>,
1091    ) -> Result<Option<String>, ParseError> {
1092        let Some(value) = root.get("agent") else {
1093            return Ok(None);
1094        };
1095        if value.is_null() {
1096            return Ok(None);
1097        }
1098        let Some(obj) = value.as_object() else {
1099            crate::lsm_warn!(
1100                "agent: expected object, got {:?}; degrading to None",
1101                JsonType::of(value)
1102            );
1103            return Ok(None);
1104        };
1105        // Same drift-detection rationale as `parse_output_style`: warn
1106        // when the wrapper is present but `name` is absent.
1107        let Some(name_value) = obj.get("name") else {
1108            crate::lsm_warn!(
1109                "agent: wrapper present but `name` field missing; degrading to None (possible CC schema drift)"
1110            );
1111            return Ok(None);
1112        };
1113        if name_value.is_null() {
1114            return Ok(None);
1115        }
1116        let Some(name) = name_value.as_str() else {
1117            crate::lsm_warn!(
1118                "agent.name: expected string, got {:?}; degrading to None",
1119                JsonType::of(name_value)
1120            );
1121            return Ok(None);
1122        };
1123        if name.is_empty() {
1124            return Ok(None);
1125        }
1126        Ok(Some(name.to_owned()))
1127    }
1128
1129    // --- helpers ------------------------------------------------------
1130
1131    fn expect_object<'a>(
1132        value: &'a serde_json::Value,
1133        path: &str,
1134    ) -> Result<&'a serde_json::Map<String, serde_json::Value>, ParseError> {
1135        value
1136            .as_object()
1137            .ok_or_else(|| type_mismatch(path, JsonType::Object, JsonType::of(value)))
1138    }
1139
1140    fn path_tail(path: &str) -> &str {
1141        path.rsplit('.').next().unwrap_or(path)
1142    }
1143
1144    fn type_mismatch(path: impl Into<String>, expected: JsonType, got: JsonType) -> ParseError {
1145        ParseError::TypeMismatch {
1146            tool: TOOL,
1147            path: path.into(),
1148            expected,
1149            got,
1150        }
1151    }
1152
1153    fn invalid_value(path: impl Into<String>, reason: &'static str) -> ParseError {
1154        ParseError::InvalidValue {
1155            tool: TOOL,
1156            path: path.into(),
1157            reason,
1158        }
1159    }
1160}
1161
1162#[cfg(test)]
1163mod tests;