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 fn load_all_settings(project_dir: &Path) -> Settings {
213 let mut settings = Settings::new();
214
215 if let Some(user_settings) = Self::load_user_settings() {
217 tracing::debug!("Loaded user settings");
218 settings.merge(user_settings);
219 }
220
221 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 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 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 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 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 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 pub fn settings(&self) -> &Settings {
280 &self.settings
281 }
282
283 pub fn project_dir(&self) -> &Path {
285 &self.project_dir
286 }
287
288 pub fn reload(&mut self) {
290 self.settings = Self::load_all_settings(&self.project_dir);
291 }
292
293 pub fn system_prompt(&self) -> Option<&str> {
295 self.settings.system_prompt.as_deref()
296 }
297
298 pub fn permission_mode(&self) -> Option<&str> {
300 self.settings.permission_mode.as_deref()
301 }
302
303 pub fn model(&self) -> Option<&str> {
305 self.settings.model.as_deref()
306 }
307
308 pub fn small_fast_model(&self) -> Option<&str> {
310 self.settings.small_fast_model.as_deref()
311 }
312
313 pub fn api_base_url(&self) -> Option<&str> {
315 self.settings.api_base_url.as_deref()
316 }
317
318 pub fn always_thinking_enabled(&self) -> bool {
320 self.settings.always_thinking_enabled.unwrap_or(false)
321 }
322
323 pub fn mcp_servers(&self) -> Option<&HashMap<String, McpServerConfig>> {
325 self.settings.mcp_servers.as_ref()
326 }
327
328 pub fn env(&self) -> Option<&HashMap<String, String>> {
330 self.settings.env.as_ref()
331 }
332
333 pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
335 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 let Some(ref allowed) = self.settings.allowed_tools {
344 return allowed.iter().any(|t| t == tool_name);
345 }
346
347 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 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 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 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 assert_eq!(manager.model(), Some("claude-sonnet"));
494 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 assert!(manager.is_tool_allowed("Read"));
508 assert!(manager.is_tool_allowed("Write"));
509
510 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 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 let mut file = std::fs::File::create(&settings_file).unwrap();
545 writeln!(file, r#"{{"model": "claude-sonnet"}}"#).unwrap();
546
547 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 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 let local_settings_file = settings_dir.join("settings.local.json");
589
590 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 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 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}