Skip to main content

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