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, Default, Serialize, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct BudgetConfig {
74 pub monthly_limit: Option<f64>,
76
77 #[serde(default = "default_warning_threshold")]
79 pub warning_threshold: f64,
80
81 #[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 pub fn masked_api_key(&self) -> Option<String> {
100 self.api_key.as_ref().map(|key| {
101 if key.len() <= 10 {
102 format!("{}••••", &key.chars().take(3).collect::<String>())
104 } else {
105 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
116#[serde(rename_all = "camelCase")]
117pub struct Permissions {
118 #[serde(default)]
120 pub allow: Option<Vec<String>>,
121
122 #[serde(default)]
124 pub deny: Option<Vec<String>>,
125
126 #[serde(default)]
128 pub allow_bash: Option<Vec<String>>,
129
130 #[serde(default)]
132 pub deny_bash: Option<Vec<String>>,
133
134 #[serde(default)]
136 pub auto_approve: Option<bool>,
137
138 #[serde(default)]
140 pub trust_project: Option<bool>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
145#[serde(rename_all = "camelCase")]
146pub struct HookGroup {
147 #[serde(default)]
149 pub matcher: Option<String>,
150
151 #[serde(default)]
153 pub hooks: Vec<HookDefinition>,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
158#[serde(rename_all = "camelCase")]
159pub struct HookDefinition {
160 pub command: String,
162
163 #[serde(default)]
165 pub r#async: Option<bool>,
166
167 #[serde(default)]
169 pub timeout: Option<u32>,
170
171 #[serde(default)]
173 pub cwd: Option<String>,
174
175 #[serde(default)]
177 pub env: Option<HashMap<String, String>>,
178
179 #[serde(skip)]
181 pub file_path: Option<std::path::PathBuf>,
182}
183
184#[derive(Debug, Clone, Default, Serialize, Deserialize)]
186pub struct MergedConfig {
187 pub global: Option<Settings>,
189 pub project: Option<Settings>,
190 pub local: Option<Settings>,
191
192 pub merged: Settings,
194}
195
196impl MergedConfig {
197 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 if let Some(ref g) = global {
209 Self::merge_into(&mut merged, g);
210 }
211
212 if let Some(ref gl) = global_local {
214 Self::merge_into(&mut merged, gl);
215 }
216
217 if let Some(ref p) = project {
219 Self::merge_into(&mut merged, p);
220 }
221
222 if let Some(ref pl) = project_local {
224 Self::merge_into(&mut merged, pl);
225 }
226
227 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, merged,
241 }
242 }
243
244 fn merge_into(target: &mut Settings, source: &Settings) {
246 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 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 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 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 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 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 for (k, v) in &source.extra {
307 target.extra.insert(k.clone(), v.clone());
308 }
309 }
310
311 fn merge_permissions(target: &mut Permissions, source: &Permissions) {
313 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 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}