Skip to main content

linesmith_core/
input.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 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 {
1164    use super::*;
1165
1166    fn pct(v: f32) -> Percent {
1167        Percent::new(v).expect("in range")
1168    }
1169
1170    #[test]
1171    fn percent_new_rejects_out_of_range() {
1172        assert!(Percent::new(-0.1).is_none());
1173        assert!(Percent::new(100.1).is_none());
1174        assert!(Percent::new(f32::NAN).is_none());
1175    }
1176
1177    #[test]
1178    fn percent_from_f64_clamped_clamps_finite_values_and_rejects_nan() {
1179        assert_eq!(Percent::from_f64_clamped(150.0).unwrap().value(), 100.0);
1180        assert_eq!(Percent::from_f64_clamped(-5.0).unwrap().value(), 0.0);
1181        assert_eq!(Percent::from_f64_clamped(100.0).unwrap().value(), 100.0);
1182        assert_eq!(Percent::from_f64_clamped(0.0).unwrap().value(), 0.0);
1183        assert_eq!(Percent::from_f64_clamped(42.5).unwrap().value(), 42.5);
1184        // Tiny overshoot is the shape claude-code#37163 actually emits.
1185        // Strict `from_f64` rejects this because its range check runs
1186        // on the f64 before narrowing, so `100.0000001` is flagged
1187        // even though it would narrow to exactly `100.0` as f32. The
1188        // clamped variant accepts the out-of-range value and pins it
1189        // to 100.0.
1190        assert_eq!(
1191            Percent::from_f64_clamped(100.0000001).unwrap().value(),
1192            100.0
1193        );
1194        // NaN is the only input that still fails: `f64::clamp` treats
1195        // NaN as identity, which would poison downstream math. Reject
1196        // so the caller can surface InvalidValue.
1197        assert!(Percent::from_f64_clamped(f64::NAN).is_none());
1198        // Infinity clamps to the nearest bound under IEEE-754 ordering
1199        // — pinning explicitly in case a future stdlib change shifts
1200        // the semantics.
1201        assert_eq!(
1202            Percent::from_f64_clamped(f64::INFINITY).unwrap().value(),
1203            100.0
1204        );
1205        assert_eq!(
1206            Percent::from_f64_clamped(f64::NEG_INFINITY)
1207                .unwrap()
1208                .value(),
1209            0.0
1210        );
1211    }
1212
1213    #[test]
1214    fn percent_from_f64_rejects_values_that_would_narrow_into_range() {
1215        // 100.0000001 as f32 rounds to exactly 100.0. from_f64 validates
1216        // before narrowing so this is rejected rather than silently passing.
1217        assert!(Percent::from_f64(100.0000001).is_none());
1218        assert!(Percent::from_f64(-0.0000001).is_none());
1219        assert!(Percent::from_f64(f64::NAN).is_none());
1220        assert!(Percent::from_f64(100.0).is_some());
1221        assert!(Percent::from_f64(0.0).is_some());
1222    }
1223
1224    #[test]
1225    fn percent_complement_stays_in_range() {
1226        assert_eq!(pct(42.0).complement().value(), 58.0);
1227        assert_eq!(pct(0.0).complement().value(), 100.0);
1228        assert_eq!(pct(100.0).complement().value(), 0.0);
1229    }
1230
1231    #[test]
1232    fn parses_minimal_claude_payload() {
1233        let json = br#"{
1234            "model": { "id": "x", "display_name": "Claude Test" },
1235            "workspace": {
1236                "current_dir": ".",
1237                "project_dir": "/home/dev/linesmith",
1238                "added_dirs": [],
1239                "git_worktree": null
1240            }
1241        }"#;
1242        let ctx = parse(json).expect("parse ok");
1243        assert_eq!(ctx.tool, Tool::ClaudeCode);
1244        let model = ctx.model.expect("model");
1245        assert_eq!(model.display_name, "Claude Test");
1246        let workspace = ctx.workspace.expect("workspace");
1247        assert_eq!(workspace.project_dir.to_str(), Some("/home/dev/linesmith"));
1248        assert!(workspace.git_worktree.is_none());
1249        assert!(ctx.context_window.is_none());
1250    }
1251
1252    #[test]
1253    fn parses_payload_with_worktree() {
1254        let json = br#"{
1255            "model": { "display_name": "X" },
1256            "workspace": {
1257                "project_dir": "/repo",
1258                "git_worktree": { "name": "main", "path": "/wt/main" }
1259            }
1260        }"#;
1261        let ctx = parse(json).expect("parse ok");
1262        let wt = ctx
1263            .workspace
1264            .expect("workspace")
1265            .git_worktree
1266            .expect("worktree");
1267        assert_eq!(wt.name, "main");
1268        assert_eq!(wt.path, PathBuf::from("/wt/main"));
1269    }
1270
1271    #[test]
1272    fn git_worktree_absent_key_treated_as_none() {
1273        let json = br#"{
1274            "model": { "display_name": "X" },
1275            "workspace": { "project_dir": "/repo" }
1276        }"#;
1277        let ctx = parse(json).expect("parse ok");
1278        assert!(ctx.workspace.expect("workspace").git_worktree.is_none());
1279    }
1280
1281    #[test]
1282    fn parses_context_window() {
1283        let json = br#"{
1284            "model": { "display_name": "X" },
1285            "workspace": { "project_dir": "/repo" },
1286            "context_window": {
1287                "used_percentage": 42.5,
1288                "remaining_percentage": 57.5,
1289                "context_window_size": 200000,
1290                "total_input_tokens": 12345,
1291                "total_output_tokens": 6789
1292            }
1293        }"#;
1294        let ctx = parse(json).expect("parse ok");
1295        let cw = ctx.context_window.expect("context_window");
1296        assert_eq!(cw.used.expect("used").value(), 42.5);
1297        assert_eq!(cw.remaining().expect("remaining").value(), 57.5);
1298        assert_eq!(cw.size, Some(200_000));
1299        assert_eq!(cw.total_input_tokens, Some(12_345));
1300        assert_eq!(cw.total_output_tokens, Some(6_789));
1301    }
1302
1303    #[test]
1304    fn used_percentage_above_100_clamps_instead_of_rejecting() {
1305        let json = br#"{
1306            "model": { "display_name": "X" },
1307            "workspace": { "project_dir": "/repo" },
1308            "context_window": {
1309                "used_percentage": 150,
1310                "context_window_size": 200000,
1311                "total_input_tokens": 0,
1312                "total_output_tokens": 0
1313            }
1314        }"#;
1315        let ctx = parse(json).expect("clamp succeeds");
1316        let cw = ctx.context_window.expect("context_window present");
1317        assert_eq!(cw.used.expect("used").value(), 100.0);
1318    }
1319
1320    #[test]
1321    fn used_percentage_fractional_overshoot_clamps_to_100() {
1322        // Catch a regression that routes the raw f64 through `as i64`
1323        // or `.floor()` before clamping.
1324        let json = br#"{
1325            "model": { "display_name": "X" },
1326            "workspace": { "project_dir": "/repo" },
1327            "context_window": {
1328                "used_percentage": 101.7,
1329                "context_window_size": 200000,
1330                "total_input_tokens": 0,
1331                "total_output_tokens": 0
1332            }
1333        }"#;
1334        let ctx = parse(json).expect("clamp succeeds");
1335        let cw = ctx.context_window.expect("context_window present");
1336        assert_eq!(cw.used.expect("used").value(), 100.0);
1337    }
1338
1339    #[test]
1340    fn used_percentage_below_0_rejects_as_invalid_value() {
1341        // Negative percentages aren't a documented Claude Code state —
1342        // treat them as a corrupted payload and surface InvalidValue
1343        // so the failure is loud, instead of silently clamping to 0%.
1344        // The above-100 case is different (known upstream bug in
1345        // claude-code#37163) and clamps via the companion test.
1346        let json = br#"{
1347            "model": { "display_name": "X" },
1348            "workspace": { "project_dir": "/repo" },
1349            "context_window": {
1350                "used_percentage": -5.0,
1351                "context_window_size": 200000,
1352                "total_input_tokens": 0,
1353                "total_output_tokens": 0
1354            }
1355        }"#;
1356        match parse(json).expect_err("should reject") {
1357            ParseError::InvalidValue { path, .. } => {
1358                assert_eq!(path, "context_window.used_percentage");
1359            }
1360            other => panic!("expected InvalidValue, got {other:?}"),
1361        }
1362    }
1363
1364    #[test]
1365    fn used_percentage_in_range_passes_through_unchanged() {
1366        // Clamp must not distort values that were already in range.
1367        let json = br#"{
1368            "model": { "display_name": "X" },
1369            "workspace": { "project_dir": "/repo" },
1370            "context_window": {
1371                "used_percentage": 42.5,
1372                "context_window_size": 200000,
1373                "total_input_tokens": 0,
1374                "total_output_tokens": 0
1375            }
1376        }"#;
1377        let ctx = parse(json).expect("in-range succeeds");
1378        let cw = ctx.context_window.expect("context_window present");
1379        assert_eq!(cw.used.expect("used").value(), 42.5);
1380    }
1381
1382    #[test]
1383    fn missing_used_percentage_degrades_leaf_to_none() {
1384        // ADR-0014: missing leaf no longer aborts the parse; only the
1385        // affected leaf goes None and peers like `size` survive.
1386        let json = br#"{
1387            "model": { "display_name": "X" },
1388            "workspace": { "project_dir": "/repo" },
1389            "context_window": {
1390                "context_window_size": 200000,
1391                "total_input_tokens": 0,
1392                "total_output_tokens": 0
1393            }
1394        }"#;
1395        let ctx = parse(json).expect("missing leaf must not fail the whole parse");
1396        let cw = ctx.context_window.expect("context_window present");
1397        assert!(cw.used.is_none());
1398        assert_eq!(cw.size, Some(200_000));
1399    }
1400
1401    #[test]
1402    fn wrong_type_used_percentage_degrades_leaf_to_none() {
1403        // ADR-0014: type drift on a leaf no longer aborts the parse.
1404        let json = br#"{
1405            "model": { "display_name": "X" },
1406            "workspace": { "project_dir": "/repo" },
1407            "context_window": {
1408                "used_percentage": "42",
1409                "context_window_size": 200000,
1410                "total_input_tokens": 0,
1411                "total_output_tokens": 0
1412            }
1413        }"#;
1414        let ctx = parse(json).expect("type-drift leaf must not fail the whole parse");
1415        let cw = ctx.context_window.expect("context_window present");
1416        assert!(cw.used.is_none());
1417        assert_eq!(cw.size, Some(200_000));
1418    }
1419
1420    #[test]
1421    fn pre_first_api_call_payload_renders_other_segments() {
1422        // Real captured CC 2.1.119 payload from the ~15s
1423        // pre-first-API-call window where `used_percentage` is null.
1424        // Asserts structurally (not value-pinned) so re-capturing the
1425        // fixture from a different account/session doesn't break the
1426        // test for reasons unrelated to the contract under check.
1427        let bytes = include_bytes!("../tests/fixtures/claude_pre_first_api_call.json");
1428        let ctx = parse(bytes).expect("parse must succeed despite null context_window leaves");
1429        assert!(
1430            !ctx.model.expect("model").display_name.is_empty(),
1431            "model must parse"
1432        );
1433        assert!(
1434            !ctx.workspace
1435                .expect("workspace")
1436                .project_dir
1437                .as_os_str()
1438                .is_empty(),
1439            "workspace must parse"
1440        );
1441        // Per ADR-0014, leaves degrade individually: `used` is null
1442        // pre-first-call but `size` and `total_*_tokens` survive.
1443        // Segments with a hard dependency on `used` (the textual
1444        // `context_window` segment) hide; segments that only need
1445        // peer leaves keep rendering.
1446        let cw = ctx
1447            .context_window
1448            .expect("context_window present with partial leaves");
1449        assert!(cw.used.is_none(), "used_percentage was null in payload");
1450        assert!(cw.size.is_some(), "size populated in payload");
1451        assert!(ctx.cost.is_some(), "cost segment must still render");
1452        assert_eq!(ctx.effort, Some(EffortLevel::XHigh));
1453    }
1454
1455    #[test]
1456    fn null_used_percentage_degrades_leaf_only() {
1457        // ADR-0014: peers (`size`, `total_*_tokens`) survive a null
1458        // `used_percentage`, including the documented pre-first-API-
1459        // call shape. The textual segment hides; tokens / current_usage
1460        // peers remain consumable.
1461        let json = br#"{
1462            "model": { "display_name": "X" },
1463            "workspace": { "project_dir": "/repo" },
1464            "context_window": {
1465                "used_percentage": null,
1466                "context_window_size": 200000,
1467                "total_input_tokens": 0,
1468                "total_output_tokens": 0
1469            }
1470        }"#;
1471        let ctx = parse(json).expect("null used_percentage must not fail the whole parse");
1472        let cw = ctx.context_window.expect("context_window present");
1473        assert!(cw.used.is_none());
1474        assert_eq!(cw.size, Some(200_000));
1475        assert_eq!(cw.total_input_tokens, Some(0));
1476    }
1477
1478    #[test]
1479    fn null_context_window_size_degrades_leaf_only() {
1480        let json = br#"{
1481            "model": { "display_name": "X" },
1482            "workspace": { "project_dir": "/repo" },
1483            "context_window": {
1484                "used_percentage": 12.5,
1485                "context_window_size": null,
1486                "total_input_tokens": 0,
1487                "total_output_tokens": 0
1488            }
1489        }"#;
1490        let ctx = parse(json).expect("null size must not fail the whole parse");
1491        let cw = ctx.context_window.expect("context_window present");
1492        assert!(cw.size.is_none());
1493        assert!(cw.used.is_some(), "used survives even when size is null");
1494    }
1495
1496    #[test]
1497    fn null_total_input_tokens_degrades_leaf_only() {
1498        let json = br#"{
1499            "model": { "display_name": "X" },
1500            "workspace": { "project_dir": "/repo" },
1501            "context_window": {
1502                "used_percentage": 12.5,
1503                "context_window_size": 200000,
1504                "total_input_tokens": null,
1505                "total_output_tokens": 0
1506            }
1507        }"#;
1508        let ctx = parse(json).expect("null total_input_tokens must not fail the whole parse");
1509        let cw = ctx.context_window.expect("context_window present");
1510        assert!(cw.total_input_tokens.is_none());
1511        assert_eq!(
1512            cw.total_output_tokens,
1513            Some(0),
1514            "peer leaves survive isolated null"
1515        );
1516    }
1517
1518    #[test]
1519    fn null_total_output_tokens_degrades_leaf_only() {
1520        let json = br#"{
1521            "model": { "display_name": "X" },
1522            "workspace": { "project_dir": "/repo" },
1523            "context_window": {
1524                "used_percentage": 12.5,
1525                "context_window_size": 200000,
1526                "total_input_tokens": 0,
1527                "total_output_tokens": null
1528            }
1529        }"#;
1530        let ctx = parse(json).expect("null total_output_tokens must not fail the whole parse");
1531        let cw = ctx.context_window.expect("context_window present");
1532        assert!(cw.total_output_tokens.is_none());
1533        assert_eq!(cw.total_input_tokens, Some(0));
1534    }
1535
1536    #[test]
1537    fn current_usage_survives_when_peer_leaf_is_null() {
1538        // ADR-0014: per-leaf isolation — current_usage must not be
1539        // dropped just because a sibling totals counter is null.
1540        let json = br#"{
1541            "model": { "display_name": "X" },
1542            "workspace": { "project_dir": "/repo" },
1543            "context_window": {
1544                "used_percentage": 50.0,
1545                "context_window_size": 200000,
1546                "total_input_tokens": 0,
1547                "total_output_tokens": null,
1548                "current_usage": {
1549                    "input_tokens": 100,
1550                    "output_tokens": 50,
1551                    "cache_creation_input_tokens": 0,
1552                    "cache_read_input_tokens": 0
1553                }
1554            }
1555        }"#;
1556        let ctx = parse(json).expect("partial null must not drop current_usage");
1557        let cw = ctx.context_window.expect("context_window present");
1558        assert!(cw.total_output_tokens.is_none());
1559        let usage = cw.current_usage.expect("current_usage preserved");
1560        assert_eq!(usage.input_tokens, 100);
1561    }
1562
1563    #[test]
1564    fn context_window_size_above_u32_max_degrades_leaf_only() {
1565        // ADR-0014 narrows `size` to `u32`. A future hypothetical
1566        // CC payload exceeding u32::MAX must degrade the leaf to
1567        // None (not wrap via `as u32`) and let peers survive.
1568        let json = br#"{
1569            "model": { "display_name": "X" },
1570            "workspace": { "project_dir": "/repo" },
1571            "context_window": {
1572                "used_percentage": 12.5,
1573                "context_window_size": 4294967296,
1574                "total_input_tokens": 0,
1575                "total_output_tokens": 0
1576            }
1577        }"#;
1578        let ctx = parse(json).expect("u32 overflow must not fail the whole parse");
1579        let cw = ctx.context_window.expect("context_window present");
1580        assert!(cw.size.is_none(), "size leaf degraded on overflow");
1581        assert!(cw.used.is_some(), "peer leaf survives");
1582    }
1583
1584    #[test]
1585    fn context_window_explicit_null_treated_as_none() {
1586        let json = br#"{
1587            "model": { "display_name": "X" },
1588            "workspace": { "project_dir": "/repo" },
1589            "context_window": null
1590        }"#;
1591        let ctx = parse(json).expect("parse ok");
1592        assert!(ctx.context_window.is_none());
1593    }
1594
1595    #[test]
1596    fn current_usage_absent_is_none() {
1597        // Schema doesn't guarantee the key is present inside a
1598        // context_window object; treat missing the same as explicit
1599        // `null` so a future schema variation parses cleanly.
1600        let json = br#"{
1601            "model": { "display_name": "X" },
1602            "workspace": { "project_dir": "/repo" },
1603            "context_window": {
1604                "used_percentage": 42.5,
1605                "context_window_size": 200000,
1606                "total_input_tokens": 0,
1607                "total_output_tokens": 0
1608            }
1609        }"#;
1610        let ctx = parse(json).expect("parse ok");
1611        let cw = ctx.context_window.expect("context_window present");
1612        assert!(cw.current_usage.is_none());
1613    }
1614
1615    #[test]
1616    fn current_usage_null_is_none() {
1617        // Claude Code emits `current_usage: null` before the first API
1618        // call in a session; round-trip to Option::None.
1619        let json = br#"{
1620            "model": { "display_name": "X" },
1621            "workspace": { "project_dir": "/repo" },
1622            "context_window": {
1623                "used_percentage": 0,
1624                "context_window_size": 200000,
1625                "total_input_tokens": 0,
1626                "total_output_tokens": 0,
1627                "current_usage": null
1628            }
1629        }"#;
1630        let ctx = parse(json).expect("parse ok");
1631        let cw = ctx.context_window.expect("context_window present");
1632        assert!(cw.current_usage.is_none());
1633    }
1634
1635    #[test]
1636    fn current_usage_present_parses_all_four_fields() {
1637        let json = br#"{
1638            "model": { "display_name": "X" },
1639            "workspace": { "project_dir": "/repo" },
1640            "context_window": {
1641                "used_percentage": 12.4,
1642                "context_window_size": 200000,
1643                "total_input_tokens": 24800,
1644                "total_output_tokens": 3200,
1645                "current_usage": {
1646                    "input_tokens": 2000,
1647                    "output_tokens": 500,
1648                    "cache_creation_input_tokens": 0,
1649                    "cache_read_input_tokens": 500
1650                }
1651            }
1652        }"#;
1653        let ctx = parse(json).expect("parse ok");
1654        let cw = ctx.context_window.expect("context_window present");
1655        let usage = cw.current_usage.expect("current_usage present");
1656        assert_eq!(usage.input_tokens, 2000);
1657        assert_eq!(usage.output_tokens, 500);
1658        assert_eq!(usage.cache_creation_input_tokens, 0);
1659        assert_eq!(usage.cache_read_input_tokens, 500);
1660    }
1661
1662    #[test]
1663    fn current_usage_non_object_degrades_to_none() {
1664        // ADR-0014: a non-object current_usage no longer aborts the
1665        // whole parse; the leaf goes None and peers survive.
1666        let json = br#"{
1667            "model": { "display_name": "X" },
1668            "workspace": { "project_dir": "/repo" },
1669            "context_window": {
1670                "used_percentage": 0,
1671                "context_window_size": 200000,
1672                "total_input_tokens": 0,
1673                "total_output_tokens": 0,
1674                "current_usage": "not an object"
1675            }
1676        }"#;
1677        let ctx = parse(json).expect("non-object current_usage must not fail the whole parse");
1678        let cw = ctx.context_window.expect("context_window present");
1679        assert!(cw.current_usage.is_none());
1680        assert_eq!(cw.size, Some(200_000));
1681    }
1682
1683    #[test]
1684    fn current_usage_missing_inner_field_degrades_to_none() {
1685        let json = br#"{
1686            "model": { "display_name": "X" },
1687            "workspace": { "project_dir": "/repo" },
1688            "context_window": {
1689                "used_percentage": 0,
1690                "context_window_size": 200000,
1691                "total_input_tokens": 0,
1692                "total_output_tokens": 0,
1693                "current_usage": {
1694                    "input_tokens": 100,
1695                    "output_tokens": 50
1696                }
1697            }
1698        }"#;
1699        let ctx = parse(json).expect("partial current_usage must not fail the whole parse");
1700        let cw = ctx.context_window.expect("context_window present");
1701        assert!(cw.current_usage.is_none());
1702    }
1703
1704    #[test]
1705    fn current_usage_inner_wrong_type_degrades_to_none() {
1706        let json = br#"{
1707            "model": { "display_name": "X" },
1708            "workspace": { "project_dir": "/repo" },
1709            "context_window": {
1710                "used_percentage": 0,
1711                "context_window_size": 200000,
1712                "total_input_tokens": 0,
1713                "total_output_tokens": 0,
1714                "current_usage": {
1715                    "input_tokens": "200",
1716                    "output_tokens": 50,
1717                    "cache_creation_input_tokens": 0,
1718                    "cache_read_input_tokens": 0
1719                }
1720            }
1721        }"#;
1722        let ctx = parse(json).expect("type-drift inner field must not fail the whole parse");
1723        let cw = ctx.context_window.expect("context_window present");
1724        assert!(cw.current_usage.is_none());
1725    }
1726
1727    #[test]
1728    fn current_usage_inner_null_collapses_whole_turn_usage() {
1729        // Pin TurnUsage's all-or-nothing semantics: a single null
1730        // leaf collapses the whole `current_usage` to None (per
1731        // parse_current_usage's let-else cascade), but peers
1732        // outside `current_usage` (size, used) survive.
1733        let json = br#"{
1734            "model": { "display_name": "X" },
1735            "workspace": { "project_dir": "/repo" },
1736            "context_window": {
1737                "used_percentage": 50.0,
1738                "context_window_size": 200000,
1739                "total_input_tokens": 0,
1740                "total_output_tokens": 0,
1741                "current_usage": {
1742                    "input_tokens": null,
1743                    "output_tokens": 50,
1744                    "cache_creation_input_tokens": 0,
1745                    "cache_read_input_tokens": 0
1746                }
1747            }
1748        }"#;
1749        let ctx = parse(json).expect("partial null inside current_usage must not fail parse");
1750        let cw = ctx.context_window.expect("context_window present");
1751        assert!(cw.current_usage.is_none());
1752        assert_eq!(cw.size, Some(200_000));
1753        assert!(cw.used.is_some());
1754    }
1755
1756    #[test]
1757    fn current_usage_inner_missing_collapses_whole_turn_usage() {
1758        // Symmetric to the null-leaf test, but pinned at the call
1759        // site (parse_current_usage's let-else cascade), not at the
1760        // helper. `try_u64_optional` collapses missing and null into
1761        // the same silent `None` branch today; if a future refactor
1762        // splits them (e.g. logging a warn on missing), this test
1763        // catches the call-site behavior change without forcing the
1764        // helper's internal control flow into the test surface.
1765        let json = br#"{
1766            "model": { "display_name": "X" },
1767            "workspace": { "project_dir": "/repo" },
1768            "context_window": {
1769                "used_percentage": 50.0,
1770                "context_window_size": 200000,
1771                "total_input_tokens": 0,
1772                "total_output_tokens": 0,
1773                "current_usage": {
1774                    "output_tokens": 50,
1775                    "cache_creation_input_tokens": 0,
1776                    "cache_read_input_tokens": 0
1777                }
1778            }
1779        }"#;
1780        let ctx = parse(json).expect("missing leaf inside current_usage must not fail parse");
1781        let cw = ctx.context_window.expect("context_window present");
1782        assert!(cw.current_usage.is_none());
1783        assert_eq!(cw.size, Some(200_000));
1784    }
1785
1786    #[test]
1787    fn cost_total_cost_usd_accepts_zero_and_tiny_positive() {
1788        // Defends try_f64_required's success path against a future
1789        // over-tightening like `n != 0.0` or `n > 0.0`. Zero is valid
1790        // (no API calls yet); a tiny non-zero positive must also
1791        // round-trip. serde_json rejects literals at f64::MAX as
1792        // "number out of range" — see `out_of_range_number_rejected_
1793        // at_json_layer` for the upper bound.
1794        for &val in &[0.0_f64, 1e-300_f64] {
1795            let bytes = format!(
1796                r#"{{"model":{{"display_name":"X"}},"workspace":{{"project_dir":"/r"}},
1797                     "cost":{{"total_cost_usd":{val},"total_duration_ms":0,"total_api_duration_ms":0,
1798                              "total_lines_added":0,"total_lines_removed":0}}}}"#
1799            );
1800            let ctx = parse(bytes.as_bytes()).expect("finite f64 must round-trip");
1801            let cost = ctx.cost.expect("cost present");
1802            assert_eq!(cost.total_cost_usd, Some(val));
1803        }
1804    }
1805
1806    #[test]
1807    fn wrong_type_git_worktree_degrades_to_none() {
1808        // ADR-0014: malformed git_worktree no longer aborts the whole
1809        // parse; the worktree goes None and the project_dir survives.
1810        let json = br#"{
1811            "model": { "display_name": "X" },
1812            "workspace": {
1813                "project_dir": "/repo",
1814                "git_worktree": "main"
1815            }
1816        }"#;
1817        let ctx = parse(json).expect("malformed worktree must not fail the whole parse");
1818        let workspace = ctx.workspace.expect("workspace present");
1819        assert!(workspace.git_worktree.is_none());
1820        assert_eq!(workspace.project_dir.to_str(), Some("/repo"));
1821    }
1822
1823    #[test]
1824    fn missing_model_degrades_to_none() {
1825        // ADR-0014: a payload without `model` no longer aborts the
1826        // whole parse; the field goes None and segments hide.
1827        let json = br#"{
1828            "workspace": { "project_dir": "/repo" }
1829        }"#;
1830        let ctx = parse(json).expect("missing model must not fail the whole parse");
1831        assert!(ctx.model.is_none());
1832        assert!(ctx.workspace.is_some());
1833    }
1834
1835    #[test]
1836    fn rejects_malformed_json() {
1837        assert!(matches!(
1838            parse(b"{not json"),
1839            Err(ParseError::InvalidJson { .. })
1840        ));
1841    }
1842
1843    #[test]
1844    fn empty_object_payload_returns_all_none_top_level() {
1845        // ADR-0014 confirmation: parse(b"{}") must succeed with every
1846        // top-level Option field None, only `tool` and `raw` populated.
1847        // This is the post-isolation contract — no field is required
1848        // for parse to succeed on a syntactically valid object root.
1849        let ctx = parse(b"{}").expect("empty object must parse");
1850        assert_eq!(ctx.tool, Tool::ClaudeCode);
1851        assert!(ctx.model.is_none());
1852        assert!(ctx.workspace.is_none());
1853        assert!(ctx.context_window.is_none());
1854        assert!(ctx.cost.is_none());
1855        assert_eq!(ctx.effort, None);
1856        assert_eq!(ctx.vim, None);
1857        assert!(ctx.output_style.is_none());
1858        assert!(ctx.agent_name.is_none());
1859        assert!(ctx.version.is_none());
1860        // raw round-trips the empty object so plugins can still query
1861        // ctx.status.raw paths without a None guard.
1862        assert!(ctx.raw.is_object());
1863    }
1864
1865    #[test]
1866    fn malformed_json_carries_exact_source_position() {
1867        // The `}` at line 2, column 10 is where serde_json bails.
1868        let ParseError::InvalidJson { location, .. } = parse(b"{\n  \"bad\": }").unwrap_err()
1869        else {
1870            panic!("expected InvalidJson");
1871        };
1872        let pos = location.expect("position populated for positional errors");
1873        assert_eq!(pos.line, 2);
1874        assert_eq!(pos.column, 10);
1875    }
1876
1877    #[test]
1878    fn json_type_of_maps_each_variant() {
1879        use serde_json::Value;
1880        assert_eq!(
1881            JsonType::of(&Value::Object(Default::default())),
1882            JsonType::Object
1883        );
1884        assert_eq!(JsonType::of(&Value::Array(vec![])), JsonType::Array);
1885        assert_eq!(JsonType::of(&Value::String("x".into())), JsonType::String);
1886        assert_eq!(JsonType::of(&Value::from(42)), JsonType::Number);
1887        assert_eq!(JsonType::of(&Value::Bool(true)), JsonType::Bool);
1888        assert_eq!(JsonType::of(&Value::Null), JsonType::Null);
1889    }
1890
1891    #[test]
1892    fn parse_error_display_formats_root_path_readably() {
1893        // When the root JSON is not an object, the path is empty.
1894        let err = parse(b"[]").expect_err("array at root rejected");
1895        let display = err.to_string();
1896        assert!(display.contains("<root>"), "got {display:?}");
1897    }
1898
1899    // --- cost error paths ---
1900
1901    #[test]
1902    fn cost_absent_treated_as_none() {
1903        let bytes = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"}}"#;
1904        assert!(parse(bytes).expect("ok").cost.is_none());
1905    }
1906
1907    #[test]
1908    fn cost_explicit_null_treated_as_none() {
1909        let bytes =
1910            br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"cost":null}"#;
1911        assert!(parse(bytes).expect("ok").cost.is_none());
1912    }
1913
1914    #[test]
1915    fn cost_wrong_type_degrades_to_none() {
1916        // ADR-0014: a non-object cost wrapper no longer aborts the
1917        // whole parse; the field goes None and peers survive.
1918        let bytes =
1919            br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"cost":"nope"}"#;
1920        let ctx = parse(bytes).expect("non-object cost must not fail the whole parse");
1921        assert!(ctx.cost.is_none());
1922        assert!(ctx.model.is_some());
1923    }
1924
1925    #[test]
1926    fn cost_missing_sub_field_degrades_leaf_only() {
1927        // ADR-0014: missing cost leaf no longer aborts the parse;
1928        // the leaf goes None, peers populate from the payload.
1929        let bytes = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},
1930            "cost":{"total_cost_usd":1.0,"total_duration_ms":0,"total_api_duration_ms":0,"total_lines_added":0}}"#;
1931        let ctx = parse(bytes).expect("missing leaf must not fail the whole parse");
1932        let cost = ctx.cost.expect("cost present");
1933        assert!(cost.total_lines_removed.is_none());
1934        assert_eq!(cost.total_cost_usd, Some(1.0));
1935        assert_eq!(cost.total_lines_added, Some(0));
1936    }
1937
1938    #[test]
1939    fn cost_total_cost_usd_non_numeric_degrades_leaf_only() {
1940        let bytes = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},
1941            "cost":{"total_cost_usd":"oops","total_duration_ms":0,"total_api_duration_ms":0,
1942                     "total_lines_added":0,"total_lines_removed":0}}"#;
1943        let ctx = parse(bytes).expect("type-drift leaf must not fail the whole parse");
1944        let cost = ctx.cost.expect("cost present");
1945        assert!(cost.total_cost_usd.is_none());
1946        assert_eq!(cost.total_duration_ms, Some(0));
1947    }
1948
1949    #[test]
1950    fn cost_wrapper_with_all_leaves_drift_collapses_to_none() {
1951        // Plugin contract regression guard: ctx.status.cost != () must
1952        // always imply at least one readable leaf. A wrapper whose
1953        // every leaf is malformed collapses to None rather than
1954        // surfacing an all-`()` cost map to rhai consumers.
1955        let bytes = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},
1956            "cost":{"total_cost_usd":"a","total_duration_ms":"b","total_api_duration_ms":"c",
1957                     "total_lines_added":"d","total_lines_removed":"e"}}"#;
1958        let ctx = parse(bytes).expect("all-leaves-drift cost must not fail the whole parse");
1959        assert!(ctx.cost.is_none());
1960    }
1961
1962    #[test]
1963    fn context_window_wrapper_with_all_leaves_drift_collapses_to_none() {
1964        // Same plugin contract for context_window.
1965        let bytes = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},
1966            "context_window":{"used_percentage":"a","context_window_size":"b",
1967                               "total_input_tokens":"c","total_output_tokens":"d"}}"#;
1968        let ctx = parse(bytes).expect("all-leaves-drift context_window must not fail the parse");
1969        assert!(ctx.context_window.is_none());
1970    }
1971
1972    #[test]
1973    fn out_of_range_number_rejected_at_json_layer() {
1974        // Pins the JSON-syntax barrier: `1e500` overflows f64 and
1975        // serde_json rejects it as InvalidJson before our normalizer
1976        // sees it. Documents why try_f64_required has no `is_finite()`
1977        // guard — that branch would be unreachable through parse().
1978        let bytes = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},
1979            "cost":{"total_cost_usd":1e500,"total_duration_ms":0,"total_api_duration_ms":0,
1980                     "total_lines_added":0,"total_lines_removed":0}}"#;
1981        match parse(bytes).expect_err("serde_json rejects out-of-range numbers") {
1982            ParseError::InvalidJson { .. } => {}
1983            other => panic!("expected InvalidJson, got {other:?}"),
1984        }
1985    }
1986
1987    #[test]
1988    fn cost_lines_added_accepts_large_value_without_truncation() {
1989        // Regression guard for slice-3 review fix: fields were previously
1990        // narrowed to u32, silently truncating at 4.29B.
1991        let bytes = format!(
1992            r#"{{"model":{{"display_name":"X"}},"workspace":{{"project_dir":"/r"}},
1993               "cost":{{"total_cost_usd":0.0,"total_duration_ms":0,"total_api_duration_ms":0,
1994                        "total_lines_added":{n},"total_lines_removed":0}}}}"#,
1995            n = 5_000_000_000u64
1996        );
1997        let ctx = parse(bytes.as_bytes()).expect("parse ok");
1998        assert_eq!(
1999            ctx.cost.expect("cost").total_lines_added,
2000            Some(5_000_000_000u64)
2001        );
2002    }
2003
2004    // --- effort error paths ---
2005
2006    #[test]
2007    fn effort_object_form_parses() {
2008        // Canonical shape as of Claude Code 2.1.x.
2009        let bytes = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"effort":{"level":"xhigh"}}"#;
2010        let ctx = parse(bytes).expect("parse ok");
2011        assert_eq!(ctx.effort, Some(EffortLevel::XHigh));
2012    }
2013
2014    #[test]
2015    fn effort_bare_string_still_parses() {
2016        // Back-compat for tools that emit a bare-string form.
2017        let bytes =
2018            br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"effort":"high"}"#;
2019        let ctx = parse(bytes).expect("parse ok");
2020        assert_eq!(ctx.effort, Some(EffortLevel::High));
2021    }
2022
2023    #[test]
2024    fn effort_object_missing_level_degrades_to_none() {
2025        let bytes =
2026            br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"effort":{}}"#;
2027        let ctx = parse(bytes).expect("missing effort.level must not fail the whole parse");
2028        assert_eq!(ctx.effort, None);
2029        assert!(ctx.model.is_some());
2030    }
2031
2032    #[test]
2033    fn effort_object_non_string_level_degrades_to_none() {
2034        let bytes = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"effort":{"level":42}}"#;
2035        let ctx = parse(bytes).expect("type-drift effort.level must not fail the whole parse");
2036        assert_eq!(ctx.effort, None);
2037    }
2038
2039    #[test]
2040    fn effort_object_null_level_maps_to_none() {
2041        let bytes = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"effort":{"level":null}}"#;
2042        let ctx = parse(bytes).expect("parse ok");
2043        assert_eq!(ctx.effort, None);
2044    }
2045
2046    #[test]
2047    fn effort_top_level_null_maps_to_none() {
2048        // Locks the outer early-return in parse_effort. A refactor that
2049        // dropped the explicit is_null() check and delegated to the
2050        // object/string match would regress this into a TypeMismatch.
2051        let bytes =
2052            br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"effort":null}"#;
2053        let ctx = parse(bytes).expect("parse ok");
2054        assert_eq!(ctx.effort, None);
2055    }
2056
2057    #[test]
2058    fn effort_non_object_non_string_degrades_to_none() {
2059        let bytes =
2060            br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"effort":42}"#;
2061        let ctx = parse(bytes).expect("non-object/non-string effort must not fail the whole parse");
2062        assert_eq!(ctx.effort, None);
2063    }
2064
2065    #[test]
2066    fn effort_object_unknown_level_degrades_to_none() {
2067        // ADR-0014: unknown effort variant warns + degrades, symmetric
2068        // with parse_vim. A future CC level shouldn't blank the line.
2069        let bytes = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"effort":{"level":"ultra"}}"#;
2070        let ctx = parse(bytes).expect("unknown effort variant must not fail the whole parse");
2071        assert_eq!(ctx.effort, None);
2072    }
2073
2074    #[test]
2075    fn effort_unknown_string_degrades_to_none() {
2076        let bytes =
2077            br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"effort":"ultra"}"#;
2078        let ctx = parse(bytes).expect("unknown string effort must not fail the whole parse");
2079        assert_eq!(ctx.effort, None);
2080    }
2081
2082    // --- vim / output_style / agent ---
2083
2084    #[test]
2085    fn parses_vim_object_form() {
2086        let bytes = br#"{
2087            "model": { "display_name": "X" },
2088            "workspace": { "project_dir": "/r" },
2089            "vim": { "mode": "insert" }
2090        }"#;
2091        let ctx = parse(bytes).expect("ok");
2092        assert_eq!(ctx.vim, Some(VimMode::Insert));
2093    }
2094
2095    #[test]
2096    fn parses_vim_string_form_for_compat() {
2097        let bytes = br#"{
2098            "model": { "display_name": "X" },
2099            "workspace": { "project_dir": "/r" },
2100            "vim": "visual"
2101        }"#;
2102        let ctx = parse(bytes).expect("ok");
2103        assert_eq!(ctx.vim, Some(VimMode::Visual));
2104    }
2105
2106    #[test]
2107    fn vim_absent_or_null_yields_none() {
2108        let absent = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"}}"#;
2109        assert_eq!(parse(absent).unwrap().vim, None);
2110        let null = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"vim":null}"#;
2111        assert_eq!(parse(null).unwrap().vim, None);
2112        let null_mode = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"vim":{"mode":null}}"#;
2113        assert_eq!(parse(null_mode).unwrap().vim, None);
2114    }
2115
2116    #[test]
2117    fn vim_unknown_mode_degrades_segment_not_whole_parse() {
2118        // An unknown vim mode (e.g. a future CC `select` or `terminal`)
2119        // must NOT abort the whole parse — the rest of the statusline
2120        // would render blank for an opt-in informational segment. Warn
2121        // and degrade to None instead. Lock the contract so a refactor
2122        // that re-introduces `InvalidValue` here regresses loudly.
2123        let bytes = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"vim":{"mode":"surrogate"}}"#;
2124        let ctx = parse(bytes).expect("unknown vim mode must not fail parse");
2125        assert_eq!(ctx.vim, None);
2126    }
2127
2128    #[test]
2129    fn parses_output_style() {
2130        let bytes = br#"{
2131            "model": { "display_name": "X" },
2132            "workspace": { "project_dir": "/r" },
2133            "output_style": { "name": "concise" }
2134        }"#;
2135        let ctx = parse(bytes).expect("ok");
2136        let style = ctx.output_style.expect("present");
2137        assert_eq!(style.name, "concise");
2138    }
2139
2140    #[test]
2141    fn output_style_absent_or_null_yields_none() {
2142        let absent = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"}}"#;
2143        assert!(parse(absent).unwrap().output_style.is_none());
2144        let null = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"output_style":null}"#;
2145        assert!(parse(null).unwrap().output_style.is_none());
2146        let null_name = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"output_style":{"name":null}}"#;
2147        assert!(parse(null_name).unwrap().output_style.is_none());
2148        // Object without `name` is tolerated as None so the schema can grow.
2149        let no_name =
2150            br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"output_style":{}}"#;
2151        assert!(parse(no_name).unwrap().output_style.is_none());
2152        let empty = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"output_style":{"name":""}}"#;
2153        assert!(parse(empty).unwrap().output_style.is_none());
2154    }
2155
2156    #[test]
2157    fn output_style_name_typed_wrong_degrades_to_none() {
2158        let bytes = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"output_style":{"name":42}}"#;
2159        let ctx = parse(bytes).expect("type-drift output_style.name must not fail the whole parse");
2160        assert_eq!(ctx.output_style, None);
2161    }
2162
2163    #[test]
2164    fn parses_agent_name() {
2165        let bytes = br#"{
2166            "model": { "display_name": "X" },
2167            "workspace": { "project_dir": "/r" },
2168            "agent": { "name": "research" }
2169        }"#;
2170        let ctx = parse(bytes).expect("ok");
2171        assert_eq!(ctx.agent_name.as_deref(), Some("research"));
2172    }
2173
2174    #[test]
2175    fn agent_absent_null_or_empty_yields_none() {
2176        let absent = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"}}"#;
2177        assert!(parse(absent).unwrap().agent_name.is_none());
2178        let null =
2179            br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"agent":null}"#;
2180        assert!(parse(null).unwrap().agent_name.is_none());
2181        let empty = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"agent":{"name":""}}"#;
2182        assert!(parse(empty).unwrap().agent_name.is_none());
2183        let no_name =
2184            br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"agent":{}}"#;
2185        assert!(parse(no_name).unwrap().agent_name.is_none());
2186    }
2187
2188    #[test]
2189    fn vim_object_missing_mode_degrades_to_none() {
2190        let bytes = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"vim":{}}"#;
2191        let ctx = parse(bytes).expect("missing vim.mode must not fail the whole parse");
2192        assert_eq!(ctx.vim, None);
2193    }
2194
2195    #[test]
2196    fn vim_object_non_string_mode_degrades_to_none() {
2197        let bytes =
2198            br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"vim":{"mode":42}}"#;
2199        let ctx = parse(bytes).expect("type-drift vim.mode must not fail the whole parse");
2200        assert_eq!(ctx.vim, None);
2201    }
2202
2203    #[test]
2204    fn vim_non_object_non_string_degrades_to_none() {
2205        let bytes = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"vim":42}"#;
2206        let ctx = parse(bytes).expect("non-object/non-string vim must not fail the whole parse");
2207        assert_eq!(ctx.vim, None);
2208    }
2209
2210    #[test]
2211    fn output_style_non_object_degrades_to_none() {
2212        let bytes = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"output_style":"concise"}"#;
2213        let ctx = parse(bytes).expect("non-object output_style must not fail the whole parse");
2214        assert_eq!(ctx.output_style, None);
2215    }
2216
2217    #[test]
2218    fn agent_non_object_degrades_to_none() {
2219        let bytes = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"agent":"research"}"#;
2220        let ctx = parse(bytes).expect("non-object agent must not fail the whole parse");
2221        assert!(ctx.agent_name.is_none());
2222    }
2223
2224    #[test]
2225    fn agent_name_typed_wrong_degrades_to_none() {
2226        let bytes = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"agent":{"name":42}}"#;
2227        let ctx = parse(bytes).expect("type-drift agent.name must not fail the whole parse");
2228        assert!(ctx.agent_name.is_none());
2229    }
2230
2231    #[test]
2232    fn parses_top_level_version_string() {
2233        // Pinned against the official CC docs at code.claude.com/docs/en/statusline
2234        // (verified 2026-04-28): `version` is a top-level string field
2235        // on the statusline payload.
2236        let bytes = br#"{
2237            "model": { "display_name": "X" },
2238            "workspace": { "project_dir": "/r" },
2239            "version": "2.1.90"
2240        }"#;
2241        assert_eq!(parse(bytes).unwrap().version.as_deref(), Some("2.1.90"));
2242    }
2243
2244    #[test]
2245    fn version_absent_or_null_or_empty_yields_none() {
2246        let absent = br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"}}"#;
2247        assert!(parse(absent).unwrap().version.is_none());
2248        let null =
2249            br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"version":null}"#;
2250        assert!(parse(null).unwrap().version.is_none());
2251        let empty =
2252            br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"version":""}"#;
2253        assert!(parse(empty).unwrap().version.is_none());
2254        // Whitespace-only is treated like empty: rendering "v   " is
2255        // worse than hiding the segment.
2256        let ws =
2257            br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"version":"   "}"#;
2258        assert!(parse(ws).unwrap().version.is_none());
2259    }
2260
2261    #[test]
2262    fn version_surrounding_whitespace_is_trimmed() {
2263        // Defensive: if CC ever ships padded version strings, the
2264        // segment shouldn't render `v  2.1.90  `.
2265        let bytes = br#"{
2266            "model": { "display_name": "X" },
2267            "workspace": { "project_dir": "/r" },
2268            "version": "  2.1.90  "
2269        }"#;
2270        assert_eq!(parse(bytes).unwrap().version.as_deref(), Some("2.1.90"));
2271    }
2272
2273    #[test]
2274    fn version_typed_wrong_degrades_to_none() {
2275        let bytes =
2276            br#"{"model":{"display_name":"X"},"workspace":{"project_dir":"/r"},"version":42}"#;
2277        let ctx = parse(bytes).expect("type-drift version must not fail the whole parse");
2278        assert!(ctx.version.is_none());
2279    }
2280}