Skip to main content

ccboard_types/models/
config.rs

1//! Configuration models for Claude Code settings
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6/// Color scheme for TUI
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum ColorScheme {
10    /// Dark theme (default): Black bg, White fg
11    #[default]
12    Dark,
13    /// Light theme: White bg, Black fg
14    Light,
15}
16
17/// Claude Code settings (from settings.json)
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct Settings {
21    /// Permission settings
22    #[serde(default)]
23    pub permissions: Option<Permissions>,
24
25    /// Hook configurations by event name
26    #[serde(default)]
27    pub hooks: Option<HashMap<String, Vec<HookGroup>>>,
28
29    /// Default model
30    #[serde(default)]
31    pub model: Option<String>,
32
33    /// Environment variables to inject
34    #[serde(default)]
35    pub env: Option<HashMap<String, String>>,
36
37    /// Enabled plugins/features
38    #[serde(default)]
39    pub enabled_plugins: Option<HashMap<String, bool>>,
40
41    /// API key (masked in display)
42    #[serde(default)]
43    pub api_key: Option<String>,
44
45    /// Custom instructions
46    #[serde(default)]
47    pub custom_instructions: Option<String>,
48
49    /// Theme settings
50    #[serde(default)]
51    pub theme: Option<String>,
52
53    /// Subscription plan for budget estimation (pro, max5x, max20x)
54    #[serde(default)]
55    pub subscription_plan: Option<String>,
56
57    /// Budget configuration
58    #[serde(default)]
59    pub budget: Option<BudgetConfig>,
60
61    /// Custom keybindings (TUI only)
62    #[serde(default)]
63    pub keybindings: Option<HashMap<String, String>>,
64
65    /// Additional untyped fields
66    #[serde(flatten)]
67    pub extra: HashMap<String, serde_json::Value>,
68}
69
70/// Budget configuration for cost tracking and alerts
71#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct BudgetConfig {
74    /// Monthly budget in USD
75    pub monthly_budget_usd: f64,
76
77    /// Alert threshold percentage (0-100), defaults to 80%
78    #[serde(default = "default_alert_threshold")]
79    pub alert_threshold_pct: f64,
80}
81
82fn default_alert_threshold() -> f64 {
83    80.0
84}
85
86impl Settings {
87    /// Get masked API key for display (SECURITY: never expose full key)
88    ///
89    /// Returns a masked version showing only prefix and suffix:
90    /// "sk-ant-1234567890abcdef" → "sk-ant-••••cdef"
91    pub fn masked_api_key(&self) -> Option<String> {
92        self.api_key.as_ref().map(|key| {
93            if key.len() <= 10 {
94                // Short key: mask everything except first 3 chars
95                format!("{}••••", &key.chars().take(3).collect::<String>())
96            } else {
97                // Standard key: show prefix and suffix
98                let prefix = key.chars().take(7).collect::<String>();
99                let suffix = key.chars().skip(key.len() - 4).collect::<String>();
100                format!("{}••••{}", prefix, suffix)
101            }
102        })
103    }
104}
105
106/// Permission configuration
107#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct Permissions {
110    /// Allowed tools
111    #[serde(default)]
112    pub allow: Option<Vec<String>>,
113
114    /// Denied tools
115    #[serde(default)]
116    pub deny: Option<Vec<String>>,
117
118    /// Allowed Bash commands/patterns
119    #[serde(default)]
120    pub allow_bash: Option<Vec<String>>,
121
122    /// Denied Bash commands/patterns
123    #[serde(default)]
124    pub deny_bash: Option<Vec<String>>,
125
126    /// Auto-approve mode
127    #[serde(default)]
128    pub auto_approve: Option<bool>,
129
130    /// Trust project settings
131    #[serde(default)]
132    pub trust_project: Option<bool>,
133}
134
135/// Hook group configuration
136#[derive(Debug, Clone, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct HookGroup {
139    /// Matcher pattern (glob or regex)
140    #[serde(default)]
141    pub matcher: Option<String>,
142
143    /// Hooks in this group
144    #[serde(default)]
145    pub hooks: Vec<HookDefinition>,
146}
147
148/// Individual hook definition
149#[derive(Debug, Clone, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct HookDefinition {
152    /// Command to execute
153    pub command: String,
154
155    /// Run asynchronously
156    #[serde(default)]
157    pub r#async: Option<bool>,
158
159    /// Timeout in seconds
160    #[serde(default)]
161    pub timeout: Option<u32>,
162
163    /// Working directory
164    #[serde(default)]
165    pub cwd: Option<String>,
166
167    /// Environment variables
168    #[serde(default)]
169    pub env: Option<HashMap<String, String>>,
170
171    /// Source file path (not from JSON, populated during scanning)
172    #[serde(skip)]
173    pub file_path: Option<std::path::PathBuf>,
174}
175
176/// Merged configuration from all levels
177#[derive(Debug, Clone, Default)]
178pub struct MergedConfig {
179    /// Source of each field for debugging
180    pub global: Option<Settings>,
181    pub project: Option<Settings>,
182    pub local: Option<Settings>,
183
184    /// Final merged result
185    pub merged: Settings,
186}
187
188impl MergedConfig {
189    /// Create merged config from three levels
190    /// Priority: local > project > global
191    pub fn from_layers(
192        global: Option<Settings>,
193        global_local: Option<Settings>,
194        project: Option<Settings>,
195        project_local: Option<Settings>,
196    ) -> Self {
197        let mut merged = Settings::default();
198
199        // Merge global first
200        if let Some(ref g) = global {
201            Self::merge_into(&mut merged, g);
202        }
203
204        // Then global local (overrides global)
205        if let Some(ref gl) = global_local {
206            Self::merge_into(&mut merged, gl);
207        }
208
209        // Then project (overrides global layers)
210        if let Some(ref p) = project {
211            Self::merge_into(&mut merged, p);
212        }
213
214        // Finally project local (highest priority)
215        if let Some(ref pl) = project_local {
216            Self::merge_into(&mut merged, pl);
217        }
218
219        // Store sources for debugging (merge global_local into global for compatibility)
220        let global_combined = match (global, global_local) {
221            (Some(mut g), Some(ref gl)) => {
222                Self::merge_into(&mut g, gl);
223                Some(g)
224            }
225            (g, gl) => g.or(gl),
226        };
227
228        Self {
229            global: global_combined,
230            project,
231            local: project_local, // Rename semantics: local now means project_local
232            merged,
233        }
234    }
235
236    /// Explicit field-by-field merge (not shallow copy)
237    fn merge_into(target: &mut Settings, source: &Settings) {
238        // Scalar fields: override if present
239        if source.model.is_some() {
240            target.model = source.model.clone();
241        }
242        if source.api_key.is_some() {
243            target.api_key = source.api_key.clone();
244        }
245        if source.custom_instructions.is_some() {
246            target.custom_instructions = source.custom_instructions.clone();
247        }
248        if source.theme.is_some() {
249            target.theme = source.theme.clone();
250        }
251        if source.subscription_plan.is_some() {
252            target.subscription_plan = source.subscription_plan.clone();
253        }
254        if source.budget.is_some() {
255            target.budget = source.budget.clone();
256        }
257
258        // Keybindings: merge maps (custom keybindings override defaults)
259        if let Some(ref src_keybindings) = source.keybindings {
260            let target_keybindings = target.keybindings.get_or_insert_with(HashMap::new);
261            for (k, v) in src_keybindings {
262                target_keybindings.insert(k.clone(), v.clone());
263            }
264        }
265
266        // Permissions: deep merge
267        if let Some(ref src_perms) = source.permissions {
268            let target_perms = target.permissions.get_or_insert_with(Permissions::default);
269            Self::merge_permissions(target_perms, src_perms);
270        }
271
272        // Hooks: merge by event name, then extend hook lists
273        if let Some(ref src_hooks) = source.hooks {
274            let target_hooks = target.hooks.get_or_insert_with(HashMap::new);
275            for (event, groups) in src_hooks {
276                let target_groups = target_hooks.entry(event.clone()).or_default();
277                target_groups.extend(groups.clone());
278            }
279        }
280
281        // Env: merge maps
282        if let Some(ref src_env) = source.env {
283            let target_env = target.env.get_or_insert_with(HashMap::new);
284            for (k, v) in src_env {
285                target_env.insert(k.clone(), v.clone());
286            }
287        }
288
289        // Plugins: merge maps
290        if let Some(ref src_plugins) = source.enabled_plugins {
291            let target_plugins = target.enabled_plugins.get_or_insert_with(HashMap::new);
292            for (k, v) in src_plugins {
293                target_plugins.insert(k.clone(), *v);
294            }
295        }
296
297        // Extra fields: merge
298        for (k, v) in &source.extra {
299            target.extra.insert(k.clone(), v.clone());
300        }
301    }
302
303    /// Deep merge permissions
304    fn merge_permissions(target: &mut Permissions, source: &Permissions) {
305        // Lists: extend (not replace)
306        if let Some(ref src_allow) = source.allow {
307            let target_allow = target.allow.get_or_insert_with(Vec::new);
308            for item in src_allow {
309                if !target_allow.contains(item) {
310                    target_allow.push(item.clone());
311                }
312            }
313        }
314        if let Some(ref src_deny) = source.deny {
315            let target_deny = target.deny.get_or_insert_with(Vec::new);
316            for item in src_deny {
317                if !target_deny.contains(item) {
318                    target_deny.push(item.clone());
319                }
320            }
321        }
322        if let Some(ref src_allow_bash) = source.allow_bash {
323            let target_allow_bash = target.allow_bash.get_or_insert_with(Vec::new);
324            for item in src_allow_bash {
325                if !target_allow_bash.contains(item) {
326                    target_allow_bash.push(item.clone());
327                }
328            }
329        }
330        if let Some(ref src_deny_bash) = source.deny_bash {
331            let target_deny_bash = target.deny_bash.get_or_insert_with(Vec::new);
332            for item in src_deny_bash {
333                if !target_deny_bash.contains(item) {
334                    target_deny_bash.push(item.clone());
335                }
336            }
337        }
338
339        // Booleans: override if present
340        if source.auto_approve.is_some() {
341            target.auto_approve = source.auto_approve;
342        }
343        if source.trust_project.is_some() {
344            target.trust_project = source.trust_project;
345        }
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn test_merge_scalar_override() {
355        let global = Settings {
356            model: Some("opus".to_string()),
357            ..Default::default()
358        };
359        let project = Settings {
360            model: Some("sonnet".to_string()),
361            ..Default::default()
362        };
363
364        let merged = MergedConfig::from_layers(Some(global), None, Some(project), None);
365        assert_eq!(merged.merged.model, Some("sonnet".to_string()));
366    }
367
368    #[test]
369    fn test_merge_env_combines() {
370        let mut global_env = HashMap::new();
371        global_env.insert("A".to_string(), "1".to_string());
372        global_env.insert("B".to_string(), "2".to_string());
373
374        let mut project_env = HashMap::new();
375        project_env.insert("B".to_string(), "override".to_string());
376        project_env.insert("C".to_string(), "3".to_string());
377
378        let global = Settings {
379            env: Some(global_env),
380            ..Default::default()
381        };
382        let project = Settings {
383            env: Some(project_env),
384            ..Default::default()
385        };
386
387        let merged = MergedConfig::from_layers(Some(global), None, Some(project), None);
388        let env = merged.merged.env.unwrap();
389
390        assert_eq!(env.get("A"), Some(&"1".to_string()));
391        assert_eq!(env.get("B"), Some(&"override".to_string()));
392        assert_eq!(env.get("C"), Some(&"3".to_string()));
393    }
394
395    #[test]
396    fn test_merge_permissions_extend() {
397        let global = Settings {
398            permissions: Some(Permissions {
399                allow: Some(vec!["Read".to_string()]),
400                ..Default::default()
401            }),
402            ..Default::default()
403        };
404        let project = Settings {
405            permissions: Some(Permissions {
406                allow: Some(vec!["Write".to_string()]),
407                deny: Some(vec!["Bash".to_string()]),
408                ..Default::default()
409            }),
410            ..Default::default()
411        };
412
413        let merged = MergedConfig::from_layers(Some(global), None, Some(project), None);
414        let perms = merged.merged.permissions.unwrap();
415
416        let allow = perms.allow.unwrap();
417        assert!(allow.contains(&"Read".to_string()));
418        assert!(allow.contains(&"Write".to_string()));
419
420        let deny = perms.deny.unwrap();
421        assert!(deny.contains(&"Bash".to_string()));
422    }
423}