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 if let Some(flag) = config.disable_prompt_caching {
243 self.env
244 .insert("DISABLE_PROMPT_CACHING".to_string(), flag.to_string());
245 }
246
247 if let Some(flag) = config.claude_code_disable_experimental_betas {
248 self.env.insert(
249 "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS".to_string(),
250 flag.to_string(),
251 );
252 }
253
254 if let Some(flag) = config.disable_autoupdater {
255 self.env
256 .insert("DISABLE_AUTOUPDATER".to_string(), flag.to_string());
257 }
258 }
259
260 pub fn remove_anthropic_env(&mut self) {
265 if self.env.is_empty() {
267 self.env = BTreeMap::new();
268 }
269
270 let env_fields = Configuration::get_env_field_names();
272 for field in &env_fields {
273 self.env.remove(*field);
274 }
275 }
276
277 pub fn switch_to_config_with_mode(
291 &mut self,
292 config: &Configuration,
293 mode: StorageMode,
294 custom_dir: Option<&str>,
295 ) -> Result<()> {
296 match mode {
297 StorageMode::Env => {
298 let clearable_env_fields = Configuration::get_clearable_env_field_names();
304
305 let mut removed_fields = Vec::new();
306
307 for field in &clearable_env_fields {
309 if self.env.remove(*field).is_some() {
310 removed_fields.push(field.to_string());
311 }
312 }
313
314 if !removed_fields.is_empty() {
316 eprintln!("๐งน Cleaning settings.json for env mode:");
317 eprintln!(" Removed configurable fields:");
318 for field in &removed_fields {
319 eprintln!(" - {}", field);
320 }
321 eprintln!();
322 eprintln!(
323 " Settings.json cleaned. Environment variables will be used instead."
324 );
325
326 self.save(custom_dir)?;
328 }
329
330 }
332 StorageMode::Config => {
333 let anthropic_env_fields = Configuration::get_env_field_names();
339
340 let mut conflicts = Vec::new();
341
342 for field in &anthropic_env_fields {
345 if std::env::var(field).is_ok() {
346 conflicts.push(format!("system env: {}", field));
347 }
348 }
349
350 if !conflicts.is_empty() {
352 eprintln!("โ Conflict detected in config mode:");
353 eprintln!(" Found existing Anthropic configuration in system environment:");
354 for conflict in &conflicts {
355 eprintln!(" - {}", conflict);
356 }
357 eprintln!();
358 eprintln!(
359 " Config mode cannot work when Anthropic environment variables are set in system env."
360 );
361 eprintln!(" Please:");
362 eprintln!(" 1. Unset system environment variables, or");
363 eprintln!(" 2. Use 'env' mode instead");
364 return Err(anyhow::anyhow!(
365 "Config mode conflict: Anthropic environment variables exist in system env"
366 ));
367 }
368
369 self.switch_to_config(config);
371
372 if let Some(max_thinking_tokens) = config.max_thinking_tokens {
374 self.env.insert(
375 "ANTHROPIC_MAX_THINKING_TOKENS".to_string(),
376 max_thinking_tokens.to_string(),
377 );
378 }
379
380 if let Some(timeout) = config.api_timeout_ms {
381 self.env
382 .insert("API_TIMEOUT_MS".to_string(), timeout.to_string());
383 }
384
385 if let Some(flag) = config.claude_code_disable_nonessential_traffic {
386 self.env.insert(
387 "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC".to_string(),
388 flag.to_string(),
389 );
390 }
391
392 if let Some(model) = &config.anthropic_default_sonnet_model
393 && !model.is_empty()
394 {
395 self.env
396 .insert("ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), model.clone());
397 }
398
399 if let Some(model) = &config.anthropic_default_opus_model
400 && !model.is_empty()
401 {
402 self.env
403 .insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), model.clone());
404 }
405
406 if let Some(model) = &config.anthropic_default_haiku_model
407 && !model.is_empty()
408 {
409 self.env
410 .insert("ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), model.clone());
411 }
412
413 self.save(custom_dir)?;
414 }
415 }
416
417 Ok(())
418 }
419
420 pub fn write_current_alias_for_pid(alias: &str) -> Result<()> {
431 let pid = std::process::id();
432 let path = Self::get_current_alias_for_pid(pid)?;
433
434 if let Some(parent) = path.parent() {
435 fs::create_dir_all(parent)
436 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
437 }
438
439 fs::write(&path, alias)
440 .with_context(|| format!("Failed to write current alias to {}", path.display()))?;
441
442 Ok(())
443 }
444
445 pub fn clear_current_alias_for_pid() -> Result<()> {
449 let pid = std::process::id();
450 let path = Self::get_current_alias_for_pid(pid)?;
451 if path.exists() {
452 fs::remove_file(&path)
453 .with_context(|| format!("Failed to remove {}", path.display()))?;
454 }
455 Ok(())
456 }
457
458 fn get_current_alias_for_pid(pid: u32) -> Result<std::path::PathBuf> {
460 let config_file = crate::config::get_config_storage_path()?;
461 let config_dir = config_file
462 .parent()
463 .context("Could not get config directory")?;
464 let alias_dir = config_dir.join("cc_auto_tmp_pid");
465 let _ = fs::create_dir_all(&alias_dir);
467 Ok(alias_dir.join(format!("{PER_PID_ALIAS_PREFIX}{pid}")))
468 }
469
470 pub fn cleanup_orphan_alias_files() -> Result<()> {
476 let config_file = crate::config::get_config_storage_path()?;
477 let config_dir = config_file
478 .parent()
479 .context("Could not get config directory")?;
480
481 let legacy_global = config_dir.join("cc_auto_switch_current_alias");
483 if legacy_global.exists() {
484 let _ = fs::remove_file(&legacy_global);
485 }
486
487 if let Ok(entries) = fs::read_dir(config_dir) {
489 for entry in entries.flatten() {
490 let file_name_str = entry.file_name();
491 let file_name = file_name_str.to_string_lossy();
492 if let Some(pid_str) = file_name.strip_prefix(PER_PID_ALIAS_PREFIX)
493 && let Ok(pid) = pid_str.parse::<u32>()
494 && !Self::is_process_running(pid)
495 {
496 let _ = fs::remove_file(entry.path());
497 }
498 }
499 }
500
501 let alias_dir = config_dir.join("cc_auto_tmp_pid");
503 if alias_dir.exists() {
504 for entry in fs::read_dir(&alias_dir)? {
505 let entry = entry?;
506 let file_name = entry.file_name();
507 let file_name_str = file_name.to_string_lossy();
508
509 if let Some(pid_str) = file_name_str.strip_prefix(PER_PID_ALIAS_PREFIX)
510 && let Ok(pid) = pid_str.parse::<u32>()
511 && !Self::is_process_running(pid)
512 {
513 let _ = fs::remove_file(entry.path());
514 }
515 }
516 }
517
518 Ok(())
519 }
520
521 #[cfg(unix)]
522 fn is_process_running(pid: u32) -> bool {
523 unsafe { libc::kill(pid as i32, 0) == 0 }
524 }
525
526 #[cfg(not(unix))]
527 fn is_process_running(_pid: u32) -> bool {
528 false
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 #[test]
537 fn test_strip_trailing_commas_simple() {
538 let input = r#"{"a": 1,}"#;
539 let expected = r#"{"a": 1}"#;
540 assert_eq!(strip_trailing_commas(input), expected);
541 }
542
543 #[test]
544 fn test_strip_trailing_commas_nested_object() {
545 let input = r#"{"env": {"KEY": "value",},}"#;
546 let expected = r#"{"env": {"KEY": "value"}}"#;
547 assert_eq!(strip_trailing_commas(input), expected);
548 }
549
550 #[test]
551 fn test_strip_trailing_commas_array() {
552 let input = r#"{"items": [1, 2, 3,],}"#;
553 let expected = r#"{"items": [1, 2, 3]}"#;
554 assert_eq!(strip_trailing_commas(input), expected);
555 }
556
557 #[test]
558 fn test_strip_trailing_commas_multiline() {
559 let input = r#"{
560 "env": {
561 "KEY": "value",
562 },
563}"#;
564 let expected = r#"{
565 "env": {
566 "KEY": "value"
567 }
568}"#;
569 assert_eq!(strip_trailing_commas(input), expected);
570 }
571
572 #[test]
573 fn test_strip_trailing_commas_no_trailing() {
574 let input = r#"{"a": 1, "b": 2}"#;
575 assert_eq!(strip_trailing_commas(input), input);
576 }
577
578 #[test]
579 fn test_strip_trailing_commas_complex() {
580 let input = r#"{
581 "env": {
582 "ANTHROPIC_AUTH_TOKEN": "token",
583 "ANTHROPIC_BASE_URL": "https://api.example.com",
584 },
585 "model": "claude-3-opus",
586}"#;
587 let expected = r#"{
588 "env": {
589 "ANTHROPIC_AUTH_TOKEN": "token",
590 "ANTHROPIC_BASE_URL": "https://api.example.com"
591 },
592 "model": "claude-3-opus"
593}"#;
594 assert_eq!(strip_trailing_commas(input), expected);
595 }
596
597 #[test]
598 fn test_strip_trailing_commas_preserves_inner_commas() {
599 let input = r#"{"a": 1, "b": 2, "c": 3,}"#;
600 let expected = r#"{"a": 1, "b": 2, "c": 3}"#;
601 assert_eq!(strip_trailing_commas(input), expected);
602 }
603
604 #[test]
605 fn test_per_pid_alias_write_and_clear() {
606 use std::process;
607
608 let pid = process::id();
609 let test_alias = "test-alias-123";
610
611 ClaudeSettings::write_current_alias_for_pid(test_alias).unwrap();
613
614 let path = ClaudeSettings::get_current_alias_for_pid(pid).unwrap();
616 assert!(path.exists());
617 let content = fs::read_to_string(&path).unwrap();
618 assert_eq!(content, test_alias);
619
620 ClaudeSettings::clear_current_alias_for_pid().unwrap();
622
623 assert!(!path.exists());
625 }
626
627 #[test]
628 fn test_per_pid_alias_path_format() {
629 let pid = 12345u32;
630 let path = ClaudeSettings::get_current_alias_for_pid(pid).unwrap();
631 let filename = path.file_name().unwrap().to_str().unwrap();
632 assert_eq!(filename, "cc_auto_switch_alias_12345");
633 }
634}