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
8fn strip_trailing_commas(json: &str) -> String {
13 let mut result = String::with_capacity(json.len());
16 let chars: Vec<char> = json.chars().collect();
17 let mut i = 0;
18
19 while i < chars.len() {
20 let c = chars[i];
21
22 if c == ',' {
24 let mut j = i + 1;
26 while j < chars.len() && chars[j].is_whitespace() {
27 j += 1;
28 }
29
30 if j < chars.len() && (chars[j] == '}' || chars[j] == ']') {
31 i += 1;
33 continue;
34 }
35 }
36
37 result.push(c);
38 i += 1;
39 }
40
41 result
42}
43
44impl ClaudeSettings {
45 pub fn load(custom_dir: Option<&str>) -> Result<Self> {
57 let path = get_claude_settings_path(custom_dir)?;
58
59 if !path.exists() {
60 let default_settings = ClaudeSettings::default();
62 default_settings.save(custom_dir)?;
63 return Ok(default_settings);
64 }
65
66 let content = fs::read_to_string(&path)
67 .with_context(|| format!("Failed to read Claude settings from {}", path.display()))?;
68
69 let mut settings: ClaudeSettings = if content.trim().is_empty() {
71 ClaudeSettings::default()
72 } else {
73 let cleaned_content = strip_trailing_commas(&content);
75
76 match serde_json::from_str(&cleaned_content) {
78 Ok(s) => s,
79 Err(e) => {
80 let error_msg = format!(
82 "Failed to parse Claude settings JSON at {}:\n {}\n\n\
83 This usually means the JSON file has invalid syntax.\n\
84 Common issues:\n\
85 - Trailing commas (e.g., {{\"key\": \"value\",}})\n\
86 - Missing quotes around keys or values\n\
87 - Unescaped special characters in strings\n\n\
88 Please fix the JSON syntax in the file.",
89 path.display(),
90 e
91 );
92 return Err(anyhow::anyhow!("{}", error_msg));
93 }
94 }
95 };
96
97 if settings.env.is_empty() && !content.contains("\"env\"") {
99 settings.env = BTreeMap::new();
100 }
101
102 Ok(settings)
103 }
104
105 pub fn save(&self, custom_dir: Option<&str>) -> Result<()> {
117 let path = get_claude_settings_path(custom_dir)?;
118
119 if let Some(parent) = path.parent() {
121 fs::create_dir_all(parent)
122 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
123 }
124
125 let settings_to_save = self;
127
128 let json = serde_json::to_string_pretty(&settings_to_save)
129 .with_context(|| "Failed to serialize Claude settings")?;
130
131 fs::write(&path, json).with_context(|| format!("Failed to write to {}", path.display()))?;
132
133 Ok(())
134 }
135
136 pub fn switch_to_config(&mut self, config: &Configuration) {
144 if self.env.is_empty() {
146 self.env = BTreeMap::new();
147 }
148
149 let env_fields = Configuration::get_env_field_names();
151 for field in &env_fields {
152 self.env.remove(*field);
153 }
154
155 self.env
157 .insert("ANTHROPIC_AUTH_TOKEN".to_string(), config.token.clone());
158 self.env
159 .insert("ANTHROPIC_BASE_URL".to_string(), config.url.clone());
160
161 if let Some(model) = &config.model
163 && !model.is_empty()
164 {
165 self.env
166 .insert("ANTHROPIC_MODEL".to_string(), model.clone());
167 }
168
169 if let Some(small_fast_model) = &config.small_fast_model
170 && !small_fast_model.is_empty()
171 {
172 self.env.insert(
173 "ANTHROPIC_SMALL_FAST_MODEL".to_string(),
174 small_fast_model.clone(),
175 );
176 }
177
178 if let Some(max_thinking_tokens) = config.max_thinking_tokens {
180 self.env.insert(
181 "ANTHROPIC_MAX_THINKING_TOKENS".to_string(),
182 max_thinking_tokens.to_string(),
183 );
184 }
185
186 if let Some(timeout) = config.api_timeout_ms {
187 self.env
188 .insert("API_TIMEOUT_MS".to_string(), timeout.to_string());
189 }
190
191 if let Some(flag) = config.claude_code_disable_nonessential_traffic {
192 self.env.insert(
193 "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC".to_string(),
194 flag.to_string(),
195 );
196 }
197
198 if let Some(model) = &config.anthropic_default_sonnet_model
199 && !model.is_empty()
200 {
201 self.env
202 .insert("ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), model.clone());
203 }
204
205 if let Some(model) = &config.anthropic_default_opus_model
206 && !model.is_empty()
207 {
208 self.env
209 .insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), model.clone());
210 }
211
212 if let Some(model) = &config.anthropic_default_haiku_model
213 && !model.is_empty()
214 {
215 self.env
216 .insert("ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), model.clone());
217 }
218
219 if let Some(model) = &config.claude_code_subagent_model
220 && !model.is_empty()
221 {
222 self.env
223 .insert("CLAUDE_CODE_SUBAGENT_MODEL".to_string(), model.clone());
224 }
225
226 if let Some(flag) = config.claude_code_disable_nonstreaming_fallback {
227 self.env.insert(
228 "CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK".to_string(),
229 flag.to_string(),
230 );
231 }
232
233 if let Some(level) = &config.claude_code_effort_level
234 && !level.is_empty()
235 {
236 self.env
237 .insert("CLAUDE_CODE_EFFORT_LEVEL".to_string(), level.clone());
238 }
239 }
240
241 pub fn remove_anthropic_env(&mut self) {
246 if self.env.is_empty() {
248 self.env = BTreeMap::new();
249 }
250
251 let env_fields = Configuration::get_env_field_names();
253 for field in &env_fields {
254 self.env.remove(*field);
255 }
256 }
257
258 pub fn switch_to_config_with_mode(
272 &mut self,
273 config: &Configuration,
274 mode: StorageMode,
275 custom_dir: Option<&str>,
276 ) -> Result<()> {
277 match mode {
278 StorageMode::Env => {
279 let clearable_env_fields = Configuration::get_clearable_env_field_names();
285
286 let mut removed_fields = Vec::new();
287
288 for field in &clearable_env_fields {
290 if self.env.remove(*field).is_some() {
291 removed_fields.push(field.to_string());
292 }
293 }
294
295 if !removed_fields.is_empty() {
297 eprintln!("🧹 Cleaning settings.json for env mode:");
298 eprintln!(" Removed configurable fields:");
299 for field in &removed_fields {
300 eprintln!(" - {}", field);
301 }
302 eprintln!();
303 eprintln!(
304 " Settings.json cleaned. Environment variables will be used instead."
305 );
306
307 self.save(custom_dir)?;
309 }
310
311 }
313 StorageMode::Config => {
314 let anthropic_env_fields = Configuration::get_env_field_names();
320
321 let mut conflicts = Vec::new();
322
323 for field in &anthropic_env_fields {
326 if std::env::var(field).is_ok() {
327 conflicts.push(format!("system env: {}", field));
328 }
329 }
330
331 if !conflicts.is_empty() {
333 eprintln!("❌ Conflict detected in config mode:");
334 eprintln!(" Found existing Anthropic configuration in system environment:");
335 for conflict in &conflicts {
336 eprintln!(" - {}", conflict);
337 }
338 eprintln!();
339 eprintln!(
340 " Config mode cannot work when Anthropic environment variables are set in system env."
341 );
342 eprintln!(" Please:");
343 eprintln!(" 1. Unset system environment variables, or");
344 eprintln!(" 2. Use 'env' mode instead");
345 return Err(anyhow::anyhow!(
346 "Config mode conflict: Anthropic environment variables exist in system env"
347 ));
348 }
349
350 self.switch_to_config(config);
352
353 if let Some(max_thinking_tokens) = config.max_thinking_tokens {
355 self.env.insert(
356 "ANTHROPIC_MAX_THINKING_TOKENS".to_string(),
357 max_thinking_tokens.to_string(),
358 );
359 }
360
361 if let Some(timeout) = config.api_timeout_ms {
362 self.env
363 .insert("API_TIMEOUT_MS".to_string(), timeout.to_string());
364 }
365
366 if let Some(flag) = config.claude_code_disable_nonessential_traffic {
367 self.env.insert(
368 "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC".to_string(),
369 flag.to_string(),
370 );
371 }
372
373 if let Some(model) = &config.anthropic_default_sonnet_model
374 && !model.is_empty()
375 {
376 self.env
377 .insert("ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), model.clone());
378 }
379
380 if let Some(model) = &config.anthropic_default_opus_model
381 && !model.is_empty()
382 {
383 self.env
384 .insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), model.clone());
385 }
386
387 if let Some(model) = &config.anthropic_default_haiku_model
388 && !model.is_empty()
389 {
390 self.env
391 .insert("ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), model.clone());
392 }
393
394 self.save(custom_dir)?;
395 }
396 }
397
398 Ok(())
399 }
400
401 pub fn write_current_alias(alias: &str) -> Result<()> {
412 let path = Self::get_current_alias_path()?;
413
414 if let Some(parent) = path.parent() {
416 fs::create_dir_all(parent)
417 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
418 }
419
420 fs::write(&path, alias)
421 .with_context(|| format!("Failed to write current alias to {}", path.display()))?;
422
423 Ok(())
424 }
425
426 pub fn read_current_alias() -> Option<String> {
430 let path = Self::get_current_alias_path().ok()?;
431 let content = fs::read_to_string(&path).ok()?;
432 let trimmed = content.trim();
433 if trimmed.is_empty() {
434 None
435 } else {
436 Some(trimmed.to_string())
437 }
438 }
439
440 pub fn clear_current_alias() -> Result<()> {
444 let path = Self::get_current_alias_path()?;
445 if path.exists() {
446 fs::remove_file(&path)
447 .with_context(|| format!("Failed to remove {}", path.display()))?;
448 }
449 Ok(())
450 }
451
452 fn get_current_alias_path() -> Result<std::path::PathBuf> {
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 Ok(config_dir.join("cc_auto_switch_current_alias"))
461 }
462
463 pub fn write_current_alias_for_pid(alias: &str) -> Result<()> {
474 let pid = std::process::id();
475 let path = Self::get_current_alias_for_pid(pid)?;
476
477 if let Some(parent) = path.parent() {
478 fs::create_dir_all(parent)
479 .with_context(|| format!("Failed to create directory {}", parent.display()))?;
480 }
481
482 fs::write(&path, alias)
483 .with_context(|| format!("Failed to write current alias to {}", path.display()))?;
484
485 Ok(())
486 }
487
488 pub fn clear_current_alias_for_pid() -> Result<()> {
492 let pid = std::process::id();
493 let path = Self::get_current_alias_for_pid(pid)?;
494 if path.exists() {
495 fs::remove_file(&path)
496 .with_context(|| format!("Failed to remove {}", path.display()))?;
497 }
498 Ok(())
499 }
500
501 fn get_current_alias_for_pid(pid: u32) -> Result<std::path::PathBuf> {
503 let config_file = crate::config::get_config_storage_path()?;
504 let config_dir = config_file
505 .parent()
506 .context("Could not get config directory")?;
507 Ok(config_dir.join(format!("cc_auto_switch_alias_{}", pid)))
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 #[test]
516 fn test_strip_trailing_commas_simple() {
517 let input = r#"{"a": 1,}"#;
518 let expected = r#"{"a": 1}"#;
519 assert_eq!(strip_trailing_commas(input), expected);
520 }
521
522 #[test]
523 fn test_strip_trailing_commas_nested_object() {
524 let input = r#"{"env": {"KEY": "value",},}"#;
525 let expected = r#"{"env": {"KEY": "value"}}"#;
526 assert_eq!(strip_trailing_commas(input), expected);
527 }
528
529 #[test]
530 fn test_strip_trailing_commas_array() {
531 let input = r#"{"items": [1, 2, 3,],}"#;
532 let expected = r#"{"items": [1, 2, 3]}"#;
533 assert_eq!(strip_trailing_commas(input), expected);
534 }
535
536 #[test]
537 fn test_strip_trailing_commas_multiline() {
538 let input = r#"{
539 "env": {
540 "KEY": "value",
541 },
542}"#;
543 let expected = r#"{
544 "env": {
545 "KEY": "value"
546 }
547}"#;
548 assert_eq!(strip_trailing_commas(input), expected);
549 }
550
551 #[test]
552 fn test_strip_trailing_commas_no_trailing() {
553 let input = r#"{"a": 1, "b": 2}"#;
554 assert_eq!(strip_trailing_commas(input), input);
555 }
556
557 #[test]
558 fn test_strip_trailing_commas_complex() {
559 let input = r#"{
560 "env": {
561 "ANTHROPIC_AUTH_TOKEN": "token",
562 "ANTHROPIC_BASE_URL": "https://api.example.com",
563 },
564 "model": "claude-3-opus",
565}"#;
566 let expected = r#"{
567 "env": {
568 "ANTHROPIC_AUTH_TOKEN": "token",
569 "ANTHROPIC_BASE_URL": "https://api.example.com"
570 },
571 "model": "claude-3-opus"
572}"#;
573 assert_eq!(strip_trailing_commas(input), expected);
574 }
575
576 #[test]
577 fn test_strip_trailing_commas_preserves_inner_commas() {
578 let input = r#"{"a": 1, "b": 2, "c": 3,}"#;
579 let expected = r#"{"a": 1, "b": 2, "c": 3}"#;
580 assert_eq!(strip_trailing_commas(input), expected);
581 }
582
583 #[test]
584 fn test_per_pid_alias_write_and_clear() {
585 use std::process;
586
587 let pid = process::id();
588 let test_alias = "test-alias-123";
589
590 ClaudeSettings::write_current_alias_for_pid(test_alias).unwrap();
592
593 let path = ClaudeSettings::get_current_alias_for_pid(pid).unwrap();
595 assert!(path.exists());
596 let content = fs::read_to_string(&path).unwrap();
597 assert_eq!(content, test_alias);
598
599 ClaudeSettings::clear_current_alias_for_pid().unwrap();
601
602 assert!(!path.exists());
604 }
605
606 #[test]
607 fn test_per_pid_alias_path_format() {
608 let pid = 12345u32;
609 let path = ClaudeSettings::get_current_alias_for_pid(pid).unwrap();
610 let filename = path.file_name().unwrap().to_str().unwrap();
611 assert_eq!(filename, "cc_auto_switch_alias_12345");
612 }
613}