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                // For unified Kimi template, try all possible environment variables
209                if let Ok(value) = std::env::var("MOONSHOT_API_KEY") {
210                    env.insert("MOONSHOT_API_KEY".to_string(), value);
211                }
212                if let Ok(value) = std::env::var("KIMI_API_KEY") {
213                    env.insert("KIMI_API_KEY".to_string(), value);
214                }
215            }
216            TemplateType::Kimi => {
217                // For unified Kimi template, try all possible environment variables
218                if let Ok(value) = std::env::var("MOONSHOT_API_KEY") {
219                    env.insert("MOONSHOT_API_KEY".to_string(), value);
220                }
221                if let Ok(value) = std::env::var("KIMI_API_KEY") {
222                    env.insert("KIMI_API_KEY".to_string(), value);
223                }
224            }
225            TemplateType::Longcat => {
226                if let Ok(value) = std::env::var("LONGCAT_API_KEY") {
227                    env.insert("LONGCAT_API_KEY".to_string(), value);
228                }
229            }
230            TemplateType::MiniMax => {
231                if let Ok(value) = std::env::var("MINIMAX_API_KEY") {
232                    env.insert("MINIMAX_API_KEY".to_string(), value);
233                }
234            }
235            TemplateType::SeedCode => {
236                if let Ok(value) = std::env::var("ARK_API_KEY") {
237                    env.insert("ARK_API_KEY".to_string(), value);
238                }
239            }
240        }
241
242        env
243    }
244
245    /// Mask API keys in settings for display
246    pub fn mask_api_keys(&self) -> Self {
247        let mut masked = self.clone();
248        if let Some(ref mut env) = masked.env {
249            let keys_to_mask: Vec<String> = env
250                .keys()
251                .filter(|key| {
252                    key.contains("API_KEY") || key.contains("AUTH_TOKEN") || key.contains("TOKEN")
253                })
254                .cloned()
255                .collect();
256
257            for key in keys_to_mask {
258                if let Some(value) = env.get(&key) {
259                    env.insert(key, mask_api_key(value));
260                }
261            }
262        }
263        masked
264    }
265
266    /// Get API key from settings or environment
267    pub fn get_api_key(&self) -> Option<String> {
268        // First try from settings
269        if let Some(ref env) = self.env {
270            if let Some(key) = env.get("ANTHROPIC_API_KEY") {
271                return Some(key.clone());
272            }
273            if let Some(key) = env.get("ANTHROPIC_AUTH_TOKEN") {
274                return Some(key.clone());
275            }
276        }
277
278        // Then try environment variables
279        if let Ok(key) = std::env::var("CLAUDE_CODE_API_KEY") {
280            return Some(key);
281        }
282        if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") {
283            return Some(key);
284        }
285
286        None
287    }
288}
289
290impl crate::Configurable for ClaudeSettings {
291    fn merge_with(self, other: Self) -> Self {
292        // Merge in priority order: self (higher priority) overrides other (lower priority)
293        ClaudeSettings {
294            env: merge_hashmaps(self.env, other.env),
295            model: other.model.or(self.model),
296            output_style: other.output_style.or(self.output_style),
297            include_co_authored_by: other.include_co_authored_by.or(self.include_co_authored_by),
298            permissions: merge_permissions(self.permissions, other.permissions),
299            hooks: merge_hooks(self.hooks, other.hooks),
300            api_key_helper: other.api_key_helper.or(self.api_key_helper),
301            cleanup_period_days: other.cleanup_period_days.or(self.cleanup_period_days),
302            disable_all_hooks: other.disable_all_hooks.or(self.disable_all_hooks),
303            force_login_method: other.force_login_method.or(self.force_login_method),
304            force_login_org_uuid: other.force_login_org_uuid.or(self.force_login_org_uuid),
305            enable_all_project_mcp_servers: other
306                .enable_all_project_mcp_servers
307                .or(self.enable_all_project_mcp_servers),
308            enabled_mcpjson_servers: merge_vec(
309                self.enabled_mcpjson_servers,
310                other.enabled_mcpjson_servers,
311            ),
312            disabled_mcpjson_servers: merge_vec(
313                self.disabled_mcpjson_servers,
314                other.disabled_mcpjson_servers,
315            ),
316            aws_auth_refresh: other.aws_auth_refresh.or(self.aws_auth_refresh),
317            aws_credential_export: other.aws_credential_export.or(self.aws_credential_export),
318            status_line: other.status_line.or(self.status_line),
319            subagent_model: other.subagent_model.or(self.subagent_model),
320        }
321    }
322
323    fn filter_by_scope(self, scope: &SnapshotScope) -> Self {
324        match scope {
325            SnapshotScope::Env => ClaudeSettings {
326                env: self.env,
327                ..Default::default()
328            },
329            SnapshotScope::All => self,
330            SnapshotScope::Common => ClaudeSettings {
331                env: self.env,
332                model: self.model,
333                output_style: self.output_style,
334                include_co_authored_by: self.include_co_authored_by,
335                permissions: self.permissions,
336                hooks: self.hooks,
337                status_line: self.status_line,
338                subagent_model: self.subagent_model,
339                ..Default::default()
340            },
341        }
342    }
343
344    fn mask_sensitive_data(self) -> Self {
345        self.mask_api_keys()
346    }
347}
348
349/// Merge multiple settings with priority
350pub fn merge_settings(settings: Vec<ClaudeSettings>) -> ClaudeSettings {
351    settings
352        .into_iter()
353        .fold(ClaudeSettings::new(), |acc, settings| {
354            settings.merge_with(acc)
355        })
356}
357
358/// Helper function to merge hashmaps
359fn merge_hashmaps<K: Clone + Eq + std::hash::Hash, V: Clone>(
360    base: Option<HashMap<K, V>>,
361    override_settings: Option<HashMap<K, V>>,
362) -> Option<HashMap<K, V>> {
363    match (base, override_settings) {
364        (Some(mut base_map), Some(override_map)) => {
365            base_map.extend(override_map);
366            Some(base_map)
367        }
368        (Some(base_map), None) => Some(base_map),
369        (None, Some(override_map)) => Some(override_map),
370        (None, None) => None,
371    }
372}
373
374/// Helper function to merge permissions
375fn merge_permissions(
376    base: Option<Permissions>,
377    override_settings: Option<Permissions>,
378) -> Option<Permissions> {
379    match (base, override_settings) {
380        (Some(base_perms), Some(override_perms)) => Some(Permissions {
381            allow: merge_vec(base_perms.allow, override_perms.allow),
382            ask: merge_vec(base_perms.ask, override_perms.ask),
383            deny: merge_vec(base_perms.deny, override_perms.deny),
384            additional_directories: merge_vec(
385                base_perms.additional_directories,
386                override_perms.additional_directories,
387            ),
388            default_mode: override_perms.default_mode.or(base_perms.default_mode),
389            disable_bypass_permissions_mode: override_perms
390                .disable_bypass_permissions_mode
391                .or(base_perms.disable_bypass_permissions_mode),
392        }),
393        (Some(base_perms), None) => Some(base_perms),
394        (None, Some(override_perms)) => Some(override_perms),
395        (None, None) => None,
396    }
397}
398
399/// Helper function to merge hooks
400fn merge_hooks(base: Option<Hooks>, override_settings: Option<Hooks>) -> Option<Hooks> {
401    match (base, override_settings) {
402        (Some(base_hooks), Some(override_hooks)) => Some(Hooks {
403            pre_command: merge_vec(base_hooks.pre_command, override_hooks.pre_command),
404            post_command: merge_vec(base_hooks.post_command, override_hooks.post_command),
405        }),
406        (Some(base_hooks), None) => Some(base_hooks),
407        (None, Some(override_hooks)) => Some(override_hooks),
408        (None, None) => None,
409    }
410}
411
412/// Helper function to merge vectors
413fn merge_vec<T: Clone>(base: Option<Vec<T>>, override_settings: Option<Vec<T>>) -> Option<Vec<T>> {
414    match (base, override_settings) {
415        (Some(mut base_vec), Some(override_vec)) => {
416            base_vec.extend(override_vec);
417            Some(base_vec)
418        }
419        (Some(base_vec), None) => Some(base_vec),
420        (None, Some(override_vec)) => Some(override_vec),
421        (None, None) => None,
422    }
423}
424
425/// Get display formatting for settings
426pub fn format_settings_for_display(settings: &ClaudeSettings, verbose: bool) -> String {
427    let mut output = String::new();
428
429    if verbose {
430        output.push_str(&format!(
431            "{} Settings\n",
432            console::style("Current").bold().cyan()
433        ));
434        output.push_str(&format!(
435            "{} {}\n",
436            console::style("Provider:").bold(),
437            settings.model.as_deref().unwrap_or("None")
438        ));
439        output.push_str(&format!(
440            "{} {}\n",
441            console::style("Model:").bold(),
442            settings.model.as_deref().unwrap_or("None")
443        ));
444
445        if let Some(ref env) = settings.env {
446            output.push_str(&format!(
447                "{}\n",
448                console::style("Environment Variables:").bold()
449            ));
450            for (key, value) in env {
451                let display_value = if key.contains("API_KEY")
452                    || key.contains("AUTH_TOKEN")
453                    || key.contains("TOKEN")
454                    || key.contains("SECRET")
455                    || key.contains("PASSWORD")
456                    || key.contains("PRIVATE_KEY")
457                {
458                    mask_api_key(value)
459                } else {
460                    value.clone()
461                };
462                output.push_str(&format!("  {} = {}\n", key, display_value));
463            }
464        }
465    } else {
466        output.push_str(&format!(
467            "{}: {} | {}: {}\n",
468            console::style("Provider").bold(),
469            "default",
470            console::style("Model").bold(),
471            settings.model.as_deref().unwrap_or("default")
472        ));
473    }
474
475    output
476}
477
478/// Compare two settings and return a formatted string showing differences
479pub fn format_settings_comparison(current: &ClaudeSettings, new: &ClaudeSettings) -> String {
480    let current_provider = "default";
481    let new_provider = "default";
482    let current_model = current.model.as_deref().unwrap_or("default");
483    let new_model = new.model.as_deref().unwrap_or("default");
484
485    // Only show comparison if there are differences
486    if current_provider == new_provider && current_model == new_model {
487        "Settings are identical.".to_string()
488    } else {
489        let mut output = String::new();
490
491        output.push_str(&format!(
492            "{}: {} → {}\n",
493            console::style("Provider").bold(),
494            current_provider,
495            new_provider
496        ));
497
498        output.push_str(&format!(
499            "{}: {} → {}\n",
500            console::style("Model").bold(),
501            current_model,
502            new_model
503        ));
504
505        output
506    }
507}
508
509/// Mask API key for display
510fn mask_api_key(api_key: &str) -> String {
511    if let Some(actual_key) = api_key.strip_prefix("sk-") {
512        let actual_len = actual_key.len();
513
514        if actual_len <= 6 {
515            format!("sk-{}", "*".repeat(actual_len))
516        } else if actual_len <= 14 {
517            format!(
518                "sk-{}***{}",
519                &actual_key[..2],
520                &actual_key[actual_len - 3..]
521            )
522        } else {
523            format!(
524                "sk-{}{}...{} ({} chars)",
525                &actual_key[..3],
526                "*".repeat(std::cmp::min(actual_len - 7, 8)),
527                &actual_key[actual_len - 4..],
528                api_key.len()
529            )
530        }
531    } else if api_key.len() <= 8 {
532        "*".repeat(api_key.len())
533    } else if api_key.len() <= 16 {
534        format!("{}***{}", &api_key[..3], &api_key[api_key.len() - 3..])
535    } else {
536        let visible_start = &api_key[..4];
537        let visible_end = &api_key[api_key.len() - 4..];
538        let masked_length = api_key.len() - 8;
539        format!(
540            "{}{}...{} ({} chars)",
541            visible_start,
542            "*".repeat(std::cmp::min(masked_length, 8)),
543            visible_end,
544            api_key.len()
545        )
546    }
547}
548
549// Environment field compatibility (for backward compatibility)
550impl ClaudeSettings {
551    /// Get environment variables (backward compatibility)
552    pub fn get_environment(&self) -> Option<&HashMap<String, String>> {
553        self.env.as_ref()
554    }
555
556    /// Set environment variables (backward compatibility)
557    pub fn set_environment(&mut self, env: HashMap<String, String>) {
558        self.env = Some(env);
559    }
560}
561
562impl ClaudeSettings {
563    /// Backward compatibility property for environment
564    pub fn environment(&self) -> Option<&HashMap<String, String>> {
565        self.env.as_ref()
566    }
567}