1use std::path::PathBuf;
20use std::env;
21use serde::{Deserialize, Serialize};
22
23#[derive(Debug, Clone, Serialize, Deserialize, Default)]
26pub struct MatrixConfig {
27 #[serde(default)]
29 pub provider: Option<String>,
30
31 #[serde(default, rename = "ANTHROPIC_AUTH_TOKEN")]
33 pub api_key: Option<String>,
34
35 #[serde(default, rename = "ANTHROPIC_BASE_URL")]
37 pub base_url: Option<String>,
38
39 #[serde(default, rename = "ANTHROPIC_MODEL")]
41 pub model: Option<String>,
42
43 #[serde(default = "default_true")]
45 pub think: bool,
46
47 #[serde(default = "default_true")]
49 pub markdown: bool,
50
51 #[serde(default = "default_max_tokens")]
53 pub max_tokens: u32,
54
55 #[serde(default)]
57 pub context_size: Option<u32>,
58
59 #[serde(default)]
61 pub multi_model: Option<bool>,
62
63 #[serde(default, rename = "ANTHROPIC_REASONING_MODEL")]
65 pub plan_model: Option<String>,
66
67 #[serde(default, rename = "ANTHROPIC_DEFAULT_HAIKU_MODEL")]
69 pub compress_model: Option<String>,
70
71 #[serde(default)]
73 pub fast_model: Option<String>,
74
75 #[serde(default = "default_approve_mode")]
77 pub approve_mode: Option<String>,
78}
79
80fn default_true() -> bool { true }
81fn default_max_tokens() -> u32 { 16384 }
82fn default_approve_mode() -> Option<String> { Some("ask".to_string()) }
83
84pub type Config = MatrixConfig;
86
87#[derive(Debug, Clone, Deserialize)]
89struct ClaudeSettings {
90 #[serde(default)]
91 env: Option<ClaudeEnv>,
92
93 #[serde(default, rename = "skipDangerousModePermissionPrompt")]
95 skip_dangerous_mode_permission_prompt: Option<bool>,
96}
97
98#[derive(Debug, Clone, Deserialize)]
101#[allow(non_snake_case)]
102struct ClaudeEnv {
103 #[serde(default)]
104 ANTHROPIC_AUTH_TOKEN: Option<String>,
105
106 #[serde(default)]
107 ANTHROPIC_BASE_URL: Option<String>,
108
109 #[serde(default)]
110 ANTHROPIC_MODEL: Option<String>,
111
112 #[serde(default)]
113 ANTHROPIC_DEFAULT_HAIKU_MODEL: Option<String>,
114
115 #[serde(default)]
116 ANTHROPIC_REASONING_MODEL: Option<String>,
117}
118
119impl MatrixConfig {
120 fn home_dir() -> Option<PathBuf> {
122 env::var_os("HOME")
123 .or_else(|| env::var_os("USERPROFILE"))
124 .map(PathBuf::from)
125 }
126
127 pub fn matrix_config_path() -> Option<PathBuf> {
129 Self::home_dir().map(|h| h.join(".matrix").join("config.json"))
130 }
131
132 pub fn claude_settings_path() -> Option<PathBuf> {
134 Self::home_dir().map(|h| h.join(".claude").join("settings.json"))
135 }
136
137 fn load_matrix_config() -> Option<Self> {
139 let path = Self::matrix_config_path()?;
140 if !path.exists() {
141 return None;
142 }
143
144 let content = std::fs::read_to_string(&path).ok()?;
145 let config: Self = serde_json::from_str(&content).ok()?;
146
147 Some(config)
149 }
150
151 fn load_ccswitch_config() -> Option<Self> {
153 let path = Self::claude_settings_path()?;
154 if !path.exists() {
155 return None;
156 }
157
158 let content = std::fs::read_to_string(&path).ok()?;
159 let settings: ClaudeSettings = serde_json::from_str(&content).ok()?;
160
161 let env = settings.env?;
162
163 let approve_mode = if settings.skip_dangerous_mode_permission_prompt == Some(true) {
165 Some("auto".to_string())
166 } else {
167 None
168 };
169
170 let config = Self {
172 provider: Some("anthropic".to_string()),
173 api_key: env.ANTHROPIC_AUTH_TOKEN,
174 base_url: env.ANTHROPIC_BASE_URL,
175 model: env.ANTHROPIC_MODEL,
176 think: true,
177 markdown: true,
178 max_tokens: 16384,
179 context_size: None,
180 multi_model: None,
181 plan_model: env.ANTHROPIC_REASONING_MODEL,
182 compress_model: env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
183 fast_model: None,
184 approve_mode,
185 };
186
187 Some(config)
188 }
189
190 pub fn load() -> Self {
196 let matrix_config = Self::load_matrix_config();
198 let claude_config = Self::load_ccswitch_config();
200
201 match (matrix_config, claude_config) {
203 (Some(mx), Some(cc)) => {
204 let needs_fallback = mx.api_key.is_none() || mx.model.is_none() || mx.base_url.is_none();
206
207 let approve_mode = mx.approve_mode.or(Some("ask".to_string()));
210
211 let merged = Self {
212 provider: mx.provider.or(cc.provider),
213 api_key: mx.api_key.or(cc.api_key),
214 base_url: mx.base_url.or(cc.base_url),
215 model: mx.model.or(cc.model),
216 think: mx.think,
217 markdown: mx.markdown,
218 max_tokens: mx.max_tokens,
219 context_size: mx.context_size.or(cc.context_size),
220 multi_model: mx.multi_model.or(cc.multi_model),
221 plan_model: mx.plan_model.or(cc.plan_model),
222 compress_model: mx.compress_model.or(cc.compress_model),
223 fast_model: mx.fast_model.or(cc.fast_model),
224 approve_mode,
225 };
226
227 if needs_fallback {
229 println!("[config: ~/.matrix/config.json + fallback from ~/.claude/settings.json]");
230 } else {
231 println!("[config: ~/.matrix/config.json]");
232 }
233 merged
234 }
235 (Some(mx), None) => {
236 println!("[config: ~/.matrix/config.json]");
237 if mx.approve_mode.is_none() {
239 Self {
240 approve_mode: Some("ask".to_string()),
241 ..mx
242 }
243 } else {
244 mx
245 }
246 }
247 (None, Some(cc)) => {
248 println!("[config: ~/.claude/settings.json (Claude Code)]");
249 Self {
252 approve_mode: Some("ask".to_string()),
253 ..cc
254 }
255 }
256 (None, None) => {
257 println!("[config: using defaults and environment variables]");
258 Self::default()
259 }
260 }
261 }
262
263 pub fn get_api_key(&self, provider: &str) -> Option<String> {
266 match provider {
267 "openai" => env::var("OPENAI_API_KEY").ok(),
268 _ => env::var("ANTHROPIC_AUTH_TOKEN")
269 .or_else(|_| env::var("ANTHROPIC_API_KEY")) .ok(),
271 }
272 .or(self.api_key.clone())
273 }
274
275 pub fn get_model(&self, provider: &str) -> String {
278 env::var("ANTHROPIC_MODEL")
279 .or_else(|_| env::var("MODEL_NAME")) .ok()
281 .or(self.model.clone())
282 .unwrap_or_else(|| match provider {
283 "openai" => "gpt-4o".to_string(),
284 _ => "claude-sonnet-4-20250514".to_string(),
285 })
286 }
287
288 pub fn get_base_url(&self, provider: &str) -> String {
291 env::var("ANTHROPIC_BASE_URL")
292 .or_else(|_| env::var("BASE_URL")) .ok()
294 .or(self.base_url.clone())
295 .unwrap_or_else(|| match provider {
296 "openai" => "https://api.openai.com/v1".to_string(),
297 _ => "https://api.anthropic.com".to_string(),
298 })
299 }
300
301 pub fn save(&self) -> anyhow::Result<()> {
303 let path = Self::matrix_config_path()
304 .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
305
306 let dir = path.parent().ok_or_else(|| anyhow::anyhow!("Invalid path"))?;
308 if !dir.exists() {
309 std::fs::create_dir_all(dir)?;
310 }
311
312 let content = serde_json::to_string_pretty(self)?;
313 std::fs::write(&path, content)?;
314
315 println!("[config saved to ~/.matrix/config.json]");
316 Ok(())
317 }
318}
319
320pub fn create_default_config() -> anyhow::Result<()> {
323 let config = MatrixConfig {
324 provider: Some("anthropic".to_string()),
325 api_key: None, base_url: None, model: None, think: true,
329 markdown: true,
330 max_tokens: 16384,
331 context_size: None,
332 multi_model: Some(false),
333 plan_model: None, compress_model: None, fast_model: None,
336 approve_mode: Some("ask".to_string()),
337 };
338
339 config.save()?;
340 println!("\nConfig file created at ~/.matrix/config.json");
341 println!("Fields use Claude Code naming convention:");
342 println!(" ANTHROPIC_AUTH_TOKEN - API key");
343 println!(" ANTHROPIC_BASE_URL - API endpoint");
344 println!(" ANTHROPIC_MODEL - Main model");
345 println!(" ANTHROPIC_REASONING_MODEL - Planning model");
346 println!(" ANTHROPIC_DEFAULT_HAIKU_MODEL - Compression model");
347 println!("\nLeave fields as null to fallback to ~/.claude/settings.json");
348 Ok(())
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 #[test]
356 fn test_default_config_values() {
357 let config = MatrixConfig {
358 provider: None,
359 api_key: None,
360 base_url: None,
361 model: None,
362 think: true,
363 markdown: true,
364 max_tokens: 16384,
365 context_size: None,
366 multi_model: None,
367 plan_model: None,
368 compress_model: None,
369 fast_model: None,
370 approve_mode: None,
371 };
372 assert!(config.api_key.is_none());
373 assert!(config.model.is_none());
374 assert!(config.think);
375 assert!(config.markdown);
376 assert_eq!(config.max_tokens, 16384);
377 }
378
379 #[test]
380 fn test_claude_code_field_names() {
381 let json = r#"{
383 "ANTHROPIC_AUTH_TOKEN": "test-key",
384 "ANTHROPIC_BASE_URL": "https://test.com",
385 "ANTHROPIC_MODEL": "test-model",
386 "ANTHROPIC_REASONING_MODEL": "reasoning-model",
387 "ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-model"
388 }"#;
389
390 let config: MatrixConfig = serde_json::from_str(json).unwrap();
391 assert_eq!(config.api_key, Some("test-key".to_string()));
392 assert_eq!(config.base_url, Some("https://test.com".to_string()));
393 assert_eq!(config.model, Some("test-model".to_string()));
394 assert_eq!(config.plan_model, Some("reasoning-model".to_string()));
395 assert_eq!(config.compress_model, Some("haiku-model".to_string()));
396 }
397
398 #[test]
399 fn test_serialization_uses_claude_names() {
400 let config = MatrixConfig {
401 api_key: Some("key".to_string()),
402 model: Some("model".to_string()),
403 ..Default::default()
404 };
405
406 let json = serde_json::to_string(&config).unwrap();
407 assert!(json.contains("ANTHROPIC_AUTH_TOKEN"));
409 assert!(json.contains("ANTHROPIC_MODEL"));
410 }
411}