opencode-stats 1.3.6

A terminal dashboard for OpenCode usage statistics inspired by the /stats command in Claude Code
use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

use serde_json::Value;

use crate::cache::errors::{Error, Result};
use crate::cache::models_cache::{ModelPricing, map_models_root_to_local};

pub fn load_pricing_overrides() -> Result<BTreeMap<String, ModelPricing>> {
    let merged = load_merged_config()?;
    Ok(extract_pricing_overrides(&merged))
}

fn load_merged_config() -> Result<Value> {
    let mut merged = Value::Object(serde_json::Map::new());
    for path in candidate_config_paths()? {
        let Some(config) = read_config_if_exists(&path)? else {
            continue;
        };
        merge_json(&mut merged, config);
    }
    Ok(merged)
}

fn candidate_config_paths() -> Result<Vec<PathBuf>> {
    let mut paths = Vec::new();

    if let Some(path) = dirs::config_dir() {
        paths.push(path.join("opencode").join("opencode.json"));
        paths.push(path.join("opencode").join("opencode.jsonc"));
    }

    if let Some(custom) = env::var_os("OPENCODE_CONFIG") {
        paths.push(PathBuf::from(custom));
    }

    if let Some(project) = discover_project_config_path()? {
        paths.push(project);
    }

    Ok(paths)
}

fn discover_project_config_path() -> Result<Option<PathBuf>> {
    let mut current = env::current_dir().map_err(|e| Error::CurrentDir { source: e })?;

    loop {
        for name in ["opencode.json", "opencode.jsonc"] {
            let candidate = current.join(name);
            if candidate.exists() {
                return Ok(Some(candidate));
            }
        }

        if current.join(".git").exists() {
            return Ok(None);
        }

        let Some(parent) = current.parent() else {
            return Ok(None);
        };
        current = parent.to_path_buf();
    }
}

fn read_config_if_exists(path: &Path) -> Result<Option<Value>> {
    if !path.exists() {
        return Ok(None);
    }

    let contents = fs::read_to_string(path).map_err(|e| Error::ConfigRead {
        path: path.to_path_buf(),
        source: e,
    })?;
    let value = json5::from_str::<Value>(&contents).map_err(|e| Error::ConfigParse {
        path: path.to_path_buf(),
        source: e,
    })?;
    Ok(Some(value))
}

fn merge_json(base: &mut Value, overlay: Value) {
    match (base, overlay) {
        (Value::Object(base_map), Value::Object(overlay_map)) => {
            for (key, value) in overlay_map {
                match base_map.get_mut(&key) {
                    Some(base_value) => merge_json(base_value, value),
                    None => {
                        base_map.insert(key, value);
                    }
                }
            }
        }
        (base_slot, overlay_value) => {
            *base_slot = overlay_value;
        }
    }
}

fn extract_pricing_overrides(config: &Value) -> BTreeMap<String, ModelPricing> {
    let Some(provider_map) = config.get("provider").and_then(Value::as_object) else {
        return BTreeMap::new();
    };

    let mut result = BTreeMap::new();
    for (provider_id, provider_value) in provider_map {
        let Some(models_root) = provider_value.get("models") else {
            continue;
        };

        let models = map_models_root_to_local(provider_id, models_root);
        for (key, value) in models {
            result.insert(key, value);
        }
    }

    result
}

#[cfg(test)]
mod tests {
    use super::{extract_pricing_overrides, merge_json};
    use rust_decimal::Decimal;
    use serde_json::json;

    #[test]
    fn merges_nested_objects() {
        let mut base = json!({
            "provider": {
                "openai": {
                    "models": {
                        "gpt-5": { "cost": { "input": 1 } }
                    }
                }
            }
        });
        merge_json(
            &mut base,
            json!({
                "provider": {
                    "openai": {
                        "models": {
                            "gpt-5": { "cost": { "output": 2 } },
                            "gpt-5-mini": { "cost": { "input": 3, "output": 4 } }
                        }
                    }
                }
            }),
        );

        let result = extract_pricing_overrides(&base);
        assert_eq!(result.get("openai/gpt-5").unwrap().input, Decimal::ONE);
        assert_eq!(
            result.get("openai/gpt-5").unwrap().output,
            Decimal::new(2, 0)
        );
        assert_eq!(
            result.get("openai/gpt-5-mini").unwrap().input,
            Decimal::new(3, 0)
        );
    }
}