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(alias: &str) -> Result<()> {
414 let path = Self::get_current_alias_path()?;
415
416 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 read_current_alias() -> Option<String> {
432 let path = Self::get_current_alias_path().ok()?;
433 let content = fs::read_to_string(&path).ok()?;
434 let trimmed = content.trim();
435 if trimmed.is_empty() {
436 None
437 } else {
438 Some(trimmed.to_string())
439 }
440 }
441
442 pub fn clear_current_alias() -> Result<()> {
446 let path = Self::get_current_alias_path()?;
447 if path.exists() {
448 fs::remove_file(&path)
449 .with_context(|| format!("Failed to remove {}", path.display()))?;
450 }
451 Ok(())
452 }
453
454 fn get_current_alias_path() -> Result<std::path::PathBuf> {
458 let config_file = crate::config::get_config_storage_path()?;
459 let config_dir = config_file
460 .parent()
461 .context("Could not get config directory")?;
462 Ok(config_dir.join("cc_auto_switch_current_alias"))
463 }
464
465 pub fn write_current_alias_for_pid(alias: &str) -> Result<()> {
476 let pid = std::process::id();
477 let path = Self::get_current_alias_for_pid(pid)?;
478
479 if let Some(parent) = path.parent() {
480 fs::create_dir_all(parent)
481 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
482 }
483
484 fs::write(&path, alias)
485 .with_context(|| format!("Failed to write current alias to {}", path.display()))?;
486
487 Ok(())
488 }
489
490 pub fn clear_current_alias_for_pid() -> Result<()> {
494 let pid = std::process::id();
495 let path = Self::get_current_alias_for_pid(pid)?;
496 if path.exists() {
497 fs::remove_file(&path)
498 .with_context(|| format!("Failed to remove {}", path.display()))?;
499 }
500 Ok(())
501 }
502
503 fn get_current_alias_for_pid(pid: u32) -> Result<std::path::PathBuf> {
505 let config_file = crate::config::get_config_storage_path()?;
506 let config_dir = config_file
507 .parent()
508 .context("Could not get config directory")?;
509 Ok(config_dir.join(format!("{PER_PID_ALIAS_PREFIX}{pid}")))
510 }
511
512 pub fn cleanup_orphan_alias_files() -> Result<()> {
518 let config_file = crate::config::get_config_storage_path()?;
519 let config_dir = config_file
520 .parent()
521 .context("Could not get config directory")?;
522
523 for entry in fs::read_dir(config_dir)? {
524 let entry = entry?;
525 let file_name = entry.file_name();
526 let file_name_str = file_name.to_string_lossy();
527
528 if let Some(pid_str) = file_name_str.strip_prefix(PER_PID_ALIAS_PREFIX)
529 && let Ok(pid) = pid_str.parse::<u32>()
530 && !Self::is_process_running(pid)
531 {
532 let _ = fs::remove_file(entry.path());
533 }
534 }
535
536 Ok(())
537 }
538
539 #[cfg(unix)]
540 fn is_process_running(pid: u32) -> bool {
541 unsafe { libc::kill(pid as i32, 0) == 0 }
542 }
543
544 #[cfg(not(unix))]
545 fn is_process_running(_pid: u32) -> bool {
546 false
547 }
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553
554 #[test]
555 fn test_strip_trailing_commas_simple() {
556 let input = r#"{"a": 1,}"#;
557 let expected = r#"{"a": 1}"#;
558 assert_eq!(strip_trailing_commas(input), expected);
559 }
560
561 #[test]
562 fn test_strip_trailing_commas_nested_object() {
563 let input = r#"{"env": {"KEY": "value",},}"#;
564 let expected = r#"{"env": {"KEY": "value"}}"#;
565 assert_eq!(strip_trailing_commas(input), expected);
566 }
567
568 #[test]
569 fn test_strip_trailing_commas_array() {
570 let input = r#"{"items": [1, 2, 3,],}"#;
571 let expected = r#"{"items": [1, 2, 3]}"#;
572 assert_eq!(strip_trailing_commas(input), expected);
573 }
574
575 #[test]
576 fn test_strip_trailing_commas_multiline() {
577 let input = r#"{
578 "env": {
579 "KEY": "value",
580 },
581}"#;
582 let expected = r#"{
583 "env": {
584 "KEY": "value"
585 }
586}"#;
587 assert_eq!(strip_trailing_commas(input), expected);
588 }
589
590 #[test]
591 fn test_strip_trailing_commas_no_trailing() {
592 let input = r#"{"a": 1, "b": 2}"#;
593 assert_eq!(strip_trailing_commas(input), input);
594 }
595
596 #[test]
597 fn test_strip_trailing_commas_complex() {
598 let input = r#"{
599 "env": {
600 "ANTHROPIC_AUTH_TOKEN": "token",
601 "ANTHROPIC_BASE_URL": "https://api.example.com",
602 },
603 "model": "claude-3-opus",
604}"#;
605 let expected = r#"{
606 "env": {
607 "ANTHROPIC_AUTH_TOKEN": "token",
608 "ANTHROPIC_BASE_URL": "https://api.example.com"
609 },
610 "model": "claude-3-opus"
611}"#;
612 assert_eq!(strip_trailing_commas(input), expected);
613 }
614
615 #[test]
616 fn test_strip_trailing_commas_preserves_inner_commas() {
617 let input = r#"{"a": 1, "b": 2, "c": 3,}"#;
618 let expected = r#"{"a": 1, "b": 2, "c": 3}"#;
619 assert_eq!(strip_trailing_commas(input), expected);
620 }
621
622 #[test]
623 fn test_per_pid_alias_write_and_clear() {
624 use std::process;
625
626 let pid = process::id();
627 let test_alias = "test-alias-123";
628
629 ClaudeSettings::write_current_alias_for_pid(test_alias).unwrap();
631
632 let path = ClaudeSettings::get_current_alias_for_pid(pid).unwrap();
634 assert!(path.exists());
635 let content = fs::read_to_string(&path).unwrap();
636 assert_eq!(content, test_alias);
637
638 ClaudeSettings::clear_current_alias_for_pid().unwrap();
640
641 assert!(!path.exists());
643 }
644
645 #[test]
646 fn test_per_pid_alias_path_format() {
647 let pid = 12345u32;
648 let path = ClaudeSettings::get_current_alias_for_pid(pid).unwrap();
649 let filename = path.file_name().unwrap().to_str().unwrap();
650 assert_eq!(filename, "cc_auto_switch_alias_12345");
651 }
652}