1use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum ColorScheme {
10 #[default]
12 Dark,
13 Light,
15}
16
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct Settings {
21 #[serde(default)]
23 pub permissions: Option<Permissions>,
24
25 #[serde(default)]
27 pub hooks: Option<HashMap<String, Vec<HookGroup>>>,
28
29 #[serde(default)]
31 pub model: Option<String>,
32
33 #[serde(default)]
35 pub env: Option<HashMap<String, String>>,
36
37 #[serde(default)]
39 pub enabled_plugins: Option<HashMap<String, bool>>,
40
41 #[serde(default)]
43 pub api_key: Option<String>,
44
45 #[serde(default)]
47 pub custom_instructions: Option<String>,
48
49 #[serde(default)]
51 pub theme: Option<String>,
52
53 #[serde(default)]
55 pub subscription_plan: Option<String>,
56
57 #[serde(default)]
59 pub budget: Option<BudgetConfig>,
60
61 #[serde(default)]
63 pub keybindings: Option<HashMap<String, String>>,
64
65 #[serde(default)]
67 pub anomaly_thresholds: Option<AnomalyThresholds>,
68
69 #[serde(flatten)]
71 pub extra: HashMap<String, serde_json::Value>,
72}
73
74#[derive(Debug, Clone, Default, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct BudgetConfig {
78 pub monthly_limit: Option<f64>,
80
81 #[serde(default = "default_warning_threshold")]
83 pub warning_threshold: f64,
84
85 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(rename_all = "camelCase")]
114pub struct AnomalyThresholds {
115 #[serde(default = "default_anomaly_warning_z")]
117 pub warning_z_score: f64,
118 #[serde(default = "default_anomaly_critical_z")]
120 pub critical_z_score: f64,
121 #[serde(default = "default_spike_2x")]
123 pub spike_2x: f64,
124 #[serde(default = "default_spike_3x")]
126 pub spike_3x: f64,
127 #[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 pub fn masked_api_key(&self) -> Option<String> {
166 self.api_key.as_ref().map(|key| {
167 if key.len() <= 10 {
168 format!("{}••••", &key.chars().take(3).collect::<String>())
170 } else {
171 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
182#[serde(rename_all = "camelCase")]
183pub struct Permissions {
184 #[serde(default)]
186 pub allow: Option<Vec<String>>,
187
188 #[serde(default)]
190 pub deny: Option<Vec<String>>,
191
192 #[serde(default)]
194 pub allow_bash: Option<Vec<String>>,
195
196 #[serde(default)]
198 pub deny_bash: Option<Vec<String>>,
199
200 #[serde(default)]
202 pub auto_approve: Option<bool>,
203
204 #[serde(default)]
206 pub trust_project: Option<bool>,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
211#[serde(rename_all = "camelCase")]
212pub struct HookGroup {
213 #[serde(default)]
215 pub matcher: Option<String>,
216
217 #[serde(default)]
219 pub hooks: Vec<HookDefinition>,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
224#[serde(rename_all = "camelCase")]
225pub struct HookDefinition {
226 pub command: String,
228
229 #[serde(default)]
231 pub r#async: Option<bool>,
232
233 #[serde(default)]
235 pub timeout: Option<u32>,
236
237 #[serde(default)]
239 pub cwd: Option<String>,
240
241 #[serde(default)]
243 pub env: Option<HashMap<String, String>>,
244
245 #[serde(skip)]
247 pub file_path: Option<std::path::PathBuf>,
248}
249
250#[derive(Debug, Clone, Default, Serialize, Deserialize)]
252pub struct MergedConfig {
253 pub global: Option<Settings>,
255 pub project: Option<Settings>,
256 pub local: Option<Settings>,
257
258 pub merged: Settings,
260}
261
262impl MergedConfig {
263 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 if let Some(ref g) = global {
275 Self::merge_into(&mut merged, g);
276 }
277
278 if let Some(ref gl) = global_local {
280 Self::merge_into(&mut merged, gl);
281 }
282
283 if let Some(ref p) = project {
285 Self::merge_into(&mut merged, p);
286 }
287
288 if let Some(ref pl) = project_local {
290 Self::merge_into(&mut merged, pl);
291 }
292
293 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, merged,
307 }
308 }
309
310 fn merge_into(target: &mut Settings, source: &Settings) {
312 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 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 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 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 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 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 for (k, v) in &source.extra {
373 target.extra.insert(k.clone(), v.clone());
374 }
375 }
376
377 fn merge_permissions(target: &mut Permissions, source: &Permissions) {
379 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 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}