1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use crate::permission;
10
11#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
16#[serde(rename_all = "camelCase")]
17pub struct Settings {
18 #[serde(default, skip_serializing_if = "permission::PermissionSet::is_empty")]
20 pub permissions: permission::PermissionSet,
21
22 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub env: Option<HashMap<String, String>>,
25
26 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub model: Option<String>,
29
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub hooks: Option<Hooks>,
33
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub sandbox: Option<Sandbox>,
37
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub attribution: Option<Attribution>,
41
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub enabled_plugins: Option<HashMap<String, bool>>,
45
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub cleanup_period_days: Option<u32>,
49
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub language: Option<String>,
53
54 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub bypass_permissions: Option<bool>,
59
60 #[serde(flatten)]
62 pub extra: HashMap<String, serde_json::Value>,
63}
64
65#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
70pub struct Permissions {
71 #[serde(default, skip_serializing_if = "Vec::is_empty")]
74 pub allow: Vec<String>,
75
76 #[serde(default, skip_serializing_if = "Vec::is_empty")]
78 pub ask: Vec<String>,
79
80 #[serde(default, skip_serializing_if = "Vec::is_empty")]
82 pub deny: Vec<String>,
83}
84
85#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
87#[serde(rename_all = "PascalCase")]
88pub struct Hooks {
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub pre_tool_use: Option<HookConfig>,
92
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub post_tool_use: Option<HookConfig>,
96
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub permission_request: Option<HookConfig>,
100
101 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub session_start: Option<Vec<HookMatcher>>,
104
105 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub stop: Option<Vec<HookMatcher>>,
108
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub notification: Option<HookConfig>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
116#[serde(untagged)]
117pub enum HookConfig {
118 Simple(HashMap<String, String>),
120 Matchers(Vec<HookMatcher>),
122}
123
124impl HookConfig {
125 pub fn insert(self, pat: &str, command: &str) -> Self {
128 match self {
129 HookConfig::Simple(hash_map) => Self::Matchers(
130 hash_map
131 .into_iter()
132 .map(|(pat, cmd)| HookMatcher {
133 matcher: pat,
134 hooks: vec![Hook {
135 hook_type: "command".into(),
136 command: Some(cmd),
137 timeout: None,
138 }],
139 })
140 .collect(),
141 )
142 .insert(pat, command),
143 HookConfig::Matchers(mut hook_matchers) => {
144 let mut found = false;
145 for hm in &mut hook_matchers {
146 if hm.matcher == pat {
147 hm.hooks.push(Hook {
148 hook_type: "command".into(),
149 command: Some(command.into()),
150 timeout: None,
151 });
152 found = true;
153 }
154 }
155 if !found {
156 hook_matchers.push(HookMatcher {
157 matcher: pat.into(),
158 hooks: vec![Hook {
159 hook_type: "command".into(),
160 command: Some(command.into()),
161 timeout: None,
162 }],
163 });
164 }
165 Self::Matchers(hook_matchers)
166 }
167 }
168 }
169}
170
171impl Default for HookConfig {
172 fn default() -> Self {
173 Self::Simple(HashMap::new())
174 }
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
179pub struct HookMatcher {
180 #[serde(default)]
182 pub matcher: String,
183
184 #[serde(default)]
186 pub hooks: Vec<Hook>,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
191pub struct Hook {
192 #[serde(rename = "type")]
194 pub hook_type: String,
195
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub command: Option<String>,
199
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub timeout: Option<u64>,
203}
204
205#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
207#[serde(rename_all = "camelCase")]
208pub struct Sandbox {
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub enabled: Option<bool>,
212
213 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub auto_allow_bash_if_sandboxed: Option<bool>,
216
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub excluded_commands: Option<Vec<String>>,
220}
221
222#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
224pub struct Attribution {
225 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub commit: Option<String>,
228
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub pr: Option<String>,
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
236pub enum SettingsLevel {
237 System,
240
241 ProjectLocal,
244
245 Project,
248
249 User,
252}
253
254impl SettingsLevel {
255 pub fn all_by_priority() -> &'static [SettingsLevel] {
257 &[
258 SettingsLevel::System,
259 SettingsLevel::ProjectLocal,
260 SettingsLevel::Project,
261 SettingsLevel::User,
262 ]
263 }
264
265 pub fn name(&self) -> &'static str {
267 match self {
268 SettingsLevel::System => "system",
269 SettingsLevel::ProjectLocal => "project-local",
270 SettingsLevel::Project => "project",
271 SettingsLevel::User => "user",
272 }
273 }
274}
275
276impl Settings {
277 pub fn new() -> Self {
279 Self::default()
280 }
281
282 pub fn with_permissions(mut self, permissions: permission::PermissionSet) -> Self {
284 self.permissions = permissions;
285 self
286 }
287
288 pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
290 self.env = Some(env);
291 self
292 }
293
294 pub fn with_model(mut self, model: impl Into<String>) -> Self {
296 self.model = Some(model.into());
297 self
298 }
299
300 pub fn with_hooks(mut self, hooks: Hooks) -> Self {
302 self.hooks = Some(hooks);
303 self
304 }
305
306 pub fn with_sandbox(mut self, sandbox: Sandbox) -> Self {
308 self.sandbox = Some(sandbox);
309 self
310 }
311
312 pub fn with_attribution(mut self, attribution: Attribution) -> Self {
314 self.attribution = Some(attribution);
315 self
316 }
317
318 pub fn with_bypass_permissions(mut self, enabled: bool) -> Self {
320 self.bypass_permissions = Some(enabled);
321 self
322 }
323
324 pub fn is_empty(&self) -> bool {
326 self.permissions.is_empty()
327 && self.env.is_none()
328 && self.model.is_none()
329 && self.hooks.is_none()
330 && self.sandbox.is_none()
331 && self.attribution.is_none()
332 && self.enabled_plugins.is_none()
333 && self.cleanup_period_days.is_none()
334 && self.language.is_none()
335 && self.bypass_permissions.is_none()
336 && self.extra.is_empty()
337 }
338
339 const CLASH_INSTALLED_KEY: &'static str = "_clashInstalled";
341
342 pub fn is_clash_installed(&self) -> bool {
346 self.extra
347 .get(Self::CLASH_INSTALLED_KEY)
348 .is_some_and(|v| v.as_bool().unwrap_or(false))
349 }
350
351 pub fn mark_clash_installed(&mut self) {
355 self.extra.insert(
356 Self::CLASH_INSTALLED_KEY.to_string(),
357 serde_json::json!(true),
358 );
359 }
360
361 pub fn clear_clash_installed(&mut self) {
363 self.extra.remove(Self::CLASH_INSTALLED_KEY);
364 }
365
366 pub fn with_clash_installed(mut self) -> Self {
368 self.mark_clash_installed();
369 self
370 }
371}
372
373impl Permissions {
374 pub fn new() -> Self {
376 Self::default()
377 }
378
379 pub fn allow(mut self, pattern: impl Into<String>) -> Self {
381 self.allow.push(pattern.into());
382 self
383 }
384
385 pub fn ask(mut self, pattern: impl Into<String>) -> Self {
387 self.ask.push(pattern.into());
388 self
389 }
390
391 pub fn deny(mut self, pattern: impl Into<String>) -> Self {
393 self.deny.push(pattern.into());
394 self
395 }
396
397 pub fn is_empty(&self) -> bool {
399 self.allow.is_empty() && self.ask.is_empty() && self.deny.is_empty()
400 }
401}
402
403#[cfg(test)]
404mod tests {
405 use crate::PermissionSet;
406
407 use super::*;
408
409 #[test]
410 fn test_settings_serialization() {
411 let settings = Settings::new()
412 .with_model("claude-opus-4-5-20251101")
413 .with_permissions(PermissionSet::new().allow("Bash(git:*)").deny("Read(.env)"));
414
415 let json = serde_json::to_string_pretty(&settings).unwrap();
416 let parsed: Settings = serde_json::from_str(&json).unwrap();
417
418 assert_eq!(settings, parsed);
419 assert_eq!(parsed.model.unwrap(), "claude-opus-4-5-20251101");
420 }
421
422 #[test]
423 fn test_permissions_builder() {
424 let perms = Permissions::new()
425 .allow("Bash(git diff:*)")
426 .allow("Bash(npm run:*)")
427 .deny("Read(.env)")
428 .ask("Bash(rm:*)");
429
430 assert_eq!(perms.allow.len(), 2);
431 assert_eq!(perms.deny.len(), 1);
432 assert_eq!(perms.ask.len(), 1);
433 }
434
435 #[test]
436 fn test_settings_level_priority() {
437 let levels = SettingsLevel::all_by_priority();
438 assert_eq!(levels[0], SettingsLevel::System);
439 assert_eq!(levels[3], SettingsLevel::User);
440 }
441
442 #[test]
443 fn test_empty_settings() {
444 let settings = Settings::new();
445 assert!(settings.is_empty());
446
447 let settings_with_model = Settings::new().with_model("test");
448 assert!(!settings_with_model.is_empty());
449 }
450
451 #[test]
452 fn test_clash_installed_marker() {
453 let settings = Settings::new();
454 assert!(!settings.is_clash_installed());
455
456 let settings = Settings::new().with_clash_installed();
457 assert!(settings.is_clash_installed());
458
459 let mut settings = Settings::new();
460 settings.mark_clash_installed();
461 assert!(settings.is_clash_installed());
462
463 settings.clear_clash_installed();
464 assert!(!settings.is_clash_installed());
465 }
466
467 #[test]
468 fn test_clash_installed_serialization() {
469 let settings = Settings::new()
470 .with_model("test-model")
471 .with_clash_installed();
472
473 let json = serde_json::to_string(&settings).unwrap();
474 assert!(json.contains("_clashInstalled"));
475
476 let parsed: Settings = serde_json::from_str(&json).unwrap();
477 assert!(parsed.is_clash_installed());
478 assert_eq!(parsed.model.as_deref(), Some("test-model"));
479 }
480
481 #[test]
482 fn test_bypass_permissions_builder() {
483 let settings = Settings::new().with_bypass_permissions(true);
484 assert_eq!(settings.bypass_permissions, Some(true));
485 assert!(!settings.is_empty());
486 }
487
488 #[test]
489 fn test_bypass_permissions_serialization() {
490 let settings = Settings::new().with_bypass_permissions(true);
491 let json = serde_json::to_string(&settings).unwrap();
492 assert!(json.contains("\"bypassPermissions\":true"));
493
494 let parsed: Settings = serde_json::from_str(&json).unwrap();
495 assert_eq!(parsed.bypass_permissions, Some(true));
496 }
497
498 #[test]
499 fn test_bypass_permissions_deserialization() {
500 let json = r#"{"bypassPermissions": true}"#;
501 let settings: Settings = serde_json::from_str(json).unwrap();
502 assert_eq!(settings.bypass_permissions, Some(true));
503 }
504}