use std::borrow::Cow;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct StatusContext {
pub tool: Tool,
pub model: Option<ModelInfo>,
pub workspace: Option<WorkspaceInfo>,
pub context_window: Option<ContextWindow>,
pub cost: Option<CostMetrics>,
pub effort: Option<EffortLevel>,
pub vim: Option<VimMode>,
pub output_style: Option<OutputStyle>,
pub agent_name: Option<String>,
pub version: Option<String>,
pub raw: Arc<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Tool {
ClaudeCode,
QwenCode,
CodexCli,
CopilotCli,
Other(Cow<'static, str>),
}
#[derive(Debug, Clone)]
pub struct ModelInfo {
pub display_name: String,
}
#[derive(Debug, Clone)]
pub struct WorkspaceInfo {
pub project_dir: PathBuf,
pub git_worktree: Option<GitWorktree>,
}
#[derive(Debug, Clone)]
pub struct GitWorktree {
pub name: String,
pub path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct ContextWindow {
pub used: Option<Percent>,
pub size: Option<u32>,
pub total_input_tokens: Option<u64>,
pub total_output_tokens: Option<u64>,
pub current_usage: Option<TurnUsage>,
}
impl ContextWindow {
#[must_use]
pub fn remaining(&self) -> Option<Percent> {
self.used.map(Percent::complement)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct TurnUsage {
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_creation_input_tokens: u64,
pub cache_read_input_tokens: u64,
}
#[derive(Debug, Clone, Copy)]
#[non_exhaustive]
pub struct CostMetrics {
pub total_cost_usd: Option<f64>,
pub total_duration_ms: Option<u64>,
pub total_api_duration_ms: Option<u64>,
pub total_lines_added: Option<u64>,
pub total_lines_removed: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EffortLevel {
Low,
Medium,
High,
Max,
XHigh,
}
impl EffortLevel {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Max => "max",
Self::XHigh => "xhigh",
}
}
}
impl std::str::FromStr for EffortLevel {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"low" => Ok(Self::Low),
"medium" => Ok(Self::Medium),
"high" => Ok(Self::High),
"max" => Ok(Self::Max),
"xhigh" => Ok(Self::XHigh),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum VimMode {
Normal,
Insert,
Visual,
Command,
Replace,
}
impl VimMode {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Normal => "normal",
Self::Insert => "insert",
Self::Visual => "visual",
Self::Command => "command",
Self::Replace => "replace",
}
}
}
impl std::str::FromStr for VimMode {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"normal" => Ok(Self::Normal),
"insert" => Ok(Self::Insert),
"visual" => Ok(Self::Visual),
"command" => Ok(Self::Command),
"replace" => Ok(Self::Replace),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct OutputStyle {
pub name: String,
}
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd, serde::Serialize)]
pub struct Percent(f32);
impl Percent {
#[must_use]
pub fn new(value: f32) -> Option<Self> {
if (0.0..=100.0).contains(&value) {
Some(Self(value))
} else {
None
}
}
#[must_use]
pub fn from_f64(value: f64) -> Option<Self> {
if (0.0..=100.0).contains(&value) {
Some(Self(value as f32))
} else {
None
}
}
#[must_use]
pub fn from_f64_clamped(value: f64) -> Option<Self> {
if value.is_nan() {
return None;
}
Some(Self(value.clamp(0.0, 100.0) as f32))
}
#[must_use]
pub fn value(self) -> f32 {
self.0
}
#[must_use]
pub fn complement(self) -> Self {
Self(100.0 - self.0)
}
}
pub fn parse(input: &[u8]) -> Result<StatusContext, ParseError> {
let raw_value: serde_json::Value =
serde_json::from_slice(input).map_err(|err| ParseError::InvalidJson {
message: err.to_string(),
location: (err.line() > 0).then(|| SourcePos {
line: err.line(),
column: err.column(),
}),
})?;
let raw = Arc::new(raw_value);
claude::normalize(raw)
}
#[derive(Debug)]
#[non_exhaustive]
pub enum ParseError {
InvalidJson {
message: String,
location: Option<SourcePos>,
},
MissingField {
tool: Tool,
path: String,
},
TypeMismatch {
tool: Tool,
path: String,
expected: JsonType,
got: JsonType,
},
InvalidValue {
tool: Tool,
path: String,
reason: &'static str,
},
NormalizerError {
tool: Tool,
message: String,
},
}
#[derive(Debug, Clone, Copy)]
pub struct SourcePos {
pub line: usize,
pub column: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JsonType {
Object,
Array,
String,
Number,
Bool,
Null,
}
impl JsonType {
#[must_use]
pub fn of(value: &serde_json::Value) -> Self {
match value {
serde_json::Value::Object(_) => Self::Object,
serde_json::Value::Array(_) => Self::Array,
serde_json::Value::String(_) => Self::String,
serde_json::Value::Number(_) => Self::Number,
serde_json::Value::Bool(_) => Self::Bool,
serde_json::Value::Null => Self::Null,
}
}
}
impl std::fmt::Display for JsonType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let name = match self {
Self::Object => "object",
Self::Array => "array",
Self::String => "string",
Self::Number => "number",
Self::Bool => "bool",
Self::Null => "null",
};
f.write_str(name)
}
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidJson { message, location } => match location {
Some(pos) => write!(f, "invalid JSON at {}:{}: {message}", pos.line, pos.column),
None => write!(f, "invalid JSON: {message}"),
},
Self::MissingField { tool, path } => {
write!(f, "missing field {} for {tool:?}", display_path(path))
}
Self::TypeMismatch {
tool,
path,
expected,
got,
} => {
write!(
f,
"type mismatch at {} for {tool:?}: expected {expected}, got {got}",
display_path(path)
)
}
Self::InvalidValue { tool, path, reason } => {
write!(
f,
"invalid value at {} for {tool:?}: {reason}",
display_path(path)
)
}
Self::NormalizerError { tool, message } => {
write!(f, "normalizer error for {tool:?}: {message}")
}
}
}
}
fn display_path(path: &str) -> String {
if path.is_empty() {
"<root>".to_string()
} else {
format!("{path:?}")
}
}
impl std::error::Error for ParseError {}
mod claude {
use super::{
ContextWindow, CostMetrics, EffortLevel, GitWorktree, JsonType, ModelInfo, OutputStyle,
ParseError, Percent, StatusContext, Tool, TurnUsage, VimMode, WorkspaceInfo,
};
use std::path::PathBuf;
use std::sync::Arc;
const TOOL: Tool = Tool::ClaudeCode;
pub(super) fn normalize(raw: Arc<serde_json::Value>) -> Result<StatusContext, ParseError> {
let root = expect_object(&raw, "")?;
let model = parse_model(root);
let workspace = parse_workspace(root);
let context_window = parse_context_window(root)?;
let cost = parse_cost(root)?;
let effort = parse_effort(root)?;
let vim = parse_vim(root)?;
let output_style = parse_output_style(root)?;
let agent_name = parse_agent_name(root)?;
let version = parse_version(root)?;
Ok(StatusContext {
tool: TOOL,
model,
workspace,
context_window,
cost,
effort,
vim,
output_style,
agent_name,
version,
raw,
})
}
fn parse_model(root: &serde_json::Map<String, serde_json::Value>) -> Option<ModelInfo> {
let value = root.get("model")?;
if value.is_null() {
return None;
}
let model = match value.as_object() {
Some(o) => o,
None => {
crate::lsm_warn!(
"model: expected object, got {:?}; degrading to None (possible CC schema drift)",
JsonType::of(value)
);
return None;
}
};
let Some(name_value) = model.get("display_name") else {
crate::lsm_warn!("model.display_name: missing; degrading to None");
return None;
};
if name_value.is_null() {
return None;
}
let Some(display_name) = name_value.as_str() else {
crate::lsm_warn!(
"model.display_name: expected string, got {:?}; degrading to None",
JsonType::of(name_value)
);
return None;
};
Some(ModelInfo {
display_name: display_name.to_owned(),
})
}
fn parse_workspace(root: &serde_json::Map<String, serde_json::Value>) -> Option<WorkspaceInfo> {
let value = root.get("workspace")?;
if value.is_null() {
return None;
}
let workspace = match value.as_object() {
Some(o) => o,
None => {
crate::lsm_warn!(
"workspace: expected object, got {:?}; degrading to None (possible CC schema drift)",
JsonType::of(value)
);
return None;
}
};
let Some(dir_value) = workspace.get("project_dir") else {
crate::lsm_warn!("workspace.project_dir: missing; degrading to None");
return None;
};
if dir_value.is_null() {
return None;
}
let Some(project_dir_str) = dir_value.as_str() else {
crate::lsm_warn!(
"workspace.project_dir: expected string, got {:?}; degrading to None",
JsonType::of(dir_value)
);
return None;
};
let git_worktree = match workspace.get("git_worktree") {
Some(serde_json::Value::Null) | None => None,
Some(serde_json::Value::Object(obj)) => parse_git_worktree(obj),
Some(other) => {
crate::lsm_warn!(
"workspace.git_worktree: expected object, got {:?}; degrading to None (worktree only)",
JsonType::of(other)
);
None
}
};
Some(WorkspaceInfo {
project_dir: PathBuf::from(project_dir_str),
git_worktree,
})
}
fn parse_git_worktree(obj: &serde_json::Map<String, serde_json::Value>) -> Option<GitWorktree> {
let name = string_leaf(obj, "workspace.git_worktree.name")?;
let path = string_leaf(obj, "workspace.git_worktree.path")?;
if name.is_empty() || path.is_empty() {
return None;
}
Some(GitWorktree {
name: name.to_owned(),
path: PathBuf::from(path),
})
}
fn string_leaf<'a>(
obj: &'a serde_json::Map<String, serde_json::Value>,
path: &'static str,
) -> Option<&'a str> {
let value = obj.get(path_tail(path))?;
if value.is_null() {
return None;
}
match value.as_str() {
Some(s) => Some(s),
None => {
crate::lsm_warn!(
"{path}: expected string, got {:?}; degrading to None",
JsonType::of(value)
);
None
}
}
}
fn parse_context_window(
root: &serde_json::Map<String, serde_json::Value>,
) -> Result<Option<ContextWindow>, ParseError> {
let Some(value) = root.get("context_window") else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
let Some(cw) = value.as_object() else {
crate::lsm_warn!(
"context_window: expected object, got {:?}; degrading to None",
JsonType::of(value)
);
return Ok(None);
};
let used = parse_used_percentage(cw)?;
let size = parse_size(cw);
let total_input_tokens = try_u64_required(cw, "context_window.total_input_tokens");
let total_output_tokens = try_u64_required(cw, "context_window.total_output_tokens");
let current_usage = parse_current_usage(cw)?;
let window = ContextWindow {
used,
size,
total_input_tokens,
total_output_tokens,
current_usage,
};
if context_window_is_empty(&window) {
return Ok(None);
}
Ok(Some(window))
}
fn context_window_is_empty(cw: &ContextWindow) -> bool {
cw.used.is_none()
&& cw.size.is_none()
&& cw.total_input_tokens.is_none()
&& cw.total_output_tokens.is_none()
&& cw.current_usage.is_none()
}
fn parse_used_percentage(
cw: &serde_json::Map<String, serde_json::Value>,
) -> Result<Option<Percent>, ParseError> {
let Some(value) = cw.get("used_percentage") else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
let Some(used_raw) = value.as_f64() else {
crate::lsm_warn!(
"context_window.used_percentage: expected number, got {:?}; degrading leaf to None",
JsonType::of(value)
);
return Ok(None);
};
if used_raw > 100.0 {
crate::lsm_warn!("context_window.used_percentage = {used_raw} > 100; clamping to 100");
return Ok(Some(
Percent::from_f64_clamped(used_raw)
.expect("non-NaN value > 100 clamps successfully"),
));
}
match Percent::from_f64(used_raw) {
Some(p) => Ok(Some(p)),
None => Err(invalid_value(
"context_window.used_percentage",
"percentage must be a number in [0, 100]",
)),
}
}
fn parse_size(cw: &serde_json::Map<String, serde_json::Value>) -> Option<u32> {
let raw = try_u64_required(cw, "context_window.context_window_size")?;
match u32::try_from(raw) {
Ok(n) => Some(n),
Err(_) => {
crate::lsm_warn!(
"context_window.context_window_size = {raw} exceeds u32::MAX; degrading leaf to None"
);
None
}
}
}
fn try_u64_required(
obj: &serde_json::Map<String, serde_json::Value>,
path: &'static str,
) -> Option<u64> {
let Some(value) = obj.get(path_tail(path)) else {
crate::lsm_warn!("{path}: missing; degrading leaf to None (possible CC schema drift)");
return None;
};
if value.is_null() {
crate::lsm_warn!("{path}: null; degrading leaf to None (possible CC schema drift)");
return None;
}
match value.as_u64() {
Some(n) => Some(n),
None => {
crate::lsm_warn!(
"{path}: expected unsigned integer, got {:?}; degrading leaf to None",
JsonType::of(value)
);
None
}
}
}
fn try_u64_optional(
obj: &serde_json::Map<String, serde_json::Value>,
path: &'static str,
) -> Option<u64> {
let value = obj.get(path_tail(path))?;
if value.is_null() {
return None;
}
match value.as_u64() {
Some(n) => Some(n),
None => {
crate::lsm_warn!(
"{path}: expected unsigned integer, got {:?}; degrading leaf to None",
JsonType::of(value)
);
None
}
}
}
fn try_f64_required(
obj: &serde_json::Map<String, serde_json::Value>,
path: &'static str,
) -> Option<f64> {
let Some(value) = obj.get(path_tail(path)) else {
crate::lsm_warn!("{path}: missing; degrading leaf to None (possible CC schema drift)");
return None;
};
if value.is_null() {
crate::lsm_warn!("{path}: null; degrading leaf to None (possible CC schema drift)");
return None;
}
let Some(n) = value.as_f64() else {
crate::lsm_warn!(
"{path}: expected number, got {:?}; degrading leaf to None",
JsonType::of(value)
);
return None;
};
Some(n)
}
fn parse_current_usage(
cw: &serde_json::Map<String, serde_json::Value>,
) -> Result<Option<TurnUsage>, ParseError> {
let Some(value) = cw.get("current_usage") else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
let Some(obj) = value.as_object() else {
crate::lsm_warn!(
"context_window.current_usage: expected object, got {:?}; degrading to None",
JsonType::of(value)
);
return Ok(None);
};
let Some(input_tokens) = try_u64_optional(obj, "context_window.current_usage.input_tokens")
else {
return Ok(None);
};
let Some(output_tokens) =
try_u64_optional(obj, "context_window.current_usage.output_tokens")
else {
return Ok(None);
};
let Some(cache_creation_input_tokens) = try_u64_optional(
obj,
"context_window.current_usage.cache_creation_input_tokens",
) else {
return Ok(None);
};
let Some(cache_read_input_tokens) =
try_u64_optional(obj, "context_window.current_usage.cache_read_input_tokens")
else {
return Ok(None);
};
Ok(Some(TurnUsage {
input_tokens,
output_tokens,
cache_creation_input_tokens,
cache_read_input_tokens,
}))
}
fn parse_cost(
root: &serde_json::Map<String, serde_json::Value>,
) -> Result<Option<CostMetrics>, ParseError> {
let Some(value) = root.get("cost") else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
let Some(cost) = value.as_object() else {
crate::lsm_warn!(
"cost: expected object, got {:?}; degrading to None",
JsonType::of(value)
);
return Ok(None);
};
let metrics = CostMetrics {
total_cost_usd: try_f64_required(cost, "cost.total_cost_usd"),
total_duration_ms: try_u64_required(cost, "cost.total_duration_ms"),
total_api_duration_ms: try_u64_required(cost, "cost.total_api_duration_ms"),
total_lines_added: try_u64_required(cost, "cost.total_lines_added"),
total_lines_removed: try_u64_required(cost, "cost.total_lines_removed"),
};
if cost_is_empty(&metrics) {
return Ok(None);
}
Ok(Some(metrics))
}
fn cost_is_empty(c: &CostMetrics) -> bool {
c.total_cost_usd.is_none()
&& c.total_duration_ms.is_none()
&& c.total_api_duration_ms.is_none()
&& c.total_lines_added.is_none()
&& c.total_lines_removed.is_none()
}
fn parse_effort(
root: &serde_json::Map<String, serde_json::Value>,
) -> Result<Option<EffortLevel>, ParseError> {
let Some(value) = root.get("effort") else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
let (raw, path): (&str, &'static str) = match value {
serde_json::Value::Object(obj) => {
let Some(level) = obj.get("level") else {
crate::lsm_warn!(
"effort: wrapper present but `level` missing; degrading to None (possible CC schema drift)"
);
return Ok(None);
};
if level.is_null() {
return Ok(None);
}
let Some(s) = level.as_str() else {
crate::lsm_warn!(
"effort.level: expected string, got {:?}; degrading to None",
JsonType::of(level)
);
return Ok(None);
};
(s, "effort.level")
}
serde_json::Value::String(s) => (s.as_str(), "effort"),
other => {
crate::lsm_warn!(
"effort: expected object or string, got {:?}; degrading to None",
JsonType::of(other)
);
return Ok(None);
}
};
match raw.parse::<EffortLevel>() {
Ok(level) => Ok(Some(level)),
Err(()) => {
crate::lsm_warn!(
"effort: unknown level {raw:?} at {path}; degrading to None (possible CC schema drift — known: low, medium, high, max, xhigh)"
);
Ok(None)
}
}
}
fn parse_vim(
root: &serde_json::Map<String, serde_json::Value>,
) -> Result<Option<VimMode>, ParseError> {
let Some(value) = root.get("vim") else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
let (raw, path): (&str, &'static str) = match value {
serde_json::Value::Object(obj) => {
let Some(mode) = obj.get("mode") else {
crate::lsm_warn!(
"vim: wrapper present but `mode` missing; degrading to None (possible CC schema drift)"
);
return Ok(None);
};
if mode.is_null() {
return Ok(None);
}
let Some(s) = mode.as_str() else {
crate::lsm_warn!(
"vim.mode: expected string, got {:?}; degrading to None",
JsonType::of(mode)
);
return Ok(None);
};
(s, "vim.mode")
}
serde_json::Value::String(s) => {
crate::lsm_debug!(
"vim: accepted bare-string compat shape {:?}; canonical is {{ mode }}",
s
);
(s.as_str(), "vim")
}
other => {
crate::lsm_warn!(
"vim: expected object or string, got {:?}; degrading to None",
JsonType::of(other)
);
return Ok(None);
}
};
match raw.parse::<VimMode>() {
Ok(mode) => Ok(Some(mode)),
Err(()) => {
crate::lsm_warn!(
"vim: unknown mode {raw:?} at {path}; degrading to None (possible CC schema drift — known: normal, insert, visual, command, replace)"
);
Ok(None)
}
}
}
fn parse_output_style(
root: &serde_json::Map<String, serde_json::Value>,
) -> Result<Option<OutputStyle>, ParseError> {
let Some(value) = root.get("output_style") else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
let Some(obj) = value.as_object() else {
crate::lsm_warn!(
"output_style: expected object, got {:?}; degrading to None",
JsonType::of(value)
);
return Ok(None);
};
let Some(name_value) = obj.get("name") else {
crate::lsm_warn!(
"output_style: wrapper present but `name` field missing; degrading to None (possible CC schema drift)"
);
return Ok(None);
};
if name_value.is_null() {
return Ok(None);
}
let Some(name) = name_value.as_str() else {
crate::lsm_warn!(
"output_style.name: expected string, got {:?}; degrading to None",
JsonType::of(name_value)
);
return Ok(None);
};
if name.is_empty() {
return Ok(None);
}
Ok(Some(OutputStyle {
name: name.to_owned(),
}))
}
fn parse_version(
root: &serde_json::Map<String, serde_json::Value>,
) -> Result<Option<String>, ParseError> {
let Some(value) = root.get("version") else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
let Some(raw) = value.as_str() else {
crate::lsm_warn!(
"version: expected string, got {:?}; degrading to None",
JsonType::of(value)
);
return Ok(None);
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(None);
}
Ok(Some(trimmed.to_owned()))
}
fn parse_agent_name(
root: &serde_json::Map<String, serde_json::Value>,
) -> Result<Option<String>, ParseError> {
let Some(value) = root.get("agent") else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
let Some(obj) = value.as_object() else {
crate::lsm_warn!(
"agent: expected object, got {:?}; degrading to None",
JsonType::of(value)
);
return Ok(None);
};
let Some(name_value) = obj.get("name") else {
crate::lsm_warn!(
"agent: wrapper present but `name` field missing; degrading to None (possible CC schema drift)"
);
return Ok(None);
};
if name_value.is_null() {
return Ok(None);
}
let Some(name) = name_value.as_str() else {
crate::lsm_warn!(
"agent.name: expected string, got {:?}; degrading to None",
JsonType::of(name_value)
);
return Ok(None);
};
if name.is_empty() {
return Ok(None);
}
Ok(Some(name.to_owned()))
}
fn expect_object<'a>(
value: &'a serde_json::Value,
path: &str,
) -> Result<&'a serde_json::Map<String, serde_json::Value>, ParseError> {
value
.as_object()
.ok_or_else(|| type_mismatch(path, JsonType::Object, JsonType::of(value)))
}
fn path_tail(path: &str) -> &str {
path.rsplit('.').next().unwrap_or(path)
}
fn type_mismatch(path: impl Into<String>, expected: JsonType, got: JsonType) -> ParseError {
ParseError::TypeMismatch {
tool: TOOL,
path: path.into(),
expected,
got,
}
}
fn invalid_value(path: impl Into<String>, reason: &'static str) -> ParseError {
ParseError::InvalidValue {
tool: TOOL,
path: path.into(),
reason,
}
}
}
#[cfg(test)]
mod tests;