use serde::{Deserialize, Serialize};
use std::env;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MatrixConfig {
#[serde(default)]
pub provider: Option<String>,
#[serde(default, rename = "ANTHROPIC_AUTH_TOKEN")]
pub api_key: Option<String>,
#[serde(default, rename = "ANTHROPIC_BASE_URL")]
pub base_url: Option<String>,
#[serde(default, rename = "ANTHROPIC_MODEL")]
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, rename = "ANTHROPIC_REASONING_MODEL")]
pub plan_model: Option<String>,
#[serde(default, rename = "ANTHROPIC_DEFAULT_HAIKU_MODEL")]
pub compress_model: Option<String>,
#[serde(default)]
pub fast_model: Option<String>,
#[serde(default = "default_approve_mode")]
pub approve_mode: Option<String>,
}
fn default_true() -> bool {
true
}
fn default_max_tokens() -> u32 {
16384
}
fn default_approve_mode() -> Option<String> {
Some("ask".to_string())
}
pub type Config = MatrixConfig;
#[derive(Debug, Clone, Deserialize)]
struct ClaudeSettings {
#[serde(default)]
env: Option<ClaudeEnv>,
#[serde(default, rename = "skipDangerousModePermissionPrompt")]
skip_dangerous_mode_permission_prompt: Option<bool>,
}
#[derive(Debug, Clone, Deserialize)]
#[allow(non_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(default)]
ANTHROPIC_DEFAULT_HAIKU_MODEL: Option<String>,
#[serde(default)]
ANTHROPIC_REASONING_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 = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
log::warn!("Failed to read ~/.matrix/config.json: {}", e);
return None;
}
};
let config: Self = match serde_json::from_str(&content) {
Ok(c) => c,
Err(e) => {
log::warn!("Failed to parse ~/.matrix/config.json: {}", e);
return None;
}
};
Some(config)
}
fn load_ccswitch_config() -> Option<Self> {
let path = Self::claude_settings_path()?;
if !path.exists() {
return None;
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
log::warn!("Failed to read ~/.claude/settings.json: {}", e);
return None;
}
};
let settings: ClaudeSettings = match serde_json::from_str(&content) {
Ok(s) => s,
Err(e) => {
log::warn!("Failed to parse ~/.claude/settings.json: {}", e);
return None;
}
};
let env = settings.env?;
let approve_mode = if settings.skip_dangerous_mode_permission_prompt == Some(true) {
Some("auto".to_string())
} else {
None
};
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.ANTHROPIC_REASONING_MODEL,
compress_model: env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
fast_model: None,
approve_mode,
};
Some(config)
}
pub fn load() -> Self {
let matrix_config = Self::load_matrix_config();
let claude_config = Self::load_ccswitch_config();
match (matrix_config, claude_config) {
(Some(mx), Some(cc)) => {
let needs_fallback =
mx.api_key.is_none() || mx.model.is_none() || mx.base_url.is_none();
let approve_mode = mx.approve_mode.or(Some("ask".to_string()));
let merged = Self {
provider: mx.provider.or(cc.provider),
api_key: mx.api_key.or(cc.api_key),
base_url: mx.base_url.or(cc.base_url),
model: mx.model.or(cc.model),
think: mx.think,
markdown: mx.markdown,
max_tokens: mx.max_tokens,
context_size: mx.context_size.or(cc.context_size),
multi_model: mx.multi_model.or(cc.multi_model),
plan_model: mx.plan_model.or(cc.plan_model),
compress_model: mx.compress_model.or(cc.compress_model),
fast_model: mx.fast_model.or(cc.fast_model),
approve_mode,
};
if needs_fallback {
println!(
"[config: ~/.matrix/config.json + fallback from ~/.claude/settings.json]"
);
} else {
println!("[config: ~/.matrix/config.json]");
}
merged
}
(Some(mx), None) => {
println!("[config: ~/.matrix/config.json]");
if mx.approve_mode.is_none() {
Self {
approve_mode: Some("ask".to_string()),
..mx
}
} else {
mx
}
}
(None, Some(cc)) => {
println!("[config: ~/.claude/settings.json (Claude Code)]");
Self {
approve_mode: Some("ask".to_string()),
..cc
}
}
(None, None) => {
println!("[config: using defaults and environment variables]");
Self::default()
}
}
}
pub fn get_api_key(&self, provider: &str) -> Option<String> {
match provider {
"openai" => env::var("OPENAI_API_KEY").ok(),
_ => env::var("ANTHROPIC_AUTH_TOKEN")
.or_else(|_| env::var("ANTHROPIC_API_KEY")) .ok(),
}
.or(self.api_key.clone())
}
pub fn get_model(&self, provider: &str) -> String {
env::var("ANTHROPIC_MODEL")
.or_else(|_| 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("ANTHROPIC_BASE_URL")
.or_else(|_| 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 is_api_configured(&self) -> bool {
self.api_key.is_some() || env::var("ANTHROPIC_AUTH_TOKEN").ok().is_some()
}
}
pub fn create_default_config() -> anyhow::Result<()> {
let config = MatrixConfig {
provider: Some("anthropic".to_string()),
api_key: None, base_url: None, model: None, 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!("\nConfig file created at ~/.matrix/config.json");
println!("Fields use Claude Code naming convention:");
println!(" ANTHROPIC_AUTH_TOKEN - API key");
println!(" ANTHROPIC_BASE_URL - API endpoint");
println!(" ANTHROPIC_MODEL - Main model");
println!(" ANTHROPIC_REASONING_MODEL - Planning model");
println!(" ANTHROPIC_DEFAULT_HAIKU_MODEL - Compression model");
println!("\nLeave fields as null to fallback to ~/.claude/settings.json");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config_values() {
let config = MatrixConfig {
provider: None,
api_key: None,
base_url: None,
model: None,
think: true,
markdown: true,
max_tokens: 16384,
context_size: None,
multi_model: None,
plan_model: None,
compress_model: None,
fast_model: None,
approve_mode: None,
};
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_claude_code_field_names() {
let json = r#"{
"ANTHROPIC_AUTH_TOKEN": "test-key",
"ANTHROPIC_BASE_URL": "https://test.com",
"ANTHROPIC_MODEL": "test-model",
"ANTHROPIC_REASONING_MODEL": "reasoning-model",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-model"
}"#;
let config: MatrixConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.api_key, Some("test-key".to_string()));
assert_eq!(config.base_url, Some("https://test.com".to_string()));
assert_eq!(config.model, Some("test-model".to_string()));
assert_eq!(config.plan_model, Some("reasoning-model".to_string()));
assert_eq!(config.compress_model, Some("haiku-model".to_string()));
}
#[test]
fn test_serialization_uses_claude_names() {
let config = MatrixConfig {
api_key: Some("key".to_string()),
model: Some("model".to_string()),
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("ANTHROPIC_AUTH_TOKEN"));
assert!(json.contains("ANTHROPIC_MODEL"));
}
}