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    /// Load and merge all settings sources
210    ///
211    /// Priority: Local > Project > User
212    fn load_all_settings(project_dir: &Path) -> Settings {
213        let mut settings = Settings::new();
214
215        // 1. Load user settings (~/.claude/settings.json)
216        if let Some(user_settings) = Self::load_user_settings() {
217            tracing::debug!("Loaded user settings");
218            settings.merge(user_settings);
219        }
220
221        // 2. Load project settings (.claude/settings.json)
222        if let Some(project_settings) = Self::load_project_settings(project_dir) {
223            tracing::debug!("Loaded project settings from {:?}", project_dir);
224            settings.merge(project_settings);
225        }
226
227        // 3. Load local settings (.claude/settings.local.json)
228        if let Some(local_settings) = Self::load_local_settings(project_dir) {
229            tracing::debug!("Loaded local settings from {:?}", project_dir);
230            settings.merge(local_settings);
231        }
232
233        settings
234    }
235
236    /// Load user settings from ~/.claude/settings.json
237    fn load_user_settings() -> Option<Settings> {
238        let home = dirs::home_dir()?;
239        let path = home.join(USER_SETTINGS_DIR).join(SETTINGS_FILE);
240        Self::load_settings_file(&path)
241    }
242
243    /// Load project settings from .claude/settings.json
244    fn load_project_settings(project_dir: &Path) -> Option<Settings> {
245        let path = project_dir.join(PROJECT_SETTINGS_DIR).join(SETTINGS_FILE);
246        Self::load_settings_file(&path)
247    }
248
249    /// Load local settings from .claude/settings.local.json
250    fn load_local_settings(project_dir: &Path) -> Option<Settings> {
251        let path = project_dir
252            .join(PROJECT_SETTINGS_DIR)
253            .join(LOCAL_SETTINGS_FILE);
254        Self::load_settings_file(&path)
255    }
256
257    /// Load settings from a file
258    fn load_settings_file(path: &Path) -> Option<Settings> {
259        if !path.exists() {
260            return None;
261        }
262
263        match std::fs::read_to_string(path) {
264            Ok(content) => match serde_json::from_str(&content) {
265                Ok(settings) => Some(settings),
266                Err(e) => {
267                    tracing::warn!("Failed to parse settings file {:?}: {}", path, e);
268                    None
269                }
270            },
271            Err(e) => {
272                tracing::warn!("Failed to read settings file {:?}: {}", path, e);
273                None
274            }
275        }
276    }
277
278    /// Get the merged settings
279    pub fn settings(&self) -> &Settings {
280        &self.settings
281    }
282
283    /// Get the project directory
284    pub fn project_dir(&self) -> &Path {
285        &self.project_dir
286    }
287
288    /// Reload settings from all sources
289    pub fn reload(&mut self) {
290        self.settings = Self::load_all_settings(&self.project_dir);
291    }
292
293    /// Get the system prompt if configured
294    pub fn system_prompt(&self) -> Option<&str> {
295        self.settings.system_prompt.as_deref()
296    }
297
298    /// Get the permission mode if configured
299    pub fn permission_mode(&self) -> Option<&str> {
300        self.settings.permission_mode.as_deref()
301    }
302
303    /// Get the model if configured
304    pub fn model(&self) -> Option<&str> {
305        self.settings.model.as_deref()
306    }
307
308    /// Get the small/fast model if configured
309    pub fn small_fast_model(&self) -> Option<&str> {
310        self.settings.small_fast_model.as_deref()
311    }
312
313    /// Get the API base URL if configured
314    pub fn api_base_url(&self) -> Option<&str> {
315        self.settings.api_base_url.as_deref()
316    }
317
318    /// Get whether extended thinking mode is always enabled
319    pub fn always_thinking_enabled(&self) -> bool {
320        self.settings.always_thinking_enabled.unwrap_or(false)
321    }
322
323    /// Get MCP servers configuration
324    pub fn mcp_servers(&self) -> Option<&HashMap<String, McpServerConfig>> {
325        self.settings.mcp_servers.as_ref()
326    }
327
328    /// Get environment variables
329    pub fn env(&self) -> Option<&HashMap<String, String>> {
330        self.settings.env.as_ref()
331    }
332
333    /// Check if a tool is allowed
334    pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
335        // If denied_tools is set and contains the tool, deny it
336        if let Some(ref denied) = self.settings.denied_tools {
337            if denied.iter().any(|t| t == tool_name) {
338                return false;
339            }
340        }
341
342        // If allowed_tools is set, check if tool is in the list
343        if let Some(ref allowed) = self.settings.allowed_tools {
344            return allowed.iter().any(|t| t == tool_name);
345        }
346
347        // Default: allow all tools
348        true
349    }
350}
351
352impl Default for SettingsManager {
353    fn default() -> Self {
354        Self {
355            settings: Settings::default(),
356            project_dir: PathBuf::from("."),
357        }
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use std::io::Write;
365    use tempfile::TempDir;
366
367    #[test]
368    fn test_settings_default() {
369        let settings = Settings::new();
370        assert!(settings.system_prompt.is_none());
371        assert!(settings.model.is_none());
372        assert!(settings.mcp_servers.is_none());
373    }
374
375    #[test]
376    fn test_settings_merge() {
377        let mut base = Settings::new();
378        base.model = Some("claude-3".to_string());
379        base.system_prompt = Some("Base prompt".to_string());
380
381        let mut override_settings = Settings::new();
382        override_settings.model = Some("claude-4".to_string());
383        override_settings.permission_mode = Some("acceptEdits".to_string());
384
385        base.merge(override_settings);
386
387        assert_eq!(base.model, Some("claude-4".to_string()));
388        assert_eq!(base.system_prompt, Some("Base prompt".to_string()));
389        assert_eq!(base.permission_mode, Some("acceptEdits".to_string()));
390    }
391
392    #[test]
393    fn test_settings_merge_mcp_servers() {
394        let mut base = Settings::new();
395        let mut base_servers = HashMap::new();
396        base_servers.insert(
397            "server1".to_string(),
398            McpServerConfig {
399                command: "cmd1".to_string(),
400                args: vec![],
401                env: None,
402                disabled: false,
403            },
404        );
405        base.mcp_servers = Some(base_servers);
406
407        let mut override_settings = Settings::new();
408        let mut override_servers = HashMap::new();
409        override_servers.insert(
410            "server2".to_string(),
411            McpServerConfig {
412                command: "cmd2".to_string(),
413                args: vec![],
414                env: None,
415                disabled: false,
416            },
417        );
418        override_settings.mcp_servers = Some(override_servers);
419
420        base.merge(override_settings);
421
422        let servers = base.mcp_servers.unwrap();
423        assert_eq!(servers.len(), 2);
424        assert!(servers.contains_key("server1"));
425        assert!(servers.contains_key("server2"));
426    }
427
428    #[test]
429    fn test_settings_manager_new() {
430        let temp_dir = TempDir::new().unwrap();
431        let manager = SettingsManager::new(temp_dir.path()).unwrap();
432
433        // Should load settings (may include user settings from ~/.claude)
434        // Just verify the manager was created successfully
435        assert_eq!(manager.project_dir(), temp_dir.path());
436    }
437
438    #[test]
439    fn test_settings_manager_load_project_settings() {
440        let temp_dir = TempDir::new().unwrap();
441        let settings_dir = temp_dir.path().join(".claude");
442        std::fs::create_dir_all(&settings_dir).unwrap();
443
444        let settings_file = settings_dir.join("settings.json");
445        let mut file = std::fs::File::create(&settings_file).unwrap();
446        writeln!(
447            file,
448            r#"{{
449            "model": "claude-opus",
450            "systemPrompt": "You are helpful"
451        }}"#
452        )
453        .unwrap();
454
455        let manager = SettingsManager::new(temp_dir.path()).unwrap();
456
457        assert_eq!(manager.model(), Some("claude-opus"));
458        assert_eq!(manager.system_prompt(), Some("You are helpful"));
459    }
460
461    #[test]
462    fn test_settings_manager_local_overrides_project() {
463        let temp_dir = TempDir::new().unwrap();
464        let settings_dir = temp_dir.path().join(".claude");
465        std::fs::create_dir_all(&settings_dir).unwrap();
466
467        // Create project settings
468        let project_settings = settings_dir.join("settings.json");
469        let mut file = std::fs::File::create(&project_settings).unwrap();
470        writeln!(
471            file,
472            r#"{{
473            "model": "claude-opus",
474            "systemPrompt": "Project prompt"
475        }}"#
476        )
477        .unwrap();
478
479        // Create local settings (higher priority)
480        let local_settings = settings_dir.join("settings.local.json");
481        let mut file = std::fs::File::create(&local_settings).unwrap();
482        writeln!(
483            file,
484            r#"{{
485            "model": "claude-sonnet"
486        }}"#
487        )
488        .unwrap();
489
490        let manager = SettingsManager::new(temp_dir.path()).unwrap();
491
492        // Local model should override project
493        assert_eq!(manager.model(), Some("claude-sonnet"));
494        // System prompt from project should remain
495        assert_eq!(manager.system_prompt(), Some("Project prompt"));
496    }
497
498    #[test]
499    fn test_is_tool_allowed() {
500        let mut settings = Settings::new();
501        let manager = SettingsManager {
502            settings: settings.clone(),
503            project_dir: PathBuf::from("."),
504        };
505
506        // Default: all tools allowed
507        assert!(manager.is_tool_allowed("Read"));
508        assert!(manager.is_tool_allowed("Write"));
509
510        // With allowed list
511        settings.allowed_tools = Some(vec!["Read".to_string(), "Edit".to_string()]);
512        let manager = SettingsManager {
513            settings: settings.clone(),
514            project_dir: PathBuf::from("."),
515        };
516        assert!(manager.is_tool_allowed("Read"));
517        assert!(!manager.is_tool_allowed("Write"));
518
519        // With denied list
520        settings.allowed_tools = None;
521        settings.denied_tools = Some(vec!["Bash".to_string()]);
522        let manager = SettingsManager {
523            settings,
524            project_dir: PathBuf::from("."),
525        };
526        assert!(manager.is_tool_allowed("Read"));
527        assert!(!manager.is_tool_allowed("Bash"));
528    }
529
530    #[test]
531    fn test_settings_manager_reload() {
532        let temp_dir = TempDir::new().unwrap();
533        let settings_dir = temp_dir.path().join(".claude");
534        std::fs::create_dir_all(&settings_dir).unwrap();
535
536        let settings_file = settings_dir.join("settings.json");
537        let mut file = std::fs::File::create(&settings_file).unwrap();
538        writeln!(file, r#"{{"model": "claude-opus"}}"#).unwrap();
539
540        let mut manager = SettingsManager::new(temp_dir.path()).unwrap();
541        assert_eq!(manager.model(), Some("claude-opus"));
542
543        // Update the file
544        let mut file = std::fs::File::create(&settings_file).unwrap();
545        writeln!(file, r#"{{"model": "claude-sonnet"}}"#).unwrap();
546
547        // Reload
548        manager.reload();
549        assert_eq!(manager.model(), Some("claude-sonnet"));
550    }
551
552    #[test]
553    #[serial_test::serial]
554    fn test_settings_deserialize_always_thinking_enabled() {
555        // Direct test of Settings deserialization
556        let json_true = r#"{"alwaysThinkingEnabled": true}"#;
557        let settings_true: Settings = serde_json::from_str(json_true).unwrap();
558        assert_eq!(
559            settings_true.always_thinking_enabled,
560            Some(true),
561            "Should parse alwaysThinkingEnabled: true"
562        );
563
564        let json_false = r#"{"alwaysThinkingEnabled": false}"#;
565        let settings_false: Settings = serde_json::from_str(json_false).unwrap();
566        assert_eq!(
567            settings_false.always_thinking_enabled,
568            Some(false),
569            "Should parse alwaysThinkingEnabled: false"
570        );
571
572        let json_none = r#"{"model": "test"}"#;
573        let settings_none: Settings = serde_json::from_str(json_none).unwrap();
574        assert_eq!(
575            settings_none.always_thinking_enabled, None,
576            "Should default to None when not specified"
577        );
578    }
579
580    #[test]
581    #[serial_test::serial]
582    fn test_always_thinking_enabled_parsing() {
583        let temp_dir = TempDir::new().unwrap();
584        let settings_dir = temp_dir.path().join(".claude");
585        std::fs::create_dir_all(&settings_dir).unwrap();
586
587        // Use settings.local.json (higher priority than user settings)
588        let local_settings_file = settings_dir.join("settings.local.json");
589
590        // Test 1: alwaysThinkingEnabled = true
591        drop(std::fs::remove_file(&local_settings_file));
592        let mut file = std::fs::File::create(&local_settings_file).unwrap();
593        writeln!(file, r#"{{"alwaysThinkingEnabled": true}}"#).unwrap();
594        drop(file);
595
596        let manager = SettingsManager::new(temp_dir.path()).unwrap();
597        assert!(
598            manager.always_thinking_enabled(),
599            "alwaysThinkingEnabled should be true, got {}",
600            manager.always_thinking_enabled()
601        );
602
603        // Test 2: alwaysThinkingEnabled = false
604        drop(std::fs::remove_file(&local_settings_file));
605        let mut file = std::fs::File::create(&local_settings_file).unwrap();
606        writeln!(file, r#"{{"alwaysThinkingEnabled": false}}"#).unwrap();
607        drop(file);
608
609        let manager = SettingsManager::new(temp_dir.path()).unwrap();
610        assert!(
611            !manager.always_thinking_enabled(),
612            "alwaysThinkingEnabled should be false, got {}",
613            manager.always_thinking_enabled()
614        );
615
616        // Test 3: Without alwaysThinkingEnabled (should default to false)
617        // Note: User settings may have alwaysThinkingEnabled: true, so we need to
618        // explicitly set it to false in the project settings to override
619        drop(std::fs::remove_file(&local_settings_file));
620        let mut file = std::fs::File::create(&local_settings_file).unwrap();
621        writeln!(
622            file,
623            r#"{{"model": "claude-opus", "alwaysThinkingEnabled": false}}"#
624        )
625        .unwrap();
626        drop(file);
627
628        let manager = SettingsManager::new(temp_dir.path()).unwrap();
629        assert!(
630            !manager.always_thinking_enabled(),
631            "alwaysThinkingEnabled should be false when explicitly set, got {}",
632            manager.always_thinking_enabled()
633        );
634    }
635}