claude_code_switcher/
settings.rs

1use anyhow::{Result, anyhow};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::fs;
5use std::path::Path;
6
7use crate::{Configurable, SnapshotScope, TemplateType};
8
9/// Main Claude Code settings structure
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
11pub struct ClaudeSettings {
12    #[serde(skip_serializing_if = "Option::is_none")]
13    pub env: Option<std::collections::HashMap<String, String>>,
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub model: Option<String>,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub output_style: Option<String>,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub include_co_authored_by: Option<bool>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub permissions: Option<Permissions>,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub hooks: Option<Hooks>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub api_key_helper: Option<String>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub cleanup_period_days: Option<u32>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub disable_all_hooks: Option<bool>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub force_login_method: Option<String>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub force_login_org_uuid: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub enable_all_project_mcp_servers: Option<bool>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub enabled_mcpjson_servers: Option<Vec<String>>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub disabled_mcpjson_servers: Option<Vec<String>>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub aws_auth_refresh: Option<String>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub aws_credential_export: Option<String>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub status_line: Option<StatusLine>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub subagent_model: Option<String>,
48}
49
50/// Snapshot structure
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct Snapshot {
53    pub id: String,
54    pub name: String,
55    pub created_at: chrono::DateTime<chrono::Utc>,
56    pub scope: SnapshotScope,
57    pub settings: ClaudeSettings,
58    pub description: Option<String>,
59    #[serde(skip)]
60    pub show_api_key: bool,
61}
62
63/// Snapshot storage
64#[derive(Debug, Clone, Serialize, Deserialize, Default)]
65pub struct SnapshotStore {
66    pub snapshots: Vec<Snapshot>,
67}
68
69impl SnapshotStore {
70    pub fn new() -> Self {
71        Self::default()
72    }
73
74    pub fn find_snapshot(&self, name: &str) -> Option<&Snapshot> {
75        self.snapshots.iter().find(|s| s.name == name)
76    }
77
78    pub fn add_snapshot(&mut self, snapshot: Snapshot) {
79        self.snapshots.push(snapshot);
80    }
81
82    pub fn delete_snapshot(&mut self, name: &str) -> Result<()> {
83        let index = self
84            .snapshots
85            .iter()
86            .position(|s| s.name == name)
87            .ok_or_else(|| anyhow!("Snapshot '{}' not found", name))?;
88        self.snapshots.remove(index);
89        Ok(())
90    }
91}
92
93/// Permissions configuration
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95pub struct Permissions {
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub allow: Option<Vec<String>>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub ask: Option<Vec<String>>,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub deny: Option<Vec<String>>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub additional_directories: Option<Vec<String>>,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub default_mode: Option<String>,
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub disable_bypass_permissions_mode: Option<String>,
108}
109
110/// Hooks configuration
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
112pub struct Hooks {
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub pre_command: Option<Vec<String>>,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub post_command: Option<Vec<String>>,
117}
118
119/// Status line configuration
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
121pub struct StatusLine {
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub r#type: Option<String>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub command: Option<String>,
126}
127
128impl ClaudeSettings {
129    /// Create empty settings
130    pub fn new() -> Self {
131        Self::default()
132    }
133
134    /// Read settings from file
135    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
136        let path = path.as_ref();
137        if !path.exists() {
138            return Ok(Self::new());
139        }
140
141        let content = fs::read_to_string(path)
142            .map_err(|e| anyhow!("Failed to read settings file {}: {}", path.display(), e))?;
143
144        if content.trim().is_empty() {
145            return Ok(Self::new());
146        }
147
148        serde_json::from_str(&content)
149            .map_err(|e| anyhow!("Failed to parse settings file {}: {}", path.display(), e))
150    }
151
152    /// Write settings to file
153    pub fn to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
154        let path = path.as_ref();
155        let parent = path.parent().ok_or_else(|| {
156            anyhow!(
157                "Settings file path {} has no parent directory",
158                path.display()
159            )
160        })?;
161
162        fs::create_dir_all(parent).map_err(|e| {
163            anyhow!(
164                "Failed to create settings directory {}: {}",
165                parent.display(),
166                e
167            )
168        })?;
169
170        let content = serde_json::to_string_pretty(self)
171            .map_err(|e| anyhow!("Failed to serialize settings: {}", e))?;
172
173        fs::write(path, content)
174            .map_err(|e| anyhow!("Failed to write settings file {}: {}", path.display(), e))
175    }
176
177    /// Capture environment variables relevant to Claude Code
178    pub fn capture_environment() -> HashMap<String, String> {
179        let mut env = HashMap::new();
180
181        // Claude Code specific environment variables
182        if let Ok(value) = std::env::var("CLAUDE_CODE_API_KEY") {
183            env.insert("CLAUDE_CODE_API_KEY".to_string(), value);
184        }
185        if let Ok(value) = std::env::var("ANTHROPIC_API_KEY") {
186            env.insert("ANTHROPIC_API_KEY".to_string(), value);
187        }
188
189        env
190    }
191
192    /// Capture environment variables for a specific template type
193    pub fn capture_template_environment(template_type: &TemplateType) -> HashMap<String, String> {
194        let mut env = HashMap::new();
195
196        match template_type {
197            TemplateType::DeepSeek => {
198                if let Ok(value) = std::env::var("DEEPSEEK_API_KEY") {
199                    env.insert("DEEPSEEK_API_KEY".to_string(), value);
200                }
201            }
202            TemplateType::Zai => {
203                if let Ok(value) = std::env::var("Z_AI_API_KEY") {
204                    env.insert("Z_AI_API_KEY".to_string(), value);
205                }
206            }
207            TemplateType::KatCoder => {
208                if let Ok(value) = std::env::var("KIMI_API_KEY") {
209                    env.insert("WQ_API_KEY".to_string(), value);
210                }
211            }
212            TemplateType::Kimi => {
213                // For unified Kimi template, try all possible environment variables
214                if let Ok(value) = std::env::var("MOONSHOT_API_KEY") {
215                    env.insert("MOONSHOT_API_KEY".to_string(), value);
216                }
217                if let Ok(value) = std::env::var("KIMI_API_KEY") {
218                    env.insert("KIMI_API_KEY".to_string(), value);
219                }
220            }
221            TemplateType::Longcat => {
222                if let Ok(value) = std::env::var("LONGCAT_API_KEY") {
223                    env.insert("LONGCAT_API_KEY".to_string(), value);
224                }
225            }
226            TemplateType::MiniMax => {
227                if let Ok(value) = std::env::var("MINIMAX_API_KEY") {
228                    env.insert("MINIMAX_API_KEY".to_string(), value);
229                }
230            }
231            TemplateType::SeedCode => {
232                if let Ok(value) = std::env::var("ARK_API_KEY") {
233                    env.insert("ARK_API_KEY".to_string(), value);
234                }
235            }
236        }
237
238        env
239    }
240
241    /// Mask API keys in settings for display
242    pub fn mask_api_keys(&self) -> Self {
243        let mut masked = self.clone();
244        if let Some(ref mut env) = masked.env {
245            let keys_to_mask: Vec<String> = env
246                .keys()
247                .filter(|key| {
248                    key.contains("API_KEY") || key.contains("AUTH_TOKEN") || key.contains("TOKEN")
249                })
250                .cloned()
251                .collect();
252
253            for key in keys_to_mask {
254                if let Some(value) = env.get(&key) {
255                    env.insert(key, mask_api_key(value));
256                }
257            }
258        }
259        masked
260    }
261
262    /// Get API key from settings or environment
263    pub fn get_api_key(&self) -> Option<String> {
264        // First try from settings
265        if let Some(ref env) = self.env {
266            if let Some(key) = env.get("ANTHROPIC_API_KEY") {
267                return Some(key.clone());
268            }
269            if let Some(key) = env.get("ANTHROPIC_AUTH_TOKEN") {
270                return Some(key.clone());
271            }
272        }
273
274        // Then try environment variables
275        if let Ok(key) = std::env::var("CLAUDE_CODE_API_KEY") {
276            return Some(key);
277        }
278        if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") {
279            return Some(key);
280        }
281
282        None
283    }
284}
285
286impl crate::Configurable for ClaudeSettings {
287    fn merge_with(self, other: Self) -> Self {
288        // Merge in priority order: self (higher priority) overrides other (lower priority)
289        ClaudeSettings {
290            env: merge_hashmaps(self.env, other.env),
291            model: other.model.or(self.model),
292            output_style: other.output_style.or(self.output_style),
293            include_co_authored_by: other.include_co_authored_by.or(self.include_co_authored_by),
294            permissions: merge_permissions(self.permissions, other.permissions),
295            hooks: merge_hooks(self.hooks, other.hooks),
296            api_key_helper: other.api_key_helper.or(self.api_key_helper),
297            cleanup_period_days: other.cleanup_period_days.or(self.cleanup_period_days),
298            disable_all_hooks: other.disable_all_hooks.or(self.disable_all_hooks),
299            force_login_method: other.force_login_method.or(self.force_login_method),
300            force_login_org_uuid: other.force_login_org_uuid.or(self.force_login_org_uuid),
301            enable_all_project_mcp_servers: other
302                .enable_all_project_mcp_servers
303                .or(self.enable_all_project_mcp_servers),
304            enabled_mcpjson_servers: merge_vec(
305                self.enabled_mcpjson_servers,
306                other.enabled_mcpjson_servers,
307            ),
308            disabled_mcpjson_servers: merge_vec(
309                self.disabled_mcpjson_servers,
310                other.disabled_mcpjson_servers,
311            ),
312            aws_auth_refresh: other.aws_auth_refresh.or(self.aws_auth_refresh),
313            aws_credential_export: other.aws_credential_export.or(self.aws_credential_export),
314            status_line: other.status_line.or(self.status_line),
315            subagent_model: other.subagent_model.or(self.subagent_model),
316        }
317    }
318
319    fn filter_by_scope(self, scope: &SnapshotScope) -> Self {
320        match scope {
321            SnapshotScope::Env => ClaudeSettings {
322                env: self.env,
323                ..Default::default()
324            },
325            SnapshotScope::All => self,
326            SnapshotScope::Common => ClaudeSettings {
327                env: self.env,
328                model: self.model,
329                output_style: self.output_style,
330                include_co_authored_by: self.include_co_authored_by,
331                permissions: self.permissions,
332                hooks: self.hooks,
333                status_line: self.status_line,
334                subagent_model: self.subagent_model,
335                ..Default::default()
336            },
337        }
338    }
339
340    fn mask_sensitive_data(self) -> Self {
341        self.mask_api_keys()
342    }
343}
344
345/// Merge multiple settings with priority
346pub fn merge_settings(settings: Vec<ClaudeSettings>) -> ClaudeSettings {
347    settings
348        .into_iter()
349        .fold(ClaudeSettings::new(), |acc, settings| {
350            settings.merge_with(acc)
351        })
352}
353
354/// Helper function to merge hashmaps
355/// base_map has higher priority and overrides other_map for conflicting keys
356fn merge_hashmaps<K: Clone + Eq + std::hash::Hash, V: Clone>(
357    base_map: Option<HashMap<K, V>>,
358    other_map: Option<HashMap<K, V>>,
359) -> Option<HashMap<K, V>> {
360    match (base_map, other_map) {
361        (Some(base), Some(other)) => {
362            let mut result = other;
363            // base_map overrides other_map for conflicting keys
364            for (key, value) in base {
365                result.insert(key, value);
366            }
367            Some(result)
368        }
369        (Some(base_map), None) => Some(base_map),
370        (None, Some(other_map)) => Some(other_map),
371        (None, None) => None,
372    }
373}
374
375/// Helper function to merge permissions
376fn merge_permissions(
377    base: Option<Permissions>,
378    override_settings: Option<Permissions>,
379) -> Option<Permissions> {
380    match (base, override_settings) {
381        (Some(base_perms), Some(override_perms)) => Some(Permissions {
382            allow: merge_vec(base_perms.allow, override_perms.allow),
383            ask: merge_vec(base_perms.ask, override_perms.ask),
384            deny: merge_vec(base_perms.deny, override_perms.deny),
385            additional_directories: merge_vec(
386                base_perms.additional_directories,
387                override_perms.additional_directories,
388            ),
389            default_mode: override_perms.default_mode.or(base_perms.default_mode),
390            disable_bypass_permissions_mode: override_perms
391                .disable_bypass_permissions_mode
392                .or(base_perms.disable_bypass_permissions_mode),
393        }),
394        (Some(base_perms), None) => Some(base_perms),
395        (None, Some(override_perms)) => Some(override_perms),
396        (None, None) => None,
397    }
398}
399
400/// Helper function to merge hooks
401fn merge_hooks(base: Option<Hooks>, override_settings: Option<Hooks>) -> Option<Hooks> {
402    match (base, override_settings) {
403        (Some(base_hooks), Some(override_hooks)) => Some(Hooks {
404            pre_command: merge_vec(base_hooks.pre_command, override_hooks.pre_command),
405            post_command: merge_vec(base_hooks.post_command, override_hooks.post_command),
406        }),
407        (Some(base_hooks), None) => Some(base_hooks),
408        (None, Some(override_hooks)) => Some(override_hooks),
409        (None, None) => None,
410    }
411}
412
413/// Helper function to merge vectors
414fn merge_vec<T: Clone>(base: Option<Vec<T>>, override_settings: Option<Vec<T>>) -> Option<Vec<T>> {
415    match (base, override_settings) {
416        (Some(mut base_vec), Some(override_vec)) => {
417            base_vec.extend(override_vec);
418            Some(base_vec)
419        }
420        (Some(base_vec), None) => Some(base_vec),
421        (None, Some(override_vec)) => Some(override_vec),
422        (None, None) => None,
423    }
424}
425
426/// Get display formatting for settings
427pub fn format_settings_for_display(settings: &ClaudeSettings, verbose: bool) -> String {
428    let mut output = String::new();
429
430    if verbose {
431        output.push_str(&format!(
432            "{} Settings\n",
433            console::style("Current").bold().cyan()
434        ));
435        output.push_str(&format!(
436            "{} {}\n",
437            console::style("Provider:").bold(),
438            settings.model.as_deref().unwrap_or("None")
439        ));
440        output.push_str(&format!(
441            "{} {}\n",
442            console::style("Model:").bold(),
443            settings.model.as_deref().unwrap_or("None")
444        ));
445
446        if let Some(ref env) = settings.env {
447            output.push_str(&format!(
448                "{}\n",
449                console::style("Environment Variables:").bold()
450            ));
451            for (key, value) in env {
452                let display_value = if key.contains("API_KEY")
453                    || key.contains("AUTH_TOKEN")
454                    || key.contains("TOKEN")
455                    || key.contains("SECRET")
456                    || key.contains("PASSWORD")
457                    || key.contains("PRIVATE_KEY")
458                {
459                    mask_api_key(value)
460                } else {
461                    value.clone()
462                };
463                output.push_str(&format!("  {} = {}\n", key, display_value));
464            }
465        }
466    } else {
467        output.push_str(&format!(
468            "{}: {} | {}: {}\n",
469            console::style("Provider").bold(),
470            "default",
471            console::style("Model").bold(),
472            settings.model.as_deref().unwrap_or("default")
473        ));
474    }
475
476    output
477}
478
479/// Compare two settings and return a formatted string showing differences
480pub fn format_settings_comparison(current: &ClaudeSettings, new: &ClaudeSettings) -> String {
481    let current_provider = "default";
482    let new_provider = "default";
483    let current_model = current.model.as_deref().unwrap_or("default");
484    let new_model = new.model.as_deref().unwrap_or("default");
485
486    // Only show comparison if there are differences
487    if current_provider == new_provider && current_model == new_model {
488        "Settings are identical.".to_string()
489    } else {
490        let mut output = String::new();
491
492        output.push_str(&format!(
493            "{}: {} → {}\n",
494            console::style("Provider").bold(),
495            current_provider,
496            new_provider
497        ));
498
499        output.push_str(&format!(
500            "{}: {} → {}\n",
501            console::style("Model").bold(),
502            current_model,
503            new_model
504        ));
505
506        output
507    }
508}
509
510/// Mask API key for display
511fn mask_api_key(api_key: &str) -> String {
512    if let Some(actual_key) = api_key.strip_prefix("sk-") {
513        let actual_len = actual_key.len();
514
515        if actual_len <= 6 {
516            format!("sk-{}", "*".repeat(actual_len))
517        } else if actual_len <= 14 {
518            format!(
519                "sk-{}***{}",
520                &actual_key[..2],
521                &actual_key[actual_len - 3..]
522            )
523        } else {
524            format!(
525                "sk-{}{}...{} ({} chars)",
526                &actual_key[..3],
527                "*".repeat(std::cmp::min(actual_len - 7, 8)),
528                &actual_key[actual_len - 4..],
529                api_key.len()
530            )
531        }
532    } else if api_key.len() <= 8 {
533        "*".repeat(api_key.len())
534    } else if api_key.len() <= 16 {
535        format!("{}***{}", &api_key[..3], &api_key[api_key.len() - 3..])
536    } else {
537        let visible_start = &api_key[..4];
538        let visible_end = &api_key[api_key.len() - 4..];
539        let masked_length = api_key.len() - 8;
540        format!(
541            "{}{}...{} ({} chars)",
542            visible_start,
543            "*".repeat(std::cmp::min(masked_length, 8)),
544            visible_end,
545            api_key.len()
546        )
547    }
548}
549
550// Environment field compatibility (for backward compatibility)
551impl ClaudeSettings {
552    /// Get environment variables (backward compatibility)
553    pub fn get_environment(&self) -> Option<&HashMap<String, String>> {
554        self.env.as_ref()
555    }
556
557    /// Set environment variables (backward compatibility)
558    pub fn set_environment(&mut self, env: HashMap<String, String>) {
559        self.env = Some(env);
560    }
561}
562
563impl ClaudeSettings {
564    /// Backward compatibility property for environment
565    pub fn environment(&self) -> Option<&HashMap<String, String>> {
566        self.env.as_ref()
567    }
568}