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