Skip to main content

tmai_core/config/
claude_settings.rs

1//! Claude Code settings reader for spinnerVerbs configuration
2//!
3//! Settings priority (per official docs):
4//! 1. `{project}/.claude/settings.local.json` (project local)
5//! 2. `{project}/.claude/settings.json` (project shared)
6//! 3. `~/.claude/settings.json` (user settings)
7//!
8//! Note: Settings are cached permanently per project path since Claude Code
9//! requires a session restart to pick up setting changes.
10
11use parking_lot::RwLock;
12use serde::Deserialize;
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16/// Spinner verbs mode
17#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum SpinnerVerbsMode {
20    /// Replace default verbs with custom ones
21    Replace,
22    /// Append custom verbs to defaults
23    #[default]
24    Append,
25}
26
27/// Spinner verbs configuration from Claude Code settings
28#[derive(Debug, Clone, Deserialize)]
29pub struct SpinnerVerbsConfig {
30    #[serde(default)]
31    pub mode: SpinnerVerbsMode,
32    #[serde(default)]
33    pub verbs: Vec<String>,
34}
35
36/// Claude Code settings (only fields we care about)
37#[derive(Debug, Clone, Default, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub struct ClaudeSettings {
40    pub spinner_verbs: Option<SpinnerVerbsConfig>,
41}
42
43/// Cache for Claude Code settings per project path
44///
45/// Settings are cached permanently since Claude Code requires a session
46/// restart to pick up setting changes.
47pub struct ClaudeSettingsCache {
48    /// Cached settings by project path (permanent cache)
49    cache: RwLock<HashMap<PathBuf, ClaudeSettings>>,
50    /// User-level settings cache (from ~/.claude/settings.json)
51    user_settings: RwLock<Option<ClaudeSettings>>,
52}
53
54impl ClaudeSettingsCache {
55    /// Create a new settings cache
56    pub fn new() -> Self {
57        Self {
58            cache: RwLock::new(HashMap::new()),
59            user_settings: RwLock::new(None),
60        }
61    }
62
63    /// Get merged settings for a project path (cwd)
64    ///
65    /// Returns None if cwd is None or settings cannot be read.
66    /// Merges settings in priority order.
67    /// Settings are cached permanently per project path.
68    pub fn get_settings(&self, cwd: Option<&str>) -> Option<ClaudeSettings> {
69        // Get user settings first (lowest priority, used as fallback)
70        let user_settings = self.get_user_settings();
71
72        let cwd = cwd?;
73        let project_path = PathBuf::from(cwd);
74
75        // Check cache for project settings (permanent cache)
76        {
77            let cache = self.cache.read();
78            if let Some(cached) = cache.get(&project_path) {
79                return self.merge_settings(user_settings.as_ref(), Some(cached));
80            }
81        }
82
83        // Read project settings (first time only)
84        let project_settings = self.read_project_settings(&project_path);
85
86        // Cache permanently
87        if let Some(ref settings) = project_settings {
88            let mut cache = self.cache.write();
89            cache.insert(project_path, settings.clone());
90        }
91
92        self.merge_settings(user_settings.as_ref(), project_settings.as_ref())
93    }
94
95    /// Get user-level settings from ~/.claude/settings.json
96    fn get_user_settings(&self) -> Option<ClaudeSettings> {
97        // Check cache first (permanent cache)
98        {
99            let cached = self.user_settings.read();
100            if cached.is_some() {
101                return cached.clone();
102            }
103        }
104
105        // Read from file (first time only)
106        let home = dirs::home_dir()?;
107        let user_settings_path = home.join(".claude").join("settings.json");
108        let settings = Self::read_settings_file(&user_settings_path);
109
110        // Cache permanently
111        let mut cached = self.user_settings.write();
112        *cached = settings.clone();
113
114        settings
115    }
116
117    /// Read project-level settings (merges settings.local.json and settings.json)
118    fn read_project_settings(&self, project_path: &Path) -> Option<ClaudeSettings> {
119        let claude_dir = project_path.join(".claude");
120
121        // Priority order: local > shared
122        let local_path = claude_dir.join("settings.local.json");
123        let shared_path = claude_dir.join("settings.json");
124
125        let local_settings = Self::read_settings_file(&local_path);
126        let shared_settings = Self::read_settings_file(&shared_path);
127
128        // Merge: local overrides shared
129        self.merge_settings(shared_settings.as_ref(), local_settings.as_ref())
130    }
131
132    /// Read and parse a single settings file
133    fn read_settings_file(path: &Path) -> Option<ClaudeSettings> {
134        let content = std::fs::read_to_string(path).ok()?;
135        serde_json::from_str(&content).ok()
136    }
137
138    /// Merge two settings, with higher priority overriding lower
139    ///
140    /// For spinnerVerbs, higher priority completely overrides lower.
141    fn merge_settings(
142        &self,
143        lower: Option<&ClaudeSettings>,
144        higher: Option<&ClaudeSettings>,
145    ) -> Option<ClaudeSettings> {
146        match (lower, higher) {
147            (None, None) => None,
148            (Some(l), None) => Some(l.clone()),
149            (None, Some(h)) => Some(h.clone()),
150            (Some(l), Some(h)) => {
151                // Higher priority's spinnerVerbs takes precedence if present
152                Some(ClaudeSettings {
153                    spinner_verbs: h.spinner_verbs.clone().or_else(|| l.spinner_verbs.clone()),
154                })
155            }
156        }
157    }
158
159    /// Clear all entries from the cache (for testing)
160    #[allow(dead_code)]
161    pub fn clear(&self) {
162        self.cache.write().clear();
163        *self.user_settings.write() = None;
164    }
165}
166
167impl Default for ClaudeSettingsCache {
168    fn default() -> Self {
169        Self::new()
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn test_cache_creation() {
179        let cache = ClaudeSettingsCache::new();
180        assert!(cache.cache.read().is_empty());
181    }
182
183    #[test]
184    fn test_spinner_verbs_mode_default() {
185        assert_eq!(SpinnerVerbsMode::default(), SpinnerVerbsMode::Append);
186    }
187
188    #[test]
189    fn test_parse_spinner_verbs() {
190        let json = r#"{
191            "spinnerVerbs": {
192                "mode": "replace",
193                "verbs": ["Thinking", "Working"]
194            }
195        }"#;
196
197        let settings: ClaudeSettings = serde_json::from_str(json).unwrap();
198        let verbs = settings.spinner_verbs.unwrap();
199        assert_eq!(verbs.mode, SpinnerVerbsMode::Replace);
200        assert_eq!(verbs.verbs, vec!["Thinking", "Working"]);
201    }
202
203    #[test]
204    fn test_parse_spinner_verbs_append() {
205        let json = r#"{
206            "spinnerVerbs": {
207                "mode": "append",
208                "verbs": ["CustomVerb"]
209            }
210        }"#;
211
212        let settings: ClaudeSettings = serde_json::from_str(json).unwrap();
213        let verbs = settings.spinner_verbs.unwrap();
214        assert_eq!(verbs.mode, SpinnerVerbsMode::Append);
215        assert_eq!(verbs.verbs, vec!["CustomVerb"]);
216    }
217
218    #[test]
219    fn test_parse_empty_settings() {
220        let json = r#"{}"#;
221        let settings: ClaudeSettings = serde_json::from_str(json).unwrap();
222        assert!(settings.spinner_verbs.is_none());
223    }
224
225    #[test]
226    fn test_merge_settings() {
227        let cache = ClaudeSettingsCache::new();
228
229        let lower = ClaudeSettings {
230            spinner_verbs: Some(SpinnerVerbsConfig {
231                mode: SpinnerVerbsMode::Append,
232                verbs: vec!["LowerVerb".to_string()],
233            }),
234        };
235
236        let higher = ClaudeSettings {
237            spinner_verbs: Some(SpinnerVerbsConfig {
238                mode: SpinnerVerbsMode::Replace,
239                verbs: vec!["HigherVerb".to_string()],
240            }),
241        };
242
243        let merged = cache.merge_settings(Some(&lower), Some(&higher)).unwrap();
244        let verbs = merged.spinner_verbs.unwrap();
245        assert_eq!(verbs.mode, SpinnerVerbsMode::Replace);
246        assert_eq!(verbs.verbs, vec!["HigherVerb"]);
247    }
248
249    #[test]
250    fn test_merge_settings_lower_only() {
251        let cache = ClaudeSettingsCache::new();
252
253        let lower = ClaudeSettings {
254            spinner_verbs: Some(SpinnerVerbsConfig {
255                mode: SpinnerVerbsMode::Append,
256                verbs: vec!["LowerVerb".to_string()],
257            }),
258        };
259
260        let merged = cache.merge_settings(Some(&lower), None).unwrap();
261        let verbs = merged.spinner_verbs.unwrap();
262        assert_eq!(verbs.verbs, vec!["LowerVerb"]);
263    }
264
265    #[test]
266    fn test_get_settings_without_cwd() {
267        let cache = ClaudeSettingsCache::new();
268        // Without cwd, should still return user settings if available
269        // But since we can't guarantee ~/.claude/settings.json exists,
270        // we just test that it doesn't panic
271        let _result = cache.get_settings(None);
272    }
273}