1use std::borrow::Cow;
8use std::path::PathBuf;
9use std::sync::Arc;
10
11#[derive(Debug, Clone)]
18#[non_exhaustive]
19pub struct StatusContext {
20 pub tool: Tool,
21 pub model: Option<ModelInfo>,
24 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 pub agent_name: Option<String>,
38 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 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 pub used: Option<Percent>,
82 pub size: Option<u32>,
85 pub total_input_tokens: Option<u64>,
86 pub total_output_tokens: Option<u64>,
87 pub current_usage: Option<TurnUsage>,
91}
92
93impl ContextWindow {
94 #[must_use]
97 pub fn remaining(&self) -> Option<Percent> {
98 self.used.map(Percent::complement)
99 }
100}
101
102#[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 pub total_cost_usd: Option<f64>,
122 pub total_duration_ms: Option<u64>,
123 pub total_api_duration_ms: Option<u64>,
124 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
219#[non_exhaustive]
220pub struct OutputStyle {
221 pub name: String,
222}
223
224#[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 #[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 #[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 #[must_use]
274 pub fn complement(self) -> Self {
275 Self(100.0 - self.0)
276 }
277}
278
279pub 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 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 MissingField {
325 tool: Tool,
326 path: String,
327 },
328 TypeMismatch {
332 tool: Tool,
333 path: String,
334 expected: JsonType,
335 got: JsonType,
336 },
337 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 pub line: usize,
355 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
443mod 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(
1191 Percent::from_f64_clamped(100.0000001).unwrap().value(),
1192 100.0
1193 );
1194 assert!(Percent::from_f64_clamped(f64::NAN).is_none());
1198 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(ctx.raw.is_object());
1863 }
1864
1865 #[test]
1866 fn malformed_json_carries_exact_source_position() {
1867 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 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 #[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 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 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 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 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 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 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 #[test]
2007 fn effort_object_form_parses() {
2008 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 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 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 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 #[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 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 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 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 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 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}