tmai_core/config/
claude_settings.rs1use parking_lot::RwLock;
12use serde::Deserialize;
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum SpinnerVerbsMode {
20 Replace,
22 #[default]
24 Append,
25}
26
27#[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#[derive(Debug, Clone, Default, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub struct ClaudeSettings {
40 pub spinner_verbs: Option<SpinnerVerbsConfig>,
41}
42
43pub struct ClaudeSettingsCache {
48 cache: RwLock<HashMap<PathBuf, ClaudeSettings>>,
50 user_settings: RwLock<Option<ClaudeSettings>>,
52}
53
54impl ClaudeSettingsCache {
55 pub fn new() -> Self {
57 Self {
58 cache: RwLock::new(HashMap::new()),
59 user_settings: RwLock::new(None),
60 }
61 }
62
63 pub fn get_settings(&self, cwd: Option<&str>) -> Option<ClaudeSettings> {
69 let user_settings = self.get_user_settings();
71
72 let cwd = cwd?;
73 let project_path = PathBuf::from(cwd);
74
75 {
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 let project_settings = self.read_project_settings(&project_path);
85
86 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 fn get_user_settings(&self) -> Option<ClaudeSettings> {
97 {
99 let cached = self.user_settings.read();
100 if cached.is_some() {
101 return cached.clone();
102 }
103 }
104
105 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 let mut cached = self.user_settings.write();
112 *cached = settings.clone();
113
114 settings
115 }
116
117 fn read_project_settings(&self, project_path: &Path) -> Option<ClaudeSettings> {
119 let claude_dir = project_path.join(".claude");
120
121 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 self.merge_settings(shared_settings.as_ref(), local_settings.as_ref())
130 }
131
132 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 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 Some(ClaudeSettings {
153 spinner_verbs: h.spinner_verbs.clone().or_else(|| l.spinner_verbs.clone()),
154 })
155 }
156 }
157 }
158
159 #[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 let _result = cache.get_settings(None);
272 }
273}