use std::path::PathBuf;
use std::env;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MatrixConfig {
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default = "default_true")]
pub think: bool,
#[serde(default = "default_true")]
pub markdown: bool,
#[serde(default = "default_max_tokens")]
pub max_tokens: u32,
#[serde(default)]
pub context_size: Option<u32>,
#[serde(default)]
pub multi_model: Option<bool>,
#[serde(default)]
pub plan_model: Option<String>,
#[serde(default)]
pub compress_model: Option<String>,
#[serde(default)]
pub fast_model: Option<String>,
#[serde(default)]
pub approve_mode: Option<String>,
}
fn default_true() -> bool { true }
fn default_max_tokens() -> u32 { 16384 }
#[derive(Debug, Clone, Deserialize)]
struct ClaudeSettings {
#[serde(default)]
env: Option<ClaudeEnv>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
struct ClaudeEnv {
#[serde(default)]
anthropic_auth_token: Option<String>,
#[serde(default)]
anthropic_base_url: Option<String>,
#[serde(default)]
anthropic_model: Option<String>,
#[serde(rename = "ANTHROPIC_DEFAULT_HAIKU_MODEL")]
#[serde(default)]
pub compress_model: Option<String>,
#[serde(rename = "ANTHROPIC_REASONING_MODEL")]
#[serde(default)]
pub plan_model: Option<String>,
}
impl MatrixConfig {
fn home_dir() -> Option<PathBuf> {
env::var_os("HOME")
.or_else(|| env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
pub fn matrix_config_path() -> Option<PathBuf> {
Self::home_dir().map(|h| h.join(".matrix").join("config.json"))
}
pub fn claude_settings_path() -> Option<PathBuf> {
Self::home_dir().map(|h| h.join(".claude").join("settings.json"))
}
fn load_matrix_config() -> Option<Self> {
let path = Self::matrix_config_path()?;
if !path.exists() {
return None;
}
let content = std::fs::read_to_string(&path).ok()?;
let config: Self = serde_json::from_str(&content).ok()?;
if config.api_key.is_some() || config.model.is_some() {
println!("[loaded config from ~/.matrix/config.json]");
}
Some(config)
}
fn load_ccswitch_config() -> Option<Self> {
let path = Self::claude_settings_path()?;
if !path.exists() {
return None;
}
let content = std::fs::read_to_string(&path).ok()?;
let settings: ClaudeSettings = serde_json::from_str(&content).ok()?;
let env = settings.env?;
let config = Self {
provider: Some("anthropic".to_string()),
api_key: env.anthropic_auth_token,
base_url: env.anthropic_base_url,
model: env.anthropic_model,
think: true,
markdown: true,
max_tokens: 16384,
context_size: None,
multi_model: None,
plan_model: env.plan_model,
compress_model: env.compress_model,
fast_model: None,
approve_mode: None,
};
if config.api_key.is_some() {
println!("[loaded config from ~/.claude/settings.json (cc-switch)]");
}
Some(config)
}
pub fn load() -> Self {
Self::load_matrix_config()
.or_else(Self::load_ccswitch_config)
.unwrap_or_default()
}
pub fn get_api_key(&self, provider: &str) -> Option<String> {
match provider {
"openai" => env::var("OPENAI_API_KEY").ok(),
_ => env::var("ANTHROPIC_API_KEY")
.or_else(|_| env::var("API_KEY"))
.ok(),
}
.or(self.api_key.clone())
}
pub fn get_model(&self, provider: &str) -> String {
env::var("MODEL_NAME")
.ok()
.or(self.model.clone())
.unwrap_or_else(|| match provider {
"openai" => "gpt-4o".to_string(),
_ => "claude-sonnet-4-20250514".to_string(),
})
}
pub fn get_base_url(&self, provider: &str) -> String {
env::var("BASE_URL")
.ok()
.or(self.base_url.clone())
.unwrap_or_else(|| match provider {
"openai" => "https://api.openai.com/v1".to_string(),
_ => "https://api.anthropic.com".to_string(),
})
}
pub fn save(&self) -> anyhow::Result<()> {
let path = Self::matrix_config_path()
.ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
let dir = path.parent().ok_or_else(|| anyhow::anyhow!("Invalid path"))?;
if !dir.exists() {
std::fs::create_dir_all(dir)?;
}
let content = serde_json::to_string_pretty(self)?;
std::fs::write(&path, content)?;
println!("[config saved to ~/.matrix/config.json]");
Ok(())
}
}
pub fn create_default_config() -> anyhow::Result<()> {
let config = MatrixConfig {
provider: Some("anthropic".to_string()),
api_key: None, base_url: None,
model: Some("claude-sonnet-4-20250514".to_string()),
think: true,
markdown: true,
max_tokens: 16384,
context_size: None,
multi_model: Some(false),
plan_model: None,
compress_model: None,
fast_model: None,
approve_mode: Some("ask".to_string()),
};
config.save()?;
println!("\nEdit ~/.matrix/config.json to set your API key.");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = MatrixConfig::default();
assert!(config.api_key.is_none());
assert!(config.model.is_none());
assert!(config.think);
assert!(config.markdown);
assert_eq!(config.max_tokens, 16384);
}
#[test]
fn test_model_fallback() {
let config = MatrixConfig::default();
let model = config.get_model("anthropic");
assert_eq!(model, "claude-sonnet-4-20250514");
let model = config.get_model("openai");
assert_eq!(model, "gpt-4o");
}
}