codex-mobile-bridge 0.3.10

Remote bridge and service manager for codex-mobile.
Documentation
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))
}