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 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();
286
287 let mut removed_fields = Vec::new();
288
289 for field in &clearable_env_fields {
291 if self.env.remove(*field).is_some() {
292 removed_fields.push(field.to_string());
293 }
294 }
295
296 if !removed_fields.is_empty() {
298 eprintln!("🧹 Cleaning settings.json for env mode:");
299 eprintln!(" Removed configurable fields:");
300 for field in &removed_fields {
301 eprintln!(" - {}", field);
302 }
303 eprintln!();
304 eprintln!(
305 " Settings.json cleaned. Environment variables will be used instead."
306 );
307
308 self.save(custom_dir)?;
310 }
311
312 }
314 StorageMode::Config => {
315 let anthropic_env_fields = Configuration::get_env_field_names();
321
322 let mut conflicts = Vec::new();
323
324 for field in &anthropic_env_fields {
327 if std::env::var(field).is_ok() {
328 conflicts.push(format!("system env: {}", field));
329 }
330 }
331
332 if !conflicts.is_empty() {
334 eprintln!("❌ Conflict detected in config mode:");
335 eprintln!(" Found existing Anthropic configuration in system environment:");
336 for conflict in &conflicts {
337 eprintln!(" - {}", conflict);
338 }
339 eprintln!();
340 eprintln!(
341 " Config mode cannot work when Anthropic environment variables are set in system env."
342 );
343 eprintln!(" Please:");
344 eprintln!(" 1. Unset system environment variables, or");
345 eprintln!(" 2. Use 'env' mode instead");
346 return Err(anyhow::anyhow!(
347 "Config mode conflict: Anthropic environment variables exist in system env"
348 ));
349 }
350
351 self.switch_to_config(config);
353
354 if let Some(max_thinking_tokens) = config.max_thinking_tokens {
356 self.env.insert(
357 "ANTHROPIC_MAX_THINKING_TOKENS".to_string(),
358 max_thinking_tokens.to_string(),
359 );
360 }
361
362 if let Some(timeout) = config.api_timeout_ms {
363 self.env
364 .insert("API_TIMEOUT_MS".to_string(), timeout.to_string());
365 }
366
367 if let Some(flag) = config.claude_code_disable_nonessential_traffic {
368 self.env.insert(
369 "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC".to_string(),
370 flag.to_string(),
371 );
372 }
373
374 if let Some(model) = &config.anthropic_default_sonnet_model
375 && !model.is_empty()
376 {
377 self.env
378 .insert("ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), model.clone());
379 }
380
381 if let Some(model) = &config.anthropic_default_opus_model
382 && !model.is_empty()
383 {
384 self.env
385 .insert("ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), model.clone());
386 }
387
388 if let Some(model) = &config.anthropic_default_haiku_model
389 && !model.is_empty()
390 {
391 self.env
392 .insert("ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), model.clone());
393 }
394
395 self.save(custom_dir)?;
396 }
397 }
398
399 Ok(())
400 }
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406
407 #[test]
408 fn test_strip_trailing_commas_simple() {
409 let input = r#"{"a": 1,}"#;
410 let expected = r#"{"a": 1}"#;
411 assert_eq!(strip_trailing_commas(input), expected);
412 }
413
414 #[test]
415 fn test_strip_trailing_commas_nested_object() {
416 let input = r#"{"env": {"KEY": "value",},}"#;
417 let expected = r#"{"env": {"KEY": "value"}}"#;
418 assert_eq!(strip_trailing_commas(input), expected);
419 }
420
421 #[test]
422 fn test_strip_trailing_commas_array() {
423 let input = r#"{"items": [1, 2, 3,],}"#;
424 let expected = r#"{"items": [1, 2, 3]}"#;
425 assert_eq!(strip_trailing_commas(input), expected);
426 }
427
428 #[test]
429 fn test_strip_trailing_commas_multiline() {
430 let input = r#"{
431 "env": {
432 "KEY": "value",
433 },
434}"#;
435 let expected = r#"{
436 "env": {
437 "KEY": "value"
438 }
439}"#;
440 assert_eq!(strip_trailing_commas(input), expected);
441 }
442
443 #[test]
444 fn test_strip_trailing_commas_no_trailing() {
445 let input = r#"{"a": 1, "b": 2}"#;
446 assert_eq!(strip_trailing_commas(input), input);
447 }
448
449 #[test]
450 fn test_strip_trailing_commas_complex() {
451 let input = r#"{
452 "env": {
453 "ANTHROPIC_AUTH_TOKEN": "token",
454 "ANTHROPIC_BASE_URL": "https://api.example.com",
455 },
456 "model": "claude-3-opus",
457}"#;
458 let expected = r#"{
459 "env": {
460 "ANTHROPIC_AUTH_TOKEN": "token",
461 "ANTHROPIC_BASE_URL": "https://api.example.com"
462 },
463 "model": "claude-3-opus"
464}"#;
465 assert_eq!(strip_trailing_commas(input), expected);
466 }
467
468 #[test]
469 fn test_strip_trailing_commas_preserves_inner_commas() {
470 let input = r#"{"a": 1, "b": 2, "c": 3,}"#;
471 let expected = r#"{"a": 1, "b": 2, "c": 3}"#;
472 assert_eq!(strip_trailing_commas(input), expected);
473 }
474}