cc_switch/
claude_settings.rs1use 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
220 pub fn remove_anthropic_env(&mut self) {
225 if self.env.is_empty() {
227 self.env = BTreeMap::new();
228 }
229
230 let env_fields = Configuration::get_env_field_names();
232 for field in &env_fields {
233 self.env.remove(*field);
234 }
235 }
236
237 pub fn switch_to_config_with_mode(
251 &mut self,
252 config: &Configuration,
253 mode: StorageMode,
254 custom_dir: Option<&str>,
255 ) -> Result<()> {
256 match mode {
257 StorageMode::Env => {
258 let clearable_env_fields = Configuration::get_clearable_env_field_names();
265
266 let mut removed_fields = Vec::new();
267
268 for field in &clearable_env_fields {
270 if self.env.remove(*field).is_some() {
271 removed_fields.push(field.to_string());
272 }
273 }
274
275 if !removed_fields.is_empty() {
277 eprintln!("🧹 Cleaning settings.json for env mode:");
278 eprintln!(" Removed configurable fields:");
279 for field in &removed_fields {
280 eprintln!(" - {}", field);
281 }
282 eprintln!();
283 eprintln!(
284 " Settings.json cleaned. Environment variables will be used instead."
285 );
286
287 self.save(custom_dir)?;
289 }
290
291 }
293 StorageMode::Config => {
294 let anthropic_env_fields = Configuration::get_env_field_names();
300
301 let mut conflicts = Vec::new();
302
303 for field in &anthropic_env_fields {
306 if std::env::var(field).is_ok() {
307 conflicts.push(format!("system env: {}", field));
308 }
309 }
310
311 if !conflicts.is_empty() {
313 eprintln!("❌ Conflict detected in config mode:");
314 eprintln!(" Found existing Anthropic configuration in system environment:");
315 for conflict in &conflicts {
316 eprintln!(" - {}", conflict);
317 }
318 eprintln!();
319 eprintln!(
320 " Config mode cannot work when Anthropic environment variables are set in system env."
321 );
322 eprintln!(" Please:");
323 eprintln!(" 1. Unset system environment variables, or");
324 eprintln!(" 2. Use 'env' mode instead");
325 return Err(anyhow::anyhow!(
326 "Config mode conflict: Anthropic environment variables exist in system env"
327 ));
328 }
329
330 self.switch_to_config(config);
332
333 if let Some(max_thinking_tokens) = config.max_thinking_tokens {
335 self.env.insert(
336 "ANTHROPIC_MAX_THINKING_TOKENS".to_string(),
337 max_thinking_tokens.to_string(),
338 );
339 }
340
341 if let Some(timeout) = config.api_timeout_ms {
342 self.env
343 .insert("API_TIMEOUT_MS".to_string(), timeout.to_string());
344 }
345
346 if let Some(flag) = config.claude_code_disable_nonessential_traffic {
347 self.env.insert(
348 "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC".to_string(),
349 flag.to_string(),
350 );
351 }
352
353 if let Some(model) = &config.anthropic_default_sonnet_model
354 && !model.is_empty()
355 {
356 self.env
357 .insert("ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), model.clone());
358 }
359
360 if let Some(model) = &config.anthropic_default_opus_model
361 && !model.is_empty()
362 {
363 self.env
364 .insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), model.clone());
365 }
366
367 if let Some(model) = &config.anthropic_default_haiku_model
368 && !model.is_empty()
369 {
370 self.env
371 .insert("ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), model.clone());
372 }
373
374 self.save(custom_dir)?;
375 }
376 }
377
378 Ok(())
379 }
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385
386 #[test]
387 fn test_strip_trailing_commas_simple() {
388 let input = r#"{"a": 1,}"#;
389 let expected = r#"{"a": 1}"#;
390 assert_eq!(strip_trailing_commas(input), expected);
391 }
392
393 #[test]
394 fn test_strip_trailing_commas_nested_object() {
395 let input = r#"{"env": {"KEY": "value",},}"#;
396 let expected = r#"{"env": {"KEY": "value"}}"#;
397 assert_eq!(strip_trailing_commas(input), expected);
398 }
399
400 #[test]
401 fn test_strip_trailing_commas_array() {
402 let input = r#"{"items": [1, 2, 3,],}"#;
403 let expected = r#"{"items": [1, 2, 3]}"#;
404 assert_eq!(strip_trailing_commas(input), expected);
405 }
406
407 #[test]
408 fn test_strip_trailing_commas_multiline() {
409 let input = r#"{
410 "env": {
411 "KEY": "value",
412 },
413}"#;
414 let expected = r#"{
415 "env": {
416 "KEY": "value"
417 }
418}"#;
419 assert_eq!(strip_trailing_commas(input), expected);
420 }
421
422 #[test]
423 fn test_strip_trailing_commas_no_trailing() {
424 let input = r#"{"a": 1, "b": 2}"#;
425 assert_eq!(strip_trailing_commas(input), input);
426 }
427
428 #[test]
429 fn test_strip_trailing_commas_complex() {
430 let input = r#"{
431 "env": {
432 "ANTHROPIC_AUTH_TOKEN": "token",
433 "ANTHROPIC_BASE_URL": "https://api.example.com",
434 },
435 "model": "claude-3-opus",
436}"#;
437 let expected = r#"{
438 "env": {
439 "ANTHROPIC_AUTH_TOKEN": "token",
440 "ANTHROPIC_BASE_URL": "https://api.example.com"
441 },
442 "model": "claude-3-opus"
443}"#;
444 assert_eq!(strip_trailing_commas(input), expected);
445 }
446
447 #[test]
448 fn test_strip_trailing_commas_preserves_inner_commas() {
449 let input = r#"{"a": 1, "b": 2, "c": 3,}"#;
450 let expected = r#"{"a": 1, "b": 2, "c": 3}"#;
451 assert_eq!(strip_trailing_commas(input), expected);
452 }
453}