use anyhow::{Context, Result};
use serde_json::Value;
use crate::bridge_protocol::{ThreadStatusInfo, ThreadSummary, ThreadTokenUsage};
pub(super) fn request_key(id: &Value) -> String {
match id {
Value::String(value) => value.clone(),
_ => id.to_string(),
}
}
pub(super) fn optional_string(value: &Value, key: &str) -> Option<String> {
value
.get(key)
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}
pub(super) fn normalize_optional_text(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
pub(super) fn normalize_name(name: Option<String>) -> Option<String> {
normalize_optional_text(name)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum OptionalTextUpdate {
Unchanged,
Clear,
Set(String),
}
pub(super) fn resolve_optional_text_update(
current: Option<&str>,
requested: Option<&str>,
) -> OptionalTextUpdate {
let current = normalize_optional_text(current.map(str::to_string));
match normalize_optional_text(requested.map(str::to_string)) {
Some(next) => {
if current.as_deref() == Some(next.as_str()) {
OptionalTextUpdate::Unchanged
} else {
OptionalTextUpdate::Set(next)
}
}
None if requested.is_none() => OptionalTextUpdate::Unchanged,
None if current.is_some() => OptionalTextUpdate::Clear,
None => OptionalTextUpdate::Unchanged,
}
}
pub(super) fn required_string<'a>(value: &'a Value, key: &str) -> Result<&'a str> {
value
.get(key)
.and_then(Value::as_str)
.with_context(|| format!("缺少字段 {key}"))
}
pub(super) fn normalize_thread(
runtime_id: &str,
thread: &Value,
archived: bool,
) -> Result<ThreadSummary> {
let cwd = required_string(thread, "cwd")?.to_string();
let status_value = thread.get("status").unwrap_or(&Value::Null);
let status_info = normalize_thread_status_info(status_value);
let status = status_info.kind.clone();
let is_loaded = status != "notLoaded";
let is_active = status == "active";
let source = match thread.get("source") {
Some(Value::String(value)) => value.clone(),
Some(other) => other.to_string(),
None => "unknown".to_string(),
};
Ok(ThreadSummary {
id: required_string(thread, "id")?.to_string(),
runtime_id: runtime_id.to_string(),
name: normalize_name(optional_string(thread, "name")),
preview: optional_string(thread, "preview").unwrap_or_default(),
cwd,
status,
status_info,
token_usage: thread
.get("tokenUsage")
.or_else(|| thread.get("usage"))
.map(normalize_thread_token_usage),
model_provider: optional_string(thread, "modelProvider")
.unwrap_or_else(|| "unknown".to_string()),
source,
created_at: thread
.get("createdAt")
.and_then(Value::as_i64)
.unwrap_or_default(),
updated_at: thread
.get("updatedAt")
.and_then(Value::as_i64)
.unwrap_or_default(),
is_loaded,
is_active,
archived,
})
}
pub(super) fn normalize_thread_status_info(status_value: &Value) -> ThreadStatusInfo {
ThreadStatusInfo {
kind: status_value
.get("type")
.and_then(Value::as_str)
.unwrap_or_else(|| status_value.as_str().unwrap_or("unknown"))
.to_string(),
reason: optional_string(status_value, "reason"),
raw: status_value.clone(),
}
}
pub(super) fn normalize_thread_token_usage(value: &Value) -> ThreadTokenUsage {
ThreadTokenUsage {
input_tokens: first_i64(value, &["inputTokens", "input_tokens"]),
cached_input_tokens: first_i64(value, &["cachedInputTokens", "cached_input_tokens"]),
output_tokens: first_i64(value, &["outputTokens", "output_tokens"]),
reasoning_tokens: first_i64(value, &["reasoningTokens", "reasoning_tokens"]),
total_tokens: first_i64(value, &["totalTokens", "total_tokens"]),
raw: value.clone(),
updated_at_ms: value
.get("updatedAtMs")
.and_then(Value::as_i64)
.unwrap_or_default(),
}
}
fn first_i64(value: &Value, keys: &[&str]) -> Option<i64> {
keys.iter()
.find_map(|key| value.get(*key).and_then(Value::as_i64))
}