claude_code_acp/settings/
manager.rs

1//! Settings manager implementation
2//!
3//! Handles loading, merging, and accessing settings from multiple sources.
4
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9
10use super::rule::PermissionSettings;
11use crate::types::Result;
12
13/// Settings file names
14const USER_SETTINGS_DIR: &str = ".claude";
15const PROJECT_SETTINGS_DIR: &str = ".claude";
16const SETTINGS_FILE: &str = "settings.json";
17const LOCAL_SETTINGS_FILE: &str = "settings.local.json";
18
19/// Claude Code settings structure
20///
21/// This mirrors the settings structure used by Claude Code.
22#[derive(Debug, Clone, Default, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct Settings {
25    /// Custom system prompt additions
26    #[serde(default)]
27    pub system_prompt: Option<String>,
28
29    /// Permission mode settings
30    #[serde(default)]
31    pub permission_mode: Option<String>,
32
33    /// Model to use
34    #[serde(default)]
35    pub model: Option<String>,
36
37    /// Small/fast model for quick operations
38    #[serde(default)]
39    pub small_fast_model: Option<String>,
40
41    /// API base URL override
42    #[serde(default)]
43    pub api_base_url: Option<String>,
44
45    /// Always enable extended thinking mode
46    /// When true, MAX_THINKING_TOKENS will be set to a default value
47    #[serde(default)]
48    pub always_thinking_enabled: Option<bool>,
49
50    /// Allowed tools list (legacy, use permissions instead)
51    #[serde(default)]
52    pub allowed_tools: Option<Vec<String>>,
53
54    /// Denied tools list (legacy, use permissions instead)
55    #[serde(default)]
56    pub denied_tools: Option<Vec<String>>,
57
58    /// Permission settings with allow/deny/ask rules
59    #[serde(default)]
60    pub permissions: Option<PermissionSettings>,
61
62    /// MCP servers configuration
63    #[serde(default)]
64    pub mcp_servers: Option<HashMap<String, McpServerConfig>>,
65
66    /// Custom environment variables
67    #[serde(default)]
68    pub env: Option<HashMap<String, String>>,
69
70    /// Additional settings as raw JSON
71    #[serde(flatten)]
72    pub extra: HashMap<String, serde_json::Value>,
73}
74
75/// MCP server configuration
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct McpServerConfig {
79    /// Command to start the MCP server
80    pub command: String,
81
82    /// Arguments for the command
83    #[serde(default)]
84    pub args: Vec<String>,
85
86    /// Environment variables for the server
87    #[serde(default)]
88    pub env: Option<HashMap<String, String>>,
89
90    /// Whether the server is disabled
91    #[serde(default)]
92    pub disabled: bool,
93}
94
95impl Settings {
96    /// Create empty settings
97    pub fn new() -> Self {
98        Self::default()
99    }
100
101    /// Merge another settings into this one
102    ///
103    /// Values from `other` take precedence over `self`.
104    pub fn merge(&mut self, other: Settings) {
105        if other.system_prompt.is_some() {
106            self.system_prompt = other.system_prompt;
107        }
108        if other.permission_mode.is_some() {
109            self.permission_mode = other.permission_mode;
110        }
111        if other.model.is_some() {
112            self.model = other.model;
113        }
114        if other.small_fast_model.is_some() {
115            self.small_fast_model = other.small_fast_model;
116        }
117        if other.api_base_url.is_some() {
118            self.api_base_url = other.api_base_url;
119        }
120        if other.always_thinking_enabled.is_some() {
121            self.always_thinking_enabled = other.always_thinking_enabled;
122        }
123        if other.allowed_tools.is_some() {
124            self.allowed_tools = other.allowed_tools;
125        }
126        if other.denied_tools.is_some() {
127            self.denied_tools = other.denied_tools;
128        }
129        // Merge permissions (combine rules from all sources)
130        if let Some(other_perms) = other.permissions {
131            let perms = self
132                .permissions
133                .get_or_insert_with(PermissionSettings::default);
134            // Merge allow rules
135            if let Some(other_allow) = other_perms.allow {
136                let allow = perms.allow.get_or_insert_with(Vec::new);
137                allow.extend(other_allow);
138            }
139            // Merge deny rules
140            if let Some(other_deny) = other_perms.deny {
141                let deny = perms.deny.get_or_insert_with(Vec::new);
142                deny.extend(other_deny);
143            }
144            // Merge ask rules
145            if let Some(other_ask) = other_perms.ask {
146                let ask = perms.ask.get_or_insert_with(Vec::new);
147                ask.extend(other_ask);
148            }
149            // Override additional_directories and default_mode
150            if other_perms.additional_directories.is_some() {
151                perms.additional_directories = other_perms.additional_directories;
152            }
153            if other_perms.default_mode.is_some() {
154                perms.default_mode = other_perms.default_mode;
155            }
156        }
157        if other.mcp_servers.is_some() {
158            // Merge MCP servers
159            let mut servers = self.mcp_servers.take().unwrap_or_default();
160            if let Some(other_servers) = other.mcp_servers {
161                for (name, config) in other_servers {
162                    servers.insert(name, config);
163                }
164            }
165            self.mcp_servers = Some(servers);
166        }
167        if other.env.is_some() {
168            // Merge env vars
169            let mut env = self.env.take().unwrap_or_default();
170            if let Some(other_env) = other.env {
171                for (key, value) in other_env {
172                    env.insert(key, value);
173                }
174            }
175            self.env = Some(env);
176        }
177        // Merge extra fields
178        for (key, value) in other.extra {
179            self.extra.insert(key, value);
180        }
181    }
182}
183
184/// Settings manager for loading and accessing settings
185#[derive(Debug)]
186pub struct SettingsManager {
187    /// The merged settings
188    settings: Settings,
189    /// Project working directory
190    project_dir: PathBuf,
191}
192
193impl SettingsManager {
194    /// Create a new settings manager and load settings
195    ///
196    /// # Arguments
197    ///
198    /// * `project_dir` - The project working directory
199    pub fn new(project_dir: impl AsRef<Path>) -> Result<Self> {
200        let project_dir = project_dir.as_ref().to_path_buf();
201        let settings = Self::load_all_settings(&project_dir);
202
203        Ok(Self {
204            settings,
205            project_dir,
206        })
207    }
208
209    /// Create a new settings manager with provided settings (fallback)
210    ///
211    /// # Arguments
212    ///
213    /// * `settings` - The settings to use
214    /// * `project_dir` - The project working directory
215    pub fn new_with_settings(settings: Settings, project_dir: impl AsRef<Path>) -> Self {
216        let project_dir = project_dir.as_ref().to_path_buf();
217
218        Self {
219            settings,
220            project_dir,
221        }
222    }
223
224    /// Load and merge all settings sources
225    ///
226    /// Priority: Local > Project > User
227    fn load_all_settings(project_dir: &Path) -> Settings {
228        let mut settings = Settings::new();
229
230        // 1. Load user settings (~/.claude/settings.json)
231        if let Some(user_settings) = Self::load_user_settings() {
232            tracing::debug!("Loaded user settings");
233            settings.merge(user_settings);
234        }
235
236        // 2. Load project settings (.claude/settings.json)
237        if let Some(project_settings) = Self::load_project_settings(project_dir) {
238            tracing::debug!("Loaded project settings from {:?}", project_dir);
239            settings.merge(project_settings);
240        }
241
242        // 3. Load local settings (.claude/settings.local.json)
243        if let Some(local_settings) = Self::load_local_settings(project_dir) {
244            tracing::debug!("Loaded local settings from {:?}", project_dir);
245            settings.merge(local_settings);
246        }
247
248        settings
249    }
250
251    /// Load user settings from ~/.claude/settings.json
252    fn load_user_settings() -> Option<Settings> {
253        let home = dirs::home_dir()?;
254        let path = home.join(USER_SETTINGS_DIR).join(SETTINGS_FILE);
255        Self::load_settings_file(&path)
256    }
257
258    /// Load project settings from .claude/settings.json
259    fn load_project_settings(project_dir: &Path) -> Option<Settings> {
260        let path = project_dir.join(PROJECT_SETTINGS_DIR).join(SETTINGS_FILE);
261        Self::load_settings_file(&path)
262    }
263
264    /// Load local settings from .claude/settings.local.json
265    fn load_local_settings(project_dir: &Path) -> Option<Settings> {
266        let path = project_dir
267            .join(PROJECT_SETTINGS_DIR)
268            .join(LOCAL_SETTINGS_FILE);
269        Self::load_settings_file(&path)
270    }
271
272    /// Load settings from a file
273    fn load_settings_file(path: &Path) -> Option<Settings> {
274        if !path.exists() {
275            return None;
276        }
277
278        match std::fs::read_to_string(path) {
279            Ok(content) => match serde_json::from_str(&content) {
280                Ok(settings) => Some(settings),
281                Err(e) => {
282                    tracing::warn!("Failed to parse settings file {:?}: {}", path, e);
283                    None
284                }
285            },
286            Err(e) => {
287                tracing::warn!("Failed to read settings file {:?}: {}", path, e);
288                None
289            }
290        }
291    }
292
293    /// Get the merged settings
294    pub fn settings(&self) -> &Settings {
295        &self.settings
296    }
297
298    /// Get the project directory
299    pub fn project_dir(&self) -> &Path {
300        &self.project_dir
301    }
302
303    /// Reload settings from all sources
304    pub fn reload(&mut self) {
305        self.settings = Self::load_all_settings(&self.project_dir);
306    }
307
308    /// Get the system prompt if configured
309    pub fn system_prompt(&self) -> Option<&str> {
310        self.settings.system_prompt.as_deref()
311    }
312
313    /// Get the permission mode if configured
314    pub fn permission_mode(&self) -> Option<&str> {
315        self.settings.permission_mode.as_deref()
316    }
317
318    /// Get the model if configured
319    pub fn model(&self) -> Option<&str> {
320        self.settings.model.as_deref()
321    }
322
323    /// Get the small/fast model if configured
324    pub fn small_fast_model(&self) -> Option<&str> {
325        self.settings.small_fast_model.as_deref()
326    }
327
328    /// Get the API base URL if configured
329    pub fn api_base_url(&self) -> Option<&str> {
330        self.settings.api_base_url.as_deref()
331    }
332
333    /// Get whether extended thinking mode is always enabled
334    pub fn always_thinking_enabled(&self) -> bool {
335        self.settings.always_thinking_enabled.unwrap_or(false)
336    }
337
338    /// Get MCP servers configuration
339    pub fn mcp_servers(&self) -> Option<&HashMap<String, McpServerConfig>> {
340        self.settings.mcp_servers.as_ref()
341    }
342
343    /// Get environment variables
344    pub fn env(&self) -> Option<&HashMap<String, String>> {
345        self.settings.env.as_ref()
346    }
347
348    /// Check if a tool is allowed
349    pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
350        // If denied_tools is set and contains the tool, deny it
351        if let Some(ref denied) = self.settings.denied_tools {
352            if denied.iter().any(|t| t == tool_name) {
353                return false;
354            }
355        }
356
357        // If allowed_tools is set, check if tool is in the list
358        if let Some(ref allowed) = self.settings.allowed_tools {
359            return allowed.iter().any(|t| t == tool_name);
360        }
361
362        // Default: allow all tools
363        true
364    }
365}
366
367impl Default for SettingsManager {
368    fn default() -> Self {
369        Self {
370            settings: Settings::default(),
371            project_dir: PathBuf::from("."),
372        }
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use std::io::Write;
380    use tempfile::TempDir;
381
382    #[test]
383    fn test_settings_default() {
384        let settings = Settings::new();
385        assert!(settings.system_prompt.is_none());
386        assert!(settings.model.is_none());
387        assert!(settings.mcp_servers.is_none());
388    }
389
390    #[test]
391    fn test_settings_merge() {
392        let mut base = Settings::new();
393        base.model = Some("claude-3".to_string());
394        base.system_prompt = Some("Base prompt".to_string());
395
396        let mut override_settings = Settings::new();
397        override_settings.model = Some("claude-4".to_string());
398        override_settings.permission_mode = Some("acceptEdits".to_string());
399
400        base.merge(override_settings);
401
402        assert_eq!(base.model, Some("claude-4".to_string()));
403        assert_eq!(base.system_prompt, Some("Base prompt".to_string()));
404        assert_eq!(base.permission_mode, Some("acceptEdits".to_string()));
405    }
406
407    #[test]
408    fn test_settings_merge_mcp_servers() {
409        let mut base = Settings::new();
410        let mut base_servers = HashMap::new();
411        base_servers.insert(
412            "server1".to_string(),
413            McpServerConfig {
414                command: "cmd1".to_string(),
415                args: vec![],
416                env: None,
417                disabled: false,
418            },
419        );
420        base.mcp_servers = Some(base_servers);
421
422        let mut override_settings = Settings::new();
423        let mut override_servers = HashMap::new();
424        override_servers.insert(
425            "server2".to_string(),
426            McpServerConfig {
427                command: "cmd2".to_string(),
428                args: vec![],
429                env: None,
430                disabled: false,
431            },
432        );
433        override_settings.mcp_servers = Some(override_servers);
434
435        base.merge(override_settings);
436
437        let servers = base.mcp_servers.unwrap();
438        assert_eq!(servers.len(), 2);
439        assert!(servers.contains_key("server1"));
440        assert!(servers.contains_key("server2"));
441    }
442
443    #[test]
444    fn test_settings_manager_new() {
445        let temp_dir = TempDir::new().unwrap();
446        let manager = SettingsManager::new(temp_dir.path()).unwrap();
447
448        // Should load settings (may include user settings from ~/.claude)
449        // Just verify the manager was created successfully
450        assert_eq!(manager.project_dir(), temp_dir.path());
451    }
452
453    #[test]
454    fn test_settings_manager_load_project_settings() {
455        let temp_dir = TempDir::new().unwrap();
456        let settings_dir = temp_dir.path().join(".claude");
457        std::fs::create_dir_all(&settings_dir).unwrap();
458
459        let settings_file = settings_dir.join("settings.json");
460        let mut file = std::fs::File::create(&settings_file).unwrap();
461        writeln!(
462            file,
463            r#"{{
464            "model": "claude-opus",
465            "systemPrompt": "You are helpful"
466        }}"#
467        )
468        .unwrap();
469
470        let manager = SettingsManager::new(temp_dir.path()).unwrap();
471
472        assert_eq!(manager.model(), Some("claude-opus"));
473        assert_eq!(manager.system_prompt(), Some("You are helpful"));
474    }
475
476    #[test]
477    fn test_settings_manager_local_overrides_project() {
478        let temp_dir = TempDir::new().unwrap();
479        let settings_dir = temp_dir.path().join(".claude");
480        std::fs::create_dir_all(&settings_dir).unwrap();
481
482        // Create project settings
483        let project_settings = settings_dir.join("settings.json");
484        let mut file = std::fs::File::create(&project_settings).unwrap();
485        writeln!(
486            file,
487            r#"{{
488            "model": "claude-opus",
489            "systemPrompt": "Project prompt"
490        }}"#
491        )
492        .unwrap();
493
494        // Create local settings (higher priority)
495        let local_settings = settings_dir.join("settings.local.json");
496        let mut file = std::fs::File::create(&local_settings).unwrap();
497        writeln!(
498            file,
499            r#"{{
500            "model": "claude-sonnet"
501        }}"#
502        )
503        .unwrap();
504
505        let manager = SettingsManager::new(temp_dir.path()).unwrap();
506
507        // Local model should override project
508        assert_eq!(manager.model(), Some("claude-sonnet"));
509        // System prompt from project should remain
510        assert_eq!(manager.system_prompt(), Some("Project prompt"));
511    }
512
513    #[test]
514    fn test_is_tool_allowed() {
515        let mut settings = Settings::new();
516        let manager = SettingsManager {
517            settings: settings.clone(),
518            project_dir: PathBuf::from("."),
519        };
520
521        // Default: all tools allowed
522        assert!(manager.is_tool_allowed("Read"));
523        assert!(manager.is_tool_allowed("Write"));
524
525        // With allowed list
526        settings.allowed_tools = Some(vec!["Read".to_string(), "Edit".to_string()]);
527        let manager = SettingsManager {
528            settings: settings.clone(),
529            project_dir: PathBuf::from("."),
530        };
531        assert!(manager.is_tool_allowed("Read"));
532        assert!(!manager.is_tool_allowed("Write"));
533
534        // With denied list
535        settings.allowed_tools = None;
536        settings.denied_tools = Some(vec!["Bash".to_string()]);
537        let manager = SettingsManager {
538            settings,
539            project_dir: PathBuf::from("."),
540        };
541        assert!(manager.is_tool_allowed("Read"));
542        assert!(!manager.is_tool_allowed("Bash"));
543    }
544
545    #[test]
546    fn test_settings_manager_reload() {
547        let temp_dir = TempDir::new().unwrap();
548        let settings_dir = temp_dir.path().join(".claude");
549        std::fs::create_dir_all(&settings_dir).unwrap();
550
551        let settings_file = settings_dir.join("settings.json");
552        let mut file = std::fs::File::create(&settings_file).unwrap();
553        writeln!(file, r#"{{"model": "claude-opus"}}"#).unwrap();
554
555        let mut manager = SettingsManager::new(temp_dir.path()).unwrap();
556        assert_eq!(manager.model(), Some("claude-opus"));
557
558        // Update the file
559        let mut file = std::fs::File::create(&settings_file).unwrap();
560        writeln!(file, r#"{{"model": "claude-sonnet"}}"#).unwrap();
561
562        // Reload
563        manager.reload();
564        assert_eq!(manager.model(), Some("claude-sonnet"));
565    }
566
567    #[test]
568    #[serial_test::serial]
569    fn test_settings_deserialize_always_thinking_enabled() {
570        // Direct test of Settings deserialization
571        let json_true = r#"{"alwaysThinkingEnabled": true}"#;
572        let settings_true: Settings = serde_json::from_str(json_true).unwrap();
573        assert_eq!(
574            settings_true.always_thinking_enabled,
575            Some(true),
576            "Should parse alwaysThinkingEnabled: true"
577        );
578
579        let json_false = r#"{"alwaysThinkingEnabled": false}"#;
580        let settings_false: Settings = serde_json::from_str(json_false).unwrap();
581        assert_eq!(
582            settings_false.always_thinking_enabled,
583            Some(false),
584            "Should parse alwaysThinkingEnabled: false"
585        );
586
587        let json_none = r#"{"model": "test"}"#;
588        let settings_none: Settings = serde_json::from_str(json_none).unwrap();
589        assert_eq!(
590            settings_none.always_thinking_enabled, None,
591            "Should default to None when not specified"
592        );
593    }
594
595    #[test]
596    #[serial_test::serial]
597    fn test_always_thinking_enabled_parsing() {
598        let temp_dir = TempDir::new().unwrap();
599        let settings_dir = temp_dir.path().join(".claude");
600        std::fs::create_dir_all(&settings_dir).unwrap();
601
602        // Use settings.local.json (higher priority than user settings)
603        let local_settings_file = settings_dir.join("settings.local.json");
604
605        // Test 1: alwaysThinkingEnabled = true
606        drop(std::fs::remove_file(&local_settings_file));
607        let mut file = std::fs::File::create(&local_settings_file).unwrap();
608        writeln!(file, r#"{{"alwaysThinkingEnabled": true}}"#).unwrap();
609        drop(file);
610
611        let manager = SettingsManager::new(temp_dir.path()).unwrap();
612        assert!(
613            manager.always_thinking_enabled(),
614            "alwaysThinkingEnabled should be true, got {}",
615            manager.always_thinking_enabled()
616        );
617
618        // Test 2: alwaysThinkingEnabled = false
619        drop(std::fs::remove_file(&local_settings_file));
620        let mut file = std::fs::File::create(&local_settings_file).unwrap();
621        writeln!(file, r#"{{"alwaysThinkingEnabled": false}}"#).unwrap();
622        drop(file);
623
624        let manager = SettingsManager::new(temp_dir.path()).unwrap();
625        assert!(
626            !manager.always_thinking_enabled(),
627            "alwaysThinkingEnabled should be false, got {}",
628            manager.always_thinking_enabled()
629        );
630
631        // Test 3: Without alwaysThinkingEnabled (should default to false)
632        // Note: User settings may have alwaysThinkingEnabled: true, so we need to
633        // explicitly set it to false in the project settings to override
634        drop(std::fs::remove_file(&local_settings_file));
635        let mut file = std::fs::File::create(&local_settings_file).unwrap();
636        writeln!(
637            file,
638            r#"{{"model": "claude-opus", "alwaysThinkingEnabled": false}}"#
639        )
640        .unwrap();
641        drop(file);
642
643        let manager = SettingsManager::new(temp_dir.path()).unwrap();
644        assert!(
645            !manager.always_thinking_enabled(),
646            "alwaysThinkingEnabled should be false when explicitly set, got {}",
647            manager.always_thinking_enabled()
648        );
649    }
650}