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(flatten)]
67 pub extra: HashMap<String, serde_json::Value>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct BudgetConfig {
74 pub monthly_budget_usd: f64,
76
77 #[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 pub fn masked_api_key(&self) -> Option<String> {
92 self.api_key.as_ref().map(|key| {
93 if key.len() <= 10 {
94 format!("{}••••", &key.chars().take(3).collect::<String>())
96 } else {
97 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct Permissions {
110 #[serde(default)]
112 pub allow: Option<Vec<String>>,
113
114 #[serde(default)]
116 pub deny: Option<Vec<String>>,
117
118 #[serde(default)]
120 pub allow_bash: Option<Vec<String>>,
121
122 #[serde(default)]
124 pub deny_bash: Option<Vec<String>>,
125
126 #[serde(default)]
128 pub auto_approve: Option<bool>,
129
130 #[serde(default)]
132 pub trust_project: Option<bool>,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137#[serde(rename_all = "camelCase")]
138pub struct HookGroup {
139 #[serde(default)]
141 pub matcher: Option<String>,
142
143 #[serde(default)]
145 pub hooks: Vec<HookDefinition>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct HookDefinition {
152 pub command: String,
154
155 #[serde(default)]
157 pub r#async: Option<bool>,
158
159 #[serde(default)]
161 pub timeout: Option<u32>,
162
163 #[serde(default)]
165 pub cwd: Option<String>,
166
167 #[serde(default)]
169 pub env: Option<HashMap<String, String>>,
170
171 #[serde(skip)]
173 pub file_path: Option<std::path::PathBuf>,
174}
175
176#[derive(Debug, Clone, Default)]
178pub struct MergedConfig {
179 pub global: Option<Settings>,
181 pub project: Option<Settings>,
182 pub local: Option<Settings>,
183
184 pub merged: Settings,
186}
187
188impl MergedConfig {
189 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 if let Some(ref g) = global {
201 Self::merge_into(&mut merged, g);
202 }
203
204 if let Some(ref gl) = global_local {
206 Self::merge_into(&mut merged, gl);
207 }
208
209 if let Some(ref p) = project {
211 Self::merge_into(&mut merged, p);
212 }
213
214 if let Some(ref pl) = project_local {
216 Self::merge_into(&mut merged, pl);
217 }
218
219 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, merged,
233 }
234 }
235
236 fn merge_into(target: &mut Settings, source: &Settings) {
238 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 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 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 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 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 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 for (k, v) in &source.extra {
299 target.extra.insert(k.clone(), v.clone());
300 }
301 }
302
303 fn merge_permissions(target: &mut Permissions, source: &Permissions) {
305 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 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}