Skip to main content

claude_settings/
types.rs

1//! Type definitions for Claude Code settings.
2//!
3//! This module contains all the data structures used to represent
4//! Claude Code settings at various levels (user, project, system).
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use crate::permission;
10
11/// The main settings structure for Claude Code.
12///
13/// This represents the full schema of a Claude Code settings file,
14/// supporting all configuration options available at user and project levels.
15#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
16#[serde(rename_all = "camelCase")]
17pub struct Settings {
18    /// Tool permission configuration controlling what Claude Code can access.
19    #[serde(default, skip_serializing_if = "permission::PermissionSet::is_empty")]
20    pub permissions: permission::PermissionSet,
21
22    /// Environment variables to set for tool execution.
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub env: Option<HashMap<String, String>>,
25
26    /// Override the default Claude model.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub model: Option<String>,
29
30    /// Hook configurations for pre/post tool execution.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub hooks: Option<Hooks>,
33
34    /// Sandbox configuration for command execution.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub sandbox: Option<Sandbox>,
37
38    /// Attribution settings for git commits and PRs.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub attribution: Option<Attribution>,
41
42    /// Map of enabled plugins.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub enabled_plugins: Option<HashMap<String, bool>>,
45
46    /// Number of days before session cleanup (default: 30).
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub cleanup_period_days: Option<u32>,
49
50    /// Preferred response language.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub language: Option<String>,
53
54    /// When true, Claude Code starts with permissions bypassed (equivalent to
55    /// `--dangerously-skip-permissions`). Useful when an external tool like
56    /// Clash is the sole permission handler.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub bypass_permissions: Option<bool>,
59
60    /// Any additional fields not explicitly defined.
61    #[serde(flatten)]
62    pub extra: HashMap<String, serde_json::Value>,
63}
64
65/// Tool permission configuration.
66///
67/// Controls which tools Claude Code is allowed to use, which require
68/// user confirmation, and which are explicitly denied.
69#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
70pub struct Permissions {
71    /// Tools that are always allowed without confirmation.
72    /// Format: "ToolName(pattern:*)" or just "ToolName"
73    #[serde(default, skip_serializing_if = "Vec::is_empty")]
74    pub allow: Vec<String>,
75
76    /// Tools that require user confirmation before use.
77    #[serde(default, skip_serializing_if = "Vec::is_empty")]
78    pub ask: Vec<String>,
79
80    /// Tools that are explicitly denied.
81    #[serde(default, skip_serializing_if = "Vec::is_empty")]
82    pub deny: Vec<String>,
83}
84
85/// Hook configurations for various lifecycle events.
86#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
87#[serde(rename_all = "PascalCase")]
88pub struct Hooks {
89    /// Hooks that run before a tool is used.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub pre_tool_use: Option<HookConfig>,
92
93    /// Hooks that run after a tool is used.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub post_tool_use: Option<HookConfig>,
96
97    /// Hooks that run when a permission request is shown.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub permission_request: Option<HookConfig>,
100
101    /// Hooks that run at session start.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub session_start: Option<Vec<HookMatcher>>,
104
105    /// Hooks that run when Claude Code stops.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub stop: Option<Vec<HookMatcher>>,
108
109    /// Hooks that run on notification events.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub notification: Option<HookConfig>,
112}
113
114/// Hook configuration that can be either a simple command map or a list of matchers.
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
116#[serde(untagged)]
117pub enum HookConfig {
118    /// Simple map of tool name to command.
119    Simple(HashMap<String, String>),
120    /// List of hook matchers with patterns.
121    Matchers(Vec<HookMatcher>),
122}
123
124impl HookConfig {
125    // FIXME(eliot): super gross but we probably should re-write any existing hook configs
126    // into Matcher based hook configs.
127    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/// A hook matcher that triggers hooks based on patterns.
178#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
179pub struct HookMatcher {
180    /// Pattern to match against (empty string matches all).
181    #[serde(default)]
182    pub matcher: String,
183
184    /// List of hooks to execute when pattern matches.
185    #[serde(default)]
186    pub hooks: Vec<Hook>,
187}
188
189/// A single hook definition.
190#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
191pub struct Hook {
192    /// The type of hook (e.g., "command").
193    #[serde(rename = "type")]
194    pub hook_type: String,
195
196    /// The command to execute.
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub command: Option<String>,
199
200    /// Timeout in milliseconds.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub timeout: Option<u64>,
203}
204
205/// Sandbox configuration for command execution.
206#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
207#[serde(rename_all = "camelCase")]
208pub struct Sandbox {
209    /// Whether sandboxing is enabled.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub enabled: Option<bool>,
212
213    /// Automatically allow bash commands when sandboxed.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub auto_allow_bash_if_sandboxed: Option<bool>,
216
217    /// Commands excluded from sandboxing.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub excluded_commands: Option<Vec<String>>,
220}
221
222/// Attribution settings for git operations.
223#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
224pub struct Attribution {
225    /// Message to include in git commits.
226    #[serde(default, skip_serializing_if = "Option::is_none")]
227    pub commit: Option<String>,
228
229    /// Message to include in pull requests.
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub pr: Option<String>,
232}
233
234/// Represents the scope/level at which settings are applied.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
236pub enum SettingsLevel {
237    /// System-wide managed settings (highest priority, typically read-only).
238    /// Location: /etc/claude-code/managed-settings.json
239    System,
240
241    /// Project-level local settings (not committed to version control).
242    /// Location: .claude/settings.local.json
243    ProjectLocal,
244
245    /// Project-level shared settings (committed to version control).
246    /// Location: .claude/settings.json
247    Project,
248
249    /// User-level settings (personal defaults).
250    /// Location: ~/.claude/settings.json
251    User,
252}
253
254impl SettingsLevel {
255    /// Returns all levels in order of priority (highest to lowest).
256    pub fn all_by_priority() -> &'static [SettingsLevel] {
257        &[
258            SettingsLevel::System,
259            SettingsLevel::ProjectLocal,
260            SettingsLevel::Project,
261            SettingsLevel::User,
262        ]
263    }
264
265    /// Returns the display name for this level.
266    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    /// Creates a new empty Settings instance.
278    pub fn new() -> Self {
279        Self::default()
280    }
281
282    /// Creates a Settings instance with the specified permissions.
283    pub fn with_permissions(mut self, permissions: permission::PermissionSet) -> Self {
284        self.permissions = permissions;
285        self
286    }
287
288    /// Creates a Settings instance with the specified environment variables.
289    pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
290        self.env = Some(env);
291        self
292    }
293
294    /// Creates a Settings instance with the specified model.
295    pub fn with_model(mut self, model: impl Into<String>) -> Self {
296        self.model = Some(model.into());
297        self
298    }
299
300    /// Creates a Settings instance with the specified hooks.
301    pub fn with_hooks(mut self, hooks: Hooks) -> Self {
302        self.hooks = Some(hooks);
303        self
304    }
305
306    /// Creates a Settings instance with the specified sandbox configuration.
307    pub fn with_sandbox(mut self, sandbox: Sandbox) -> Self {
308        self.sandbox = Some(sandbox);
309        self
310    }
311
312    /// Creates a Settings instance with the specified attribution.
313    pub fn with_attribution(mut self, attribution: Attribution) -> Self {
314        self.attribution = Some(attribution);
315        self
316    }
317
318    /// Creates a Settings instance with bypass_permissions enabled or disabled.
319    pub fn with_bypass_permissions(mut self, enabled: bool) -> Self {
320        self.bypass_permissions = Some(enabled);
321        self
322    }
323
324    /// Returns true if all fields are None or empty.
325    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    /// The key used in `extra` to track clash installation status.
340    const CLASH_INSTALLED_KEY: &'static str = "_clashInstalled";
341
342    /// Returns true if these settings were installed by clash.
343    ///
344    /// This checks for the presence of the `_clashInstalled` marker field.
345    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    /// Marks these settings as installed by clash.
352    ///
353    /// This sets the `_clashInstalled` field to `true`.
354    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    /// Clears the clash installation marker from these settings.
362    pub fn clear_clash_installed(&mut self) {
363        self.extra.remove(Self::CLASH_INSTALLED_KEY);
364    }
365
366    /// Builder method to mark settings as clash-installed.
367    pub fn with_clash_installed(mut self) -> Self {
368        self.mark_clash_installed();
369        self
370    }
371}
372
373impl Permissions {
374    /// Creates a new empty Permissions instance.
375    pub fn new() -> Self {
376        Self::default()
377    }
378
379    /// Adds a tool pattern to the allow list.
380    pub fn allow(mut self, pattern: impl Into<String>) -> Self {
381        self.allow.push(pattern.into());
382        self
383    }
384
385    /// Adds a tool pattern to the ask list.
386    pub fn ask(mut self, pattern: impl Into<String>) -> Self {
387        self.ask.push(pattern.into());
388        self
389    }
390
391    /// Adds a tool pattern to the deny list.
392    pub fn deny(mut self, pattern: impl Into<String>) -> Self {
393        self.deny.push(pattern.into());
394        self
395    }
396
397    /// Returns true if all lists are empty.
398    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}