1use anyhow::{Context, Result};
2use std::collections::BTreeMap;
3use std::fs;
4
5use crate::config::types::{ClaudeSettings, Configuration, StorageMode};
6use crate::utils::get_claude_settings_path;
7
8const PER_PID_ALIAS_PREFIX: &str = "cc_auto_switch_alias_";
9
10fn strip_trailing_commas(json: &str) -> String {
15 let mut result = String::with_capacity(json.len());
18 let chars: Vec<char> = json.chars().collect();
19 let mut i = 0;
20
21 while i < chars.len() {
22 let c = chars[i];
23
24 if c == ',' {
26 let mut j = i + 1;
28 while j < chars.len() && chars[j].is_whitespace() {
29 j += 1;
30 }
31
32 if j < chars.len() && (chars[j] == '}' || chars[j] == ']') {
33 i += 1;
35 continue;
36 }
37 }
38
39 result.push(c);
40 i += 1;
41 }
42
43 result
44}
45
46impl ClaudeSettings {
47 pub fn load(custom_dir: Option<&str>) -> Result<Self> {
59 let path = get_claude_settings_path(custom_dir)?;
60
61 if !path.exists() {
62 let default_settings = ClaudeSettings::default();
64 default_settings.save(custom_dir)?;
65 return Ok(default_settings);
66 }
67
68 let content = fs::read_to_string(&path)
69 .with_context(|| format!("Failed to read Claude settings from {}", path.display()))?;
70
71 let mut settings: ClaudeSettings = if content.trim().is_empty() {
73 ClaudeSettings::default()
74 } else {
75 let cleaned_content = strip_trailing_commas(&content);
77
78 match serde_json::from_str(&cleaned_content) {
80 Ok(s) => s,
81 Err(e) => {
82 let error_msg = format!(
84 "Failed to parse Claude settings JSON at {}:\n {}\n\n\
85 This usually means the JSON file has invalid syntax.\n\
86 Common issues:\n\
87 - Trailing commas (e.g., {{\"key\": \"value\",}})\n\
88 - Missing quotes around keys or values\n\
89 - Unescaped special characters in strings\n\n\
90 Please fix the JSON syntax in the file.",
91 path.display(),
92 e
93 );
94 return Err(anyhow::anyhow!("{}", error_msg));
95 }
96 }
97 };
98
99 if settings.env.is_empty() && !content.contains("\"env\"") {
101 settings.env = BTreeMap::new();
102 }
103
104 Ok(settings)
105 }
106
107 pub fn save(&self, custom_dir: Option<&str>) -> Result<()> {
119 let path = get_claude_settings_path(custom_dir)?;
120
121 if let Some(parent) = path.parent() {
123 fs::create_dir_all(parent)
124 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
125 }
126
127 let settings_to_save = self;
129
130 let json = serde_json::to_string_pretty(&settings_to_save)
131 .with_context(|| "Failed to serialize Claude settings")?;
132
133 fs::write(&path, json).with_context(|| format!("Failed to write to {}", path.display()))?;
134
135 Ok(())
136 }
137
138 pub fn switch_to_config(&mut self, config: &Configuration) {
146 if self.env.is_empty() {
148 self.env = BTreeMap::new();
149 }
150
151 let env_fields = Configuration::get_env_field_names();
153 for field in &env_fields {
154 self.env.remove(*field);
155 }
156
157 self.env
159 .insert("ANTHROPIC_AUTH_TOKEN".to_string(), config.token.clone());
160 self.env
161 .insert("ANTHROPIC_BASE_URL".to_string(), config.url.clone());
162
163 if let Some(model) = &config.model
165 && !model.is_empty()
166 {
167 self.env
168 .insert("ANTHROPIC_MODEL".to_string(), model.clone());
169 }
170
171 if let Some(small_fast_model) = &config.small_fast_model
172 && !small_fast_model.is_empty()
173 {
174 self.env.insert(
175 "ANTHROPIC_SMALL_FAST_MODEL".to_string(),
176 small_fast_model.clone(),
177 );
178 }
179
180 if let Some(max_thinking_tokens) = config.max_thinking_tokens {
182 self.env.insert(
183 "ANTHROPIC_MAX_THINKING_TOKENS".to_string(),
184 max_thinking_tokens.to_string(),
185 );
186 }
187
188 if let Some(timeout) = config.api_timeout_ms {
189 self.env
190 .insert("API_TIMEOUT_MS".to_string(), timeout.to_string());
191 }
192
193 if let Some(flag) = config.claude_code_disable_nonessential_traffic {
194 self.env.insert(
195 "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC".to_string(),
196 flag.to_string(),
197 );
198 }
199
200 if let Some(model) = &config.anthropic_default_sonnet_model
201 && !model.is_empty()
202 {
203 self.env
204 .insert("ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), model.clone());
205 }
206
207 if let Some(model) = &config.anthropic_default_opus_model
208 && !model.is_empty()
209 {
210 self.env
211 .insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), model.clone());
212 }
213
214 if let Some(model) = &config.anthropic_default_haiku_model
215 && !model.is_empty()
216 {
217 self.env
218 .insert("ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), model.clone());
219 }
220
221 if let Some(model) = &config.claude_code_subagent_model
222 && !model.is_empty()
223 {
224 self.env
225 .insert("CLAUDE_CODE_SUBAGENT_MODEL".to_string(), model.clone());
226 }
227
228 if let Some(flag) = config.claude_code_disable_nonstreaming_fallback {
229 self.env.insert(
230 "CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK".to_string(),
231 flag.to_string(),
232 );
233 }
234
235 if let Some(level) = &config.claude_code_effort_level
236 && !level.is_empty()
237 {
238 self.env
239 .insert("CLAUDE_CODE_EFFORT_LEVEL".to_string(), level.clone());
240 }
241 }
242
243 pub fn remove_anthropic_env(&mut self) {
248 if self.env.is_empty() {
250 self.env = BTreeMap::new();
251 }
252
253 let env_fields = Configuration::get_env_field_names();
255 for field in &env_fields {
256 self.env.remove(*field);
257 }
258 }
259
260 pub fn switch_to_config_with_mode(
274 &mut self,
275 config: &Configuration,
276 mode: StorageMode,
277 custom_dir: Option<&str>,
278 ) -> Result<()> {
279 match mode {
280 StorageMode::Env => {
281 let clearable_env_fields = Configuration::get_clearable_env_field_names();
287
288 let mut removed_fields = Vec::new();
289
290 for field in &clearable_env_fields {
292 if self.env.remove(*field).is_some() {
293 removed_fields.push(field.to_string());
294 }
295 }
296
297 if !removed_fields.is_empty() {
299 eprintln!("🧹 Cleaning settings.json for env mode:");
300 eprintln!(" Removed configurable fields:");
301 for field in &removed_fields {
302 eprintln!(" - {}", field);
303 }
304 eprintln!();
305 eprintln!(
306 " Settings.json cleaned. Environment variables will be used instead."
307 );
308
309 self.save(custom_dir)?;
311 }
312
313 }
315 StorageMode::Config => {
316 let anthropic_env_fields = Configuration::get_env_field_names();
322
323 let mut conflicts = Vec::new();
324
325 for field in &anthropic_env_fields {
328 if std::env::var(field).is_ok() {
329 conflicts.push(format!("system env: {}", field));
330 }
331 }
332
333 if !conflicts.is_empty() {
335 eprintln!("❌ Conflict detected in config mode:");
336 eprintln!(" Found existing Anthropic configuration in system environment:");
337 for conflict in &conflicts {
338 eprintln!(" - {}", conflict);
339 }
340 eprintln!();
341 eprintln!(
342 " Config mode cannot work when Anthropic environment variables are set in system env."
343 );
344 eprintln!(" Please:");
345 eprintln!(" 1. Unset system environment variables, or");
346 eprintln!(" 2. Use 'env' mode instead");
347 return Err(anyhow::anyhow!(
348 "Config mode conflict: Anthropic environment variables exist in system env"
349 ));
350 }
351
352 self.switch_to_config(config);
354
355 if let Some(max_thinking_tokens) = config.max_thinking_tokens {
357 self.env.insert(
358 "ANTHROPIC_MAX_THINKING_TOKENS".to_string(),
359 max_thinking_tokens.to_string(),
360 );
361 }
362
363 if let Some(timeout) = config.api_timeout_ms {
364 self.env
365 .insert("API_TIMEOUT_MS".to_string(), timeout.to_string());
366 }
367
368 if let Some(flag) = config.claude_code_disable_nonessential_traffic {
369 self.env.insert(
370 "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC".to_string(),
371 flag.to_string(),
372 );
373 }
374
375 if let Some(model) = &config.anthropic_default_sonnet_model
376 && !model.is_empty()
377 {
378 self.env
379 .insert("ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), model.clone());
380 }
381
382 if let Some(model) = &config.anthropic_default_opus_model
383 && !model.is_empty()
384 {
385 self.env
386 .insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), model.clone());
387 }
388
389 if let Some(model) = &config.anthropic_default_haiku_model
390 && !model.is_empty()
391 {
392 self.env
393 .insert("ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), model.clone());
394 }
395
396 self.save(custom_dir)?;
397 }
398 }
399
400 Ok(())
401 }
402
403 pub fn write_current_alias_for_pid(alias: &str) -> Result<()> {
414 let pid = std::process::id();
415 let path = Self::get_current_alias_for_pid(pid)?;
416
417 if let Some(parent) = path.parent() {
418 fs::create_dir_all(parent)
419 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
420 }
421
422 fs::write(&path, alias)
423 .with_context(|| format!("Failed to write current alias to {}", path.display()))?;
424
425 Ok(())
426 }
427
428 pub fn clear_current_alias_for_pid() -> Result<()> {
432 let pid = std::process::id();
433 let path = Self::get_current_alias_for_pid(pid)?;
434 if path.exists() {
435 fs::remove_file(&path)
436 .with_context(|| format!("Failed to remove {}", path.display()))?;
437 }
438 Ok(())
439 }
440
441 fn get_current_alias_for_pid(pid: u32) -> Result<std::path::PathBuf> {
443 let config_file = crate::config::get_config_storage_path()?;
444 let config_dir = config_file
445 .parent()
446 .context("Could not get config directory")?;
447 Ok(config_dir.join(format!("{PER_PID_ALIAS_PREFIX}{pid}")))
448 }
449
450 pub fn cleanup_orphan_alias_files() -> Result<()> {
456 let config_file = crate::config::get_config_storage_path()?;
457 let config_dir = config_file
458 .parent()
459 .context("Could not get config directory")?;
460
461 let legacy_global = config_dir.join("cc_auto_switch_current_alias");
462 if legacy_global.exists() {
463 let _ = fs::remove_file(&legacy_global);
464 }
465
466 for entry in fs::read_dir(config_dir)? {
467 let entry = entry?;
468 let file_name = entry.file_name();
469 let file_name_str = file_name.to_string_lossy();
470
471 if let Some(pid_str) = file_name_str.strip_prefix(PER_PID_ALIAS_PREFIX)
472 && let Ok(pid) = pid_str.parse::<u32>()
473 && !Self::is_process_running(pid)
474 {
475 let _ = fs::remove_file(entry.path());
476 }
477 }
478
479 Ok(())
480 }
481
482 #[cfg(unix)]
483 fn is_process_running(pid: u32) -> bool {
484 unsafe { libc::kill(pid as i32, 0) == 0 }
485 }
486
487 #[cfg(not(unix))]
488 fn is_process_running(_pid: u32) -> bool {
489 false
490 }
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496
497 #[test]
498 fn test_strip_trailing_commas_simple() {
499 let input = r#"{"a": 1,}"#;
500 let expected = r#"{"a": 1}"#;
501 assert_eq!(strip_trailing_commas(input), expected);
502 }
503
504 #[test]
505 fn test_strip_trailing_commas_nested_object() {
506 let input = r#"{"env": {"KEY": "value",},}"#;
507 let expected = r#"{"env": {"KEY": "value"}}"#;
508 assert_eq!(strip_trailing_commas(input), expected);
509 }
510
511 #[test]
512 fn test_strip_trailing_commas_array() {
513 let input = r#"{"items": [1, 2, 3,],}"#;
514 let expected = r#"{"items": [1, 2, 3]}"#;
515 assert_eq!(strip_trailing_commas(input), expected);
516 }
517
518 #[test]
519 fn test_strip_trailing_commas_multiline() {
520 let input = r#"{
521 "env": {
522 "KEY": "value",
523 },
524}"#;
525 let expected = r#"{
526 "env": {
527 "KEY": "value"
528 }
529}"#;
530 assert_eq!(strip_trailing_commas(input), expected);
531 }
532
533 #[test]
534 fn test_strip_trailing_commas_no_trailing() {
535 let input = r#"{"a": 1, "b": 2}"#;
536 assert_eq!(strip_trailing_commas(input), input);
537 }
538
539 #[test]
540 fn test_strip_trailing_commas_complex() {
541 let input = r#"{
542 "env": {
543 "ANTHROPIC_AUTH_TOKEN": "token",
544 "ANTHROPIC_BASE_URL": "https://api.example.com",
545 },
546 "model": "claude-3-opus",
547}"#;
548 let expected = r#"{
549 "env": {
550 "ANTHROPIC_AUTH_TOKEN": "token",
551 "ANTHROPIC_BASE_URL": "https://api.example.com"
552 },
553 "model": "claude-3-opus"
554}"#;
555 assert_eq!(strip_trailing_commas(input), expected);
556 }
557
558 #[test]
559 fn test_strip_trailing_commas_preserves_inner_commas() {
560 let input = r#"{"a": 1, "b": 2, "c": 3,}"#;
561 let expected = r#"{"a": 1, "b": 2, "c": 3}"#;
562 assert_eq!(strip_trailing_commas(input), expected);
563 }
564
565 #[test]
566 fn test_per_pid_alias_write_and_clear() {
567 use std::process;
568
569 let pid = process::id();
570 let test_alias = "test-alias-123";
571
572 ClaudeSettings::write_current_alias_for_pid(test_alias).unwrap();
574
575 let path = ClaudeSettings::get_current_alias_for_pid(pid).unwrap();
577 assert!(path.exists());
578 let content = fs::read_to_string(&path).unwrap();
579 assert_eq!(content, test_alias);
580
581 ClaudeSettings::clear_current_alias_for_pid().unwrap();
583
584 assert!(!path.exists());
586 }
587
588 #[test]
589 fn test_per_pid_alias_path_format() {
590 let pid = 12345u32;
591 let path = ClaudeSettings::get_current_alias_for_pid(pid).unwrap();
592 let filename = path.file_name().unwrap().to_str().unwrap();
593 assert_eq!(filename, "cc_auto_switch_alias_12345");
594 }
595}