1use serde::{Deserialize, Serialize};
20use std::env;
21use std::path::PathBuf;
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 {
81 true
82}
83fn default_max_tokens() -> u32 {
84 16384
85}
86fn default_approve_mode() -> Option<String> {
87 Some("ask".to_string())
88}
89
90pub type Config = MatrixConfig;
92
93#[derive(Debug, Clone, Deserialize)]
95struct ClaudeSettings {
96 #[serde(default)]
97 env: Option<ClaudeEnv>,
98
99 #[serde(default, rename = "skipDangerousModePermissionPrompt")]
101 skip_dangerous_mode_permission_prompt: Option<bool>,
102}
103
104#[derive(Debug, Clone, Deserialize)]
107#[allow(non_snake_case)]
108struct ClaudeEnv {
109 #[serde(default)]
110 ANTHROPIC_AUTH_TOKEN: Option<String>,
111
112 #[serde(default)]
113 ANTHROPIC_BASE_URL: Option<String>,
114
115 #[serde(default)]
116 ANTHROPIC_MODEL: Option<String>,
117
118 #[serde(default)]
119 ANTHROPIC_DEFAULT_HAIKU_MODEL: Option<String>,
120
121 #[serde(default)]
122 ANTHROPIC_REASONING_MODEL: Option<String>,
123}
124
125impl MatrixConfig {
126 fn home_dir() -> Option<PathBuf> {
128 env::var_os("HOME")
129 .or_else(|| env::var_os("USERPROFILE"))
130 .map(PathBuf::from)
131 }
132
133 pub fn matrix_config_path() -> Option<PathBuf> {
135 Self::home_dir().map(|h| h.join(".matrix").join("config.json"))
136 }
137
138 pub fn claude_settings_path() -> Option<PathBuf> {
140 Self::home_dir().map(|h| h.join(".claude").join("settings.json"))
141 }
142
143 fn load_matrix_config() -> Option<Self> {
145 let path = Self::matrix_config_path()?;
146 if !path.exists() {
147 return None;
148 }
149
150 let content = match std::fs::read_to_string(&path) {
151 Ok(c) => c,
152 Err(e) => {
153 log::warn!("Failed to read ~/.matrix/config.json: {}", e);
154 return None;
155 }
156 };
157 let config: Self = match serde_json::from_str(&content) {
158 Ok(c) => c,
159 Err(e) => {
160 log::warn!("Failed to parse ~/.matrix/config.json: {}", e);
161 return None;
162 }
163 };
164
165 Some(config)
166 }
167
168 fn load_ccswitch_config() -> Option<Self> {
170 let path = Self::claude_settings_path()?;
171 if !path.exists() {
172 return None;
173 }
174
175 let content = match std::fs::read_to_string(&path) {
176 Ok(c) => c,
177 Err(e) => {
178 log::warn!("Failed to read ~/.claude/settings.json: {}", e);
179 return None;
180 }
181 };
182 let settings: ClaudeSettings = match serde_json::from_str(&content) {
183 Ok(s) => s,
184 Err(e) => {
185 log::warn!("Failed to parse ~/.claude/settings.json: {}", e);
186 return None;
187 }
188 };
189
190 let env = settings.env?;
191
192 let approve_mode = if settings.skip_dangerous_mode_permission_prompt == Some(true) {
194 Some("auto".to_string())
195 } else {
196 None
197 };
198
199 let config = Self {
201 provider: Some("anthropic".to_string()),
202 api_key: env.ANTHROPIC_AUTH_TOKEN,
203 base_url: env.ANTHROPIC_BASE_URL,
204 model: env.ANTHROPIC_MODEL,
205 think: true,
206 markdown: true,
207 max_tokens: 16384,
208 context_size: None,
209 multi_model: None,
210 plan_model: env.ANTHROPIC_REASONING_MODEL,
211 compress_model: env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
212 fast_model: None,
213 approve_mode,
214 };
215
216 Some(config)
217 }
218
219 pub fn load() -> Self {
225 let matrix_config = Self::load_matrix_config();
227 let claude_config = Self::load_ccswitch_config();
229
230 match (matrix_config, claude_config) {
232 (Some(mx), Some(cc)) => {
233 let needs_fallback =
235 mx.api_key.is_none() || mx.model.is_none() || mx.base_url.is_none();
236
237 let approve_mode = mx.approve_mode.or(Some("ask".to_string()));
240
241 let merged = Self {
242 provider: mx.provider.or(cc.provider),
243 api_key: mx.api_key.or(cc.api_key),
244 base_url: mx.base_url.or(cc.base_url),
245 model: mx.model.or(cc.model),
246 think: mx.think,
247 markdown: mx.markdown,
248 max_tokens: mx.max_tokens,
249 context_size: mx.context_size.or(cc.context_size),
250 multi_model: mx.multi_model.or(cc.multi_model),
251 plan_model: mx.plan_model.or(cc.plan_model),
252 compress_model: mx.compress_model.or(cc.compress_model),
253 fast_model: mx.fast_model.or(cc.fast_model),
254 approve_mode,
255 };
256
257 if needs_fallback {
259 println!(
260 "[config: ~/.matrix/config.json + fallback from ~/.claude/settings.json]"
261 );
262 } else {
263 println!("[config: ~/.matrix/config.json]");
264 }
265 merged
266 }
267 (Some(mx), None) => {
268 println!("[config: ~/.matrix/config.json]");
269 if mx.approve_mode.is_none() {
271 Self {
272 approve_mode: Some("ask".to_string()),
273 ..mx
274 }
275 } else {
276 mx
277 }
278 }
279 (None, Some(cc)) => {
280 println!("[config: ~/.claude/settings.json (Claude Code)]");
281 Self {
284 approve_mode: Some("ask".to_string()),
285 ..cc
286 }
287 }
288 (None, None) => {
289 println!("[config: using defaults and environment variables]");
290 Self::default()
291 }
292 }
293 }
294
295 pub fn get_api_key(&self, provider: &str) -> Option<String> {
298 match provider {
299 "openai" => env::var("OPENAI_API_KEY").ok(),
300 _ => env::var("ANTHROPIC_AUTH_TOKEN")
301 .or_else(|_| env::var("ANTHROPIC_API_KEY")) .ok(),
303 }
304 .or(self.api_key.clone())
305 }
306
307 pub fn get_model(&self, provider: &str) -> String {
310 env::var("ANTHROPIC_MODEL")
311 .or_else(|_| env::var("MODEL_NAME")) .ok()
313 .or(self.model.clone())
314 .unwrap_or_else(|| match provider {
315 "openai" => "gpt-4o".to_string(),
316 _ => "claude-sonnet-4-20250514".to_string(),
317 })
318 }
319
320 pub fn get_base_url(&self, provider: &str) -> String {
323 env::var("ANTHROPIC_BASE_URL")
324 .or_else(|_| env::var("BASE_URL")) .ok()
326 .or(self.base_url.clone())
327 .unwrap_or_else(|| match provider {
328 "openai" => "https://api.openai.com/v1".to_string(),
329 _ => "https://api.anthropic.com".to_string(),
330 })
331 }
332
333 pub fn save(&self) -> anyhow::Result<()> {
335 let path = Self::matrix_config_path()
336 .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
337
338 let dir = path
340 .parent()
341 .ok_or_else(|| anyhow::anyhow!("Invalid path"))?;
342 if !dir.exists() {
343 std::fs::create_dir_all(dir)?;
344 }
345
346 let content = serde_json::to_string_pretty(self)?;
347 std::fs::write(&path, content)?;
348
349 println!("[config saved to ~/.matrix/config.json]");
350 Ok(())
351 }
352
353 pub fn is_api_configured(&self) -> bool {
356 self.api_key.is_some() || env::var("ANTHROPIC_AUTH_TOKEN").ok().is_some()
357 }
358}
359
360pub fn create_default_config() -> anyhow::Result<()> {
363 let config = MatrixConfig {
364 provider: Some("anthropic".to_string()),
365 api_key: None, base_url: None, model: None, think: true,
369 markdown: true,
370 max_tokens: 16384,
371 context_size: None,
372 multi_model: Some(false),
373 plan_model: None, compress_model: None, fast_model: None,
376 approve_mode: Some("ask".to_string()),
377 };
378
379 config.save()?;
380 println!("\nConfig file created at ~/.matrix/config.json");
381 println!("Fields use Claude Code naming convention:");
382 println!(" ANTHROPIC_AUTH_TOKEN - API key");
383 println!(" ANTHROPIC_BASE_URL - API endpoint");
384 println!(" ANTHROPIC_MODEL - Main model");
385 println!(" ANTHROPIC_REASONING_MODEL - Planning model");
386 println!(" ANTHROPIC_DEFAULT_HAIKU_MODEL - Compression model");
387 println!("\nLeave fields as null to fallback to ~/.claude/settings.json");
388 Ok(())
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn test_default_config_values() {
397 let config = MatrixConfig {
398 provider: None,
399 api_key: None,
400 base_url: None,
401 model: None,
402 think: true,
403 markdown: true,
404 max_tokens: 16384,
405 context_size: None,
406 multi_model: None,
407 plan_model: None,
408 compress_model: None,
409 fast_model: None,
410 approve_mode: None,
411 };
412 assert!(config.api_key.is_none());
413 assert!(config.model.is_none());
414 assert!(config.think);
415 assert!(config.markdown);
416 assert_eq!(config.max_tokens, 16384);
417 }
418
419 #[test]
420 fn test_claude_code_field_names() {
421 let json = r#"{
423 "ANTHROPIC_AUTH_TOKEN": "test-key",
424 "ANTHROPIC_BASE_URL": "https://test.com",
425 "ANTHROPIC_MODEL": "test-model",
426 "ANTHROPIC_REASONING_MODEL": "reasoning-model",
427 "ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-model"
428 }"#;
429
430 let config: MatrixConfig = serde_json::from_str(json).unwrap();
431 assert_eq!(config.api_key, Some("test-key".to_string()));
432 assert_eq!(config.base_url, Some("https://test.com".to_string()));
433 assert_eq!(config.model, Some("test-model".to_string()));
434 assert_eq!(config.plan_model, Some("reasoning-model".to_string()));
435 assert_eq!(config.compress_model, Some("haiku-model".to_string()));
436 }
437
438 #[test]
439 fn test_serialization_uses_claude_names() {
440 let config = MatrixConfig {
441 api_key: Some("key".to_string()),
442 model: Some("model".to_string()),
443 ..Default::default()
444 };
445
446 let json = serde_json::to_string(&config).unwrap();
447 assert!(json.contains("ANTHROPIC_AUTH_TOKEN"));
449 assert!(json.contains("ANTHROPIC_MODEL"));
450 }
451}