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(super) fn normalize(raw: Arc<serde_json::Value>) -> Result<StatusContext, ParseError> {
460 let root = expect_object(&raw, "")?;
461
462 let model = parse_model(root);
463 let workspace = parse_workspace(root);
464 let context_window = parse_context_window(root)?;
465 let cost = parse_cost(root)?;
466 let effort = parse_effort(root)?;
467 let vim = parse_vim(root)?;
468 let output_style = parse_output_style(root)?;
469 let agent_name = parse_agent_name(root)?;
470 let version = parse_version(root)?;
471
472 Ok(StatusContext {
473 tool: TOOL,
474 model,
475 workspace,
476 context_window,
477 cost,
478 effort,
479 vim,
480 output_style,
481 agent_name,
482 version,
483 raw,
484 })
485 }
486
487 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;