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