claude_code_acp/settings/
manager.rs1use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9
10use super::rule::PermissionSettings;
11use crate::types::Result;
12
13const 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
23#[serde(rename_all = "camelCase")]
24pub struct Settings {
25 #[serde(default)]
27 pub system_prompt: Option<String>,
28
29 #[serde(default)]
31 pub permission_mode: Option<String>,
32
33 #[serde(default)]
35 pub model: Option<String>,
36
37 #[serde(default)]
39 pub small_fast_model: Option<String>,
40
41 #[serde(default)]
43 pub api_base_url: Option<String>,
44
45 #[serde(default)]
48 pub always_thinking_enabled: Option<bool>,
49
50 #[serde(default)]
52 pub allowed_tools: Option<Vec<String>>,
53
54 #[serde(default)]
56 pub denied_tools: Option<Vec<String>>,
57
58 #[serde(default)]
60 pub permissions: Option<PermissionSettings>,
61
62 #[serde(default)]
64 pub mcp_servers: Option<HashMap<String, McpServerConfig>>,
65
66 #[serde(default)]
68 pub env: Option<HashMap<String, String>>,
69
70 #[serde(flatten)]
72 pub extra: HashMap<String, serde_json::Value>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct McpServerConfig {
79 pub command: String,
81
82 #[serde(default)]
84 pub args: Vec<String>,
85
86 #[serde(default)]
88 pub env: Option<HashMap<String, String>>,
89
90 #[serde(default)]
92 pub disabled: bool,
93}
94
95impl Settings {
96 pub fn new() -> Self {
98 Self::default()
99 }
100
101 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 if let Some(other_perms) = other.permissions {
131 let perms = self
132 .permissions
133 .get_or_insert_with(PermissionSettings::default);
134 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 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 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 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 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 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 for (key, value) in other.extra {
179 self.extra.insert(key, value);
180 }
181 }
182}
183
184#[derive(Debug)]
186pub struct SettingsManager {
187 settings: Settings,
189 project_dir: PathBuf,
191}
192
193impl SettingsManager {
194 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 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 fn load_all_settings(project_dir: &Path) -> Settings {
228 let mut settings = Settings::new();
229
230 if let Some(user_settings) = Self::load_user_settings() {
232 tracing::debug!("Loaded user settings");
233 settings.merge(user_settings);
234 }
235
236 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 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 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 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 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 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 pub fn settings(&self) -> &Settings {
295 &self.settings
296 }
297
298 pub fn project_dir(&self) -> &Path {
300 &self.project_dir
301 }
302
303 pub fn reload(&mut self) {
305 self.settings = Self::load_all_settings(&self.project_dir);
306 }
307
308 pub fn system_prompt(&self) -> Option<&str> {
310 self.settings.system_prompt.as_deref()
311 }
312
313 pub fn permission_mode(&self) -> Option<&str> {
315 self.settings.permission_mode.as_deref()
316 }
317
318 pub fn model(&self) -> Option<&str> {
320 self.settings.model.as_deref()
321 }
322
323 pub fn small_fast_model(&self) -> Option<&str> {
325 self.settings.small_fast_model.as_deref()
326 }
327
328 pub fn api_base_url(&self) -> Option<&str> {
330 self.settings.api_base_url.as_deref()
331 }
332
333 pub fn always_thinking_enabled(&self) -> bool {
335 self.settings.always_thinking_enabled.unwrap_or(false)
336 }
337
338 pub fn mcp_servers(&self) -> Option<&HashMap<String, McpServerConfig>> {
340 self.settings.mcp_servers.as_ref()
341 }
342
343 pub fn env(&self) -> Option<&HashMap<String, String>> {
345 self.settings.env.as_ref()
346 }
347
348 pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
350 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 let Some(ref allowed) = self.settings.allowed_tools {
359 return allowed.iter().any(|t| t == tool_name);
360 }
361
362 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 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 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 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 assert_eq!(manager.model(), Some("claude-sonnet"));
509 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 assert!(manager.is_tool_allowed("Read"));
523 assert!(manager.is_tool_allowed("Write"));
524
525 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 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 let mut file = std::fs::File::create(&settings_file).unwrap();
560 writeln!(file, r#"{{"model": "claude-sonnet"}}"#).unwrap();
561
562 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 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 let local_settings_file = settings_dir.join("settings.local.json");
604
605 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 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 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}