Skip to main content

cc_switch/config/
types.rs

1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use std::collections::BTreeMap;
3
4/// Type alias for configuration map
5type ConfigMap = BTreeMap<String, Configuration>;
6/// Type alias for environment variable map
7type EnvMap = BTreeMap<String, String>;
8/// Type alias for JSON value map
9type JsonMap = BTreeMap<String, serde_json::Value>;
10
11/// Storage mode for how configuration should be written to settings.json
12#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
13pub enum StorageMode {
14    /// Write to env field with uppercase environment variable names (default)
15    #[serde(rename = "env")]
16    #[default]
17    Env,
18    /// Write to root level with camelCase field names
19    #[serde(rename = "config")]
20    Config,
21}
22
23/// Represents a Claude API configuration
24///
25/// Contains the components needed to configure Claude API access:
26/// - alias_name: User-friendly identifier for the configuration
27/// - token: API authentication token
28/// - url: Base URL for the API endpoint
29/// - model: Optional custom model name
30/// - small_fast_model: Optional Haiku-class model for background tasks
31#[derive(Serialize, Deserialize, Default, Clone)]
32pub struct Configuration {
33    /// User-friendly alias name for this configuration
34    pub alias_name: String,
35    /// ANTHROPIC_AUTH_TOKEN value (API authentication token)
36    pub token: String,
37    /// ANTHROPIC_BASE_URL value (API endpoint URL)
38    pub url: String,
39    /// ANTHROPIC_MODEL value (custom model name)
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub model: Option<String>,
42    /// ANTHROPIC_SMALL_FAST_MODEL value (Haiku-class model for background tasks)
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub small_fast_model: Option<String>,
45    /// ANTHROPIC_MAX_THINKING_TOKENS value (Maximum thinking tokens limit)
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub max_thinking_tokens: Option<u32>,
48    /// API timeout in milliseconds
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub api_timeout_ms: Option<u32>,
51    /// Disable non-essential traffic flag
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub claude_code_disable_nonessential_traffic: Option<u32>,
54    /// Default Sonnet model name
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub anthropic_default_sonnet_model: Option<String>,
57    /// Default Opus model name
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub anthropic_default_opus_model: Option<String>,
60    /// Default Haiku model name
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub anthropic_default_haiku_model: Option<String>,
63    /// Enable experimental agent teams flag
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub claude_code_experimental_agent_teams: Option<u32>,
66    /// Disable 1M context limit flag
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub claude_code_disable_1m_context: Option<u32>,
69}
70
71impl Configuration {
72    /// Get all environment variable names that this configuration can set
73    ///
74    /// Returns a vector of all UPPERCASE environment variable names
75    /// that can be set by this configuration, used for conflict detection
76    /// in config mode.
77    pub fn get_env_field_names() -> Vec<&'static str> {
78        vec![
79            "ANTHROPIC_AUTH_TOKEN",
80            "ANTHROPIC_BASE_URL",
81            "ANTHROPIC_MODEL",
82            "ANTHROPIC_SMALL_FAST_MODEL",
83            "ANTHROPIC_MAX_THINKING_TOKENS",
84            "API_TIMEOUT_MS",
85            "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
86            "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS",
87            "CLAUDE_CODE_DISABLE_1M_CONTEXT",
88            "ANTHROPIC_DEFAULT_SONNET_MODEL",
89            "ANTHROPIC_DEFAULT_OPUS_MODEL",
90            "ANTHROPIC_DEFAULT_HAIKU_MODEL",
91        ]
92    }
93
94    /// Get environment variable names that should be cleared in env mode
95    ///
96    /// Returns a vector of UPPERCASE environment variable names that are
97    /// configuration-specific and should be cleared from settings.json when
98    /// switching in env mode. User preference fields are excluded.
99    pub fn get_clearable_env_field_names() -> Vec<&'static str> {
100        vec![
101            "ANTHROPIC_AUTH_TOKEN",
102            "ANTHROPIC_BASE_URL",
103            "ANTHROPIC_MODEL",
104            "ANTHROPIC_SMALL_FAST_MODEL",
105            "ANTHROPIC_MAX_THINKING_TOKENS",
106            "API_TIMEOUT_MS",
107            "ANTHROPIC_DEFAULT_SONNET_MODEL",
108            "ANTHROPIC_DEFAULT_OPUS_MODEL",
109            "ANTHROPIC_DEFAULT_HAIKU_MODEL",
110            // User preference fields are NOT included:
111            // - CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC
112            // - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS
113            // - CLAUDE_CODE_DISABLE_1M_CONTEXT
114        ]
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn test_get_env_field_names() {
124        let fields = Configuration::get_env_field_names();
125
126        // Verify all expected fields are present
127        let expected_fields = vec![
128            "ANTHROPIC_AUTH_TOKEN",
129            "ANTHROPIC_BASE_URL",
130            "ANTHROPIC_MODEL",
131            "ANTHROPIC_SMALL_FAST_MODEL",
132            "ANTHROPIC_MAX_THINKING_TOKENS",
133            "API_TIMEOUT_MS",
134            "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
135            "ANTHROPIC_DEFAULT_SONNET_MODEL",
136            "ANTHROPIC_DEFAULT_OPUS_MODEL",
137            "ANTHROPIC_DEFAULT_HAIKU_MODEL",
138            "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS",
139            "CLAUDE_CODE_DISABLE_1M_CONTEXT",
140        ];
141
142        assert_eq!(
143            fields.len(),
144            expected_fields.len(),
145            "Should have exactly 12 fields"
146        );
147
148        for expected_field in expected_fields {
149            assert!(
150                fields.contains(&expected_field),
151                "Missing field: {}",
152                expected_field
153            );
154        }
155
156        // Verify all fields are uppercase
157        for field in &fields {
158            assert_eq!(
159                field,
160                &field.to_uppercase(),
161                "Field {} should be uppercase",
162                field
163            );
164        }
165    }
166
167    #[test]
168    fn test_get_clearable_env_field_names() {
169        let fields = Configuration::get_clearable_env_field_names();
170
171        // Verify clearable fields (excludes user preference fields)
172        let expected_fields = vec![
173            "ANTHROPIC_AUTH_TOKEN",
174            "ANTHROPIC_BASE_URL",
175            "ANTHROPIC_MODEL",
176            "ANTHROPIC_SMALL_FAST_MODEL",
177            "ANTHROPIC_MAX_THINKING_TOKENS",
178            "API_TIMEOUT_MS",
179            "ANTHROPIC_DEFAULT_SONNET_MODEL",
180            "ANTHROPIC_DEFAULT_OPUS_MODEL",
181            "ANTHROPIC_DEFAULT_HAIKU_MODEL",
182        ];
183
184        // User preference fields should NOT be in clearable list
185        let excluded_fields = vec![
186            "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
187            "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS",
188            "CLAUDE_CODE_DISABLE_1M_CONTEXT",
189        ];
190
191        assert_eq!(
192            fields.len(),
193            expected_fields.len(),
194            "Should have exactly 9 clearable fields"
195        );
196
197        for expected_field in expected_fields {
198            assert!(
199                fields.contains(&expected_field),
200                "Missing clearable field: {}",
201                expected_field
202            );
203        }
204
205        // Verify excluded fields are NOT present
206        for excluded_field in excluded_fields {
207            assert!(
208                !fields.contains(&excluded_field),
209                "User preference field {} should NOT be in clearable list",
210                excluded_field
211            );
212        }
213    }
214
215    #[test]
216    fn test_remove_anthropic_env_uses_dynamic_fields() {
217        let mut settings = ClaudeSettings::default();
218
219        // Add all possible environment variables
220        let env_fields = Configuration::get_env_field_names();
221        for field in &env_fields {
222            settings
223                .env
224                .insert(field.to_string(), "test_value".to_string());
225        }
226
227        // Add some other env variables that shouldn't be removed
228        settings
229            .env
230            .insert("OTHER_VAR".to_string(), "other_value".to_string());
231        settings
232            .env
233            .insert("CLAUDE_THEME".to_string(), "dark".to_string());
234
235        // Remove Anthropic environment variables
236        settings.remove_anthropic_env();
237
238        // Verify all Anthropic fields are removed
239        for field in &env_fields {
240            assert!(
241                !settings.env.contains_key(*field),
242                "Field {} should be removed",
243                field
244            );
245        }
246
247        // Verify other fields are preserved
248        assert!(
249            settings.env.contains_key("OTHER_VAR"),
250            "Other variables should be preserved"
251        );
252        assert!(
253            settings.env.contains_key("CLAUDE_THEME"),
254            "Other variables should be preserved"
255        );
256        assert_eq!(
257            settings.env.get("OTHER_VAR"),
258            Some(&"other_value".to_string())
259        );
260        assert_eq!(settings.env.get("CLAUDE_THEME"), Some(&"dark".to_string()));
261    }
262
263    #[test]
264    fn test_switch_to_config_uses_dynamic_fields() {
265        let mut settings = ClaudeSettings::default();
266
267        // Add all possible environment variables
268        let env_fields = Configuration::get_env_field_names();
269        for field in &env_fields {
270            settings
271                .env
272                .insert(field.to_string(), "old_value".to_string());
273        }
274
275        // Create a test configuration
276        let config = Configuration {
277            alias_name: "test".to_string(),
278            token: "new_token".to_string(),
279            url: "https://api.new.com".to_string(),
280            model: Some("new_model".to_string()),
281            small_fast_model: Some("new_fast_model".to_string()),
282            max_thinking_tokens: Some(50000),
283            api_timeout_ms: Some(300000),
284            claude_code_disable_nonessential_traffic: Some(1),
285            anthropic_default_sonnet_model: Some("new_sonnet".to_string()),
286            anthropic_default_opus_model: Some("new_opus".to_string()),
287            anthropic_default_haiku_model: Some("new_haiku".to_string()),
288            claude_code_experimental_agent_teams: None,
289            claude_code_disable_1m_context: None,
290        };
291
292        // Switch to new configuration
293        settings.switch_to_config(&config);
294
295        // Verify the required fields are set correctly
296        assert_eq!(
297            settings.env.get("ANTHROPIC_AUTH_TOKEN"),
298            Some(&"new_token".to_string())
299        );
300        assert_eq!(
301            settings.env.get("ANTHROPIC_BASE_URL"),
302            Some(&"https://api.new.com".to_string())
303        );
304        assert_eq!(
305            settings.env.get("ANTHROPIC_MODEL"),
306            Some(&"new_model".to_string())
307        );
308        assert_eq!(
309            settings.env.get("ANTHROPIC_SMALL_FAST_MODEL"),
310            Some(&"new_fast_model".to_string())
311        );
312
313        // Verify additional fields are set correctly when provided in config
314        assert_eq!(
315            settings.env.get("ANTHROPIC_MAX_THINKING_TOKENS"),
316            Some(&"50000".to_string())
317        );
318        assert_eq!(
319            settings.env.get("API_TIMEOUT_MS"),
320            Some(&"300000".to_string())
321        );
322        assert_eq!(
323            settings.env.get("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"),
324            Some(&"1".to_string())
325        );
326        assert_eq!(
327            settings.env.get("ANTHROPIC_DEFAULT_SONNET_MODEL"),
328            Some(&"new_sonnet".to_string())
329        );
330        assert_eq!(
331            settings.env.get("ANTHROPIC_DEFAULT_OPUS_MODEL"),
332            Some(&"new_opus".to_string())
333        );
334        assert_eq!(
335            settings.env.get("ANTHROPIC_DEFAULT_HAIKU_MODEL"),
336            Some(&"new_haiku".to_string())
337        );
338    }
339
340    #[test]
341    fn test_switch_to_config_removes_optional_fields_when_not_provided() {
342        let mut settings = ClaudeSettings::default();
343
344        // Add all possible environment variables with old values
345        let env_fields = Configuration::get_env_field_names();
346        for field in &env_fields {
347            settings
348                .env
349                .insert(field.to_string(), "old_value".to_string());
350        }
351
352        // Create a test configuration without optional fields
353        let config = Configuration {
354            alias_name: "test".to_string(),
355            token: "new_token".to_string(),
356            url: "https://api.new.com".to_string(),
357            model: Some("new_model".to_string()),
358            small_fast_model: Some("new_fast_model".to_string()),
359            max_thinking_tokens: None,
360            api_timeout_ms: None,
361            claude_code_disable_nonessential_traffic: None,
362            anthropic_default_sonnet_model: None,
363            anthropic_default_opus_model: None,
364            anthropic_default_haiku_model: None,
365            claude_code_experimental_agent_teams: None,
366            claude_code_disable_1m_context: None,
367        };
368
369        // Switch to new configuration
370        settings.switch_to_config(&config);
371
372        // Verify the required fields are set correctly
373        assert_eq!(
374            settings.env.get("ANTHROPIC_AUTH_TOKEN"),
375            Some(&"new_token".to_string())
376        );
377        assert_eq!(
378            settings.env.get("ANTHROPIC_BASE_URL"),
379            Some(&"https://api.new.com".to_string())
380        );
381        assert_eq!(
382            settings.env.get("ANTHROPIC_MODEL"),
383            Some(&"new_model".to_string())
384        );
385        assert_eq!(
386            settings.env.get("ANTHROPIC_SMALL_FAST_MODEL"),
387            Some(&"new_fast_model".to_string())
388        );
389
390        // Verify optional fields are removed when not provided in config
391        assert!(!settings.env.contains_key("ANTHROPIC_MAX_THINKING_TOKENS"));
392        assert!(!settings.env.contains_key("API_TIMEOUT_MS"));
393        assert!(
394            !settings
395                .env
396                .contains_key("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC")
397        );
398        assert!(!settings.env.contains_key("ANTHROPIC_DEFAULT_SONNET_MODEL"));
399        assert!(!settings.env.contains_key("ANTHROPIC_DEFAULT_OPUS_MODEL"));
400        assert!(!settings.env.contains_key("ANTHROPIC_DEFAULT_HAIKU_MODEL"));
401    }
402}
403
404/// Storage manager for Claude API configurations
405///
406/// Handles persistence and retrieval of multiple API configurations
407/// stored in `~/.cc_auto_switch/configurations.json`
408#[derive(Serialize, Deserialize, Default)]
409pub struct ConfigStorage {
410    /// Map of alias names to configuration objects
411    pub configurations: ConfigMap,
412    /// Custom directory for Claude settings (optional)
413    pub claude_settings_dir: Option<String>,
414    /// Default storage mode for writing configurations (None = use env mode)
415    #[serde(skip_serializing_if = "Option::is_none")]
416    pub default_storage_mode: Option<StorageMode>,
417}
418
419/// Claude settings manager for API configuration
420///
421/// Manages the Claude settings.json file to control Claude's API configuration
422/// Handles environment variables and preserves other settings
423#[derive(Default, Clone)]
424#[allow(dead_code)]
425pub struct ClaudeSettings {
426    /// Environment variables map (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, ANTHROPIC_MODEL, ANTHROPIC_SMALL_FAST_MODEL)
427    pub env: EnvMap,
428    /// Other settings to preserve when modifying API configuration
429    pub other: JsonMap,
430}
431
432impl Serialize for ClaudeSettings {
433    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
434    where
435        S: Serializer,
436    {
437        use serde::ser::SerializeMap;
438
439        let mut map = serializer.serialize_map(Some(
440            self.other.len() + if self.env.is_empty() { 0 } else { 1 },
441        ))?;
442
443        // Serialize env field only if it has content
444        if !self.env.is_empty() {
445            map.serialize_entry("env", &self.env)?;
446        }
447
448        // Serialize other fields
449        for (key, value) in &self.other {
450            map.serialize_entry(key, value)?;
451        }
452
453        map.end()
454    }
455}
456
457impl<'de> Deserialize<'de> for ClaudeSettings {
458    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
459    where
460        D: Deserializer<'de>,
461    {
462        #[derive(Deserialize)]
463        struct ClaudeSettingsHelper {
464            #[serde(default)]
465            env: EnvMap,
466            #[serde(flatten)]
467            other: JsonMap,
468        }
469
470        let helper = ClaudeSettingsHelper::deserialize(deserializer)?;
471        Ok(ClaudeSettings {
472            env: helper.env,
473            other: helper.other,
474        })
475    }
476}
477
478/// Parameters for adding a new configuration
479#[allow(dead_code)]
480pub struct AddCommandParams {
481    pub alias_name: String,
482    pub token: Option<String>,
483    pub url: Option<String>,
484    pub model: Option<String>,
485    pub small_fast_model: Option<String>,
486    pub max_thinking_tokens: Option<u32>,
487    pub api_timeout_ms: Option<u32>,
488    pub claude_code_disable_nonessential_traffic: Option<u32>,
489    pub anthropic_default_sonnet_model: Option<String>,
490    pub anthropic_default_opus_model: Option<String>,
491    pub anthropic_default_haiku_model: Option<String>,
492    pub force: bool,
493    pub interactive: bool,
494    pub token_arg: Option<String>,
495    pub url_arg: Option<String>,
496    pub from_file: Option<String>,
497}