claude_code_acp/settings/
watcher.rs1use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use std::time::Duration;
8
9use notify::{RecommendedWatcher, RecursiveMode};
10use notify_debouncer_mini::{DebounceEventResult, DebouncedEventKind, Debouncer, new_debouncer};
11use tokio::sync::mpsc;
12
13#[allow(missing_debug_implementations)]
17pub struct SettingsWatcher {
18 _watcher: Debouncer<RecommendedWatcher>,
20 watched_paths: Vec<PathBuf>,
22}
23
24#[derive(Debug, Clone)]
26pub struct SettingsChangeEvent {
27 pub changed_paths: Vec<PathBuf>,
29}
30
31impl SettingsWatcher {
32 pub fn new(
44 project_dir: impl AsRef<Path>,
45 debounce_ms: u64,
46 ) -> Result<(Self, mpsc::UnboundedReceiver<SettingsChangeEvent>), WatcherError> {
47 let project_dir = project_dir.as_ref();
48 let (tx, rx) = mpsc::unbounded_channel();
49
50 let mut watched_paths = Vec::new();
52
53 if let Some(home) = dirs::home_dir() {
55 let user_settings_dir = home.join(".claude");
56 if user_settings_dir.exists() {
57 watched_paths.push(user_settings_dir);
58 }
59 }
60
61 let project_settings_dir = project_dir.join(".claude");
63 if project_settings_dir.exists() {
64 watched_paths.push(project_settings_dir);
65 }
66
67 let tx_clone = tx.clone();
69 let watched_clone = watched_paths.clone();
70 let mut watcher = new_debouncer(
71 Duration::from_millis(debounce_ms),
72 move |result: DebounceEventResult| {
73 match result {
74 Ok(events) => {
75 let changed_paths: Vec<PathBuf> = events
77 .into_iter()
78 .filter(|e| matches!(e.kind, DebouncedEventKind::Any))
79 .map(|e| e.path)
80 .filter(|p| is_settings_file(p))
81 .collect();
82
83 if !changed_paths.is_empty() {
84 tracing::debug!("Settings files changed: {:?}", changed_paths);
85 drop(tx_clone.send(SettingsChangeEvent { changed_paths }));
86 }
87 }
88 Err(e) => {
89 tracing::warn!("Settings watcher error: {:?}", e);
90 }
91 }
92 },
93 )
94 .map_err(|e| WatcherError::Init(e.to_string()))?;
95
96 for path in &watched_paths {
98 watcher
99 .watcher()
100 .watch(path, RecursiveMode::NonRecursive)
101 .map_err(|e| WatcherError::Watch(path.clone(), e.to_string()))?;
102 tracing::info!("Watching settings directory: {:?}", path);
103 }
104
105 Ok((
106 Self {
107 _watcher: watcher,
108 watched_paths: watched_clone,
109 },
110 rx,
111 ))
112 }
113
114 pub fn watched_paths(&self) -> &[PathBuf] {
116 &self.watched_paths
117 }
118
119 pub fn start_auto_reload(
123 project_dir: impl AsRef<Path>,
124 settings_manager: Arc<tokio::sync::RwLock<super::SettingsManager>>,
125 debounce_ms: u64,
126 ) -> Result<WatcherHandle, WatcherError> {
127 let (watcher, mut rx) = Self::new(project_dir, debounce_ms)?;
128
129 let handle = tokio::spawn(async move {
130 while let Some(event) = rx.recv().await {
131 tracing::info!("Settings changed, reloading: {:?}", event.changed_paths);
132 let mut manager = settings_manager.write().await;
133 manager.reload();
134 }
135 });
136
137 Ok(WatcherHandle {
138 _watcher: watcher,
139 task: handle,
140 })
141 }
142}
143
144#[allow(missing_debug_implementations)]
146pub struct WatcherHandle {
147 _watcher: SettingsWatcher,
149 task: tokio::task::JoinHandle<()>,
151}
152
153impl WatcherHandle {
154 pub fn stop(self) {
156 self.task.abort();
157 }
158
159 pub fn is_running(&self) -> bool {
161 !self.task.is_finished()
162 }
163}
164
165fn is_settings_file(path: &Path) -> bool {
167 let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
168 file_name == "settings.json" || file_name == "settings.local.json"
169}
170
171#[derive(Debug, thiserror::Error)]
173pub enum WatcherError {
174 #[error("Failed to initialize watcher: {0}")]
176 Init(String),
177
178 #[error("Failed to watch path {0:?}: {1}")]
180 Watch(PathBuf, String),
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use std::fs::{self, File};
187 use std::io::Write;
188 use tempfile::TempDir;
189 use tokio::time::timeout;
190
191 #[test]
192 fn test_is_settings_file() {
193 assert!(is_settings_file(Path::new("/some/path/settings.json")));
194 assert!(is_settings_file(Path::new(
195 "/some/path/settings.local.json"
196 )));
197 assert!(!is_settings_file(Path::new("/some/path/other.json")));
198 assert!(!is_settings_file(Path::new("/some/path/settings.yaml")));
199 }
200
201 #[tokio::test]
202 async fn test_watcher_creation() {
203 let temp_dir = TempDir::new().unwrap();
204 let settings_dir = temp_dir.path().join(".claude");
205 fs::create_dir_all(&settings_dir).unwrap();
206
207 let result = SettingsWatcher::new(temp_dir.path(), 100);
208 assert!(result.is_ok());
209
210 let (watcher, _rx) = result.unwrap();
211 assert!(!watcher.watched_paths().is_empty());
212 }
213
214 #[tokio::test]
215 async fn test_watcher_detects_changes() {
216 let temp_dir = TempDir::new().unwrap();
217 let settings_dir = temp_dir.path().join(".claude");
218 fs::create_dir_all(&settings_dir).unwrap();
219
220 let settings_file = settings_dir.join("settings.json");
222 let mut file = File::create(&settings_file).unwrap();
223 writeln!(file, r#"{{"model": "claude-opus"}}"#).unwrap();
224 drop(file);
225
226 let (watcher, mut rx) = SettingsWatcher::new(temp_dir.path(), 50).unwrap();
228 assert!(!watcher.watched_paths().is_empty());
229
230 tokio::time::sleep(Duration::from_millis(100)).await;
232
233 let mut file = File::create(&settings_file).unwrap();
235 writeln!(file, r#"{{"model": "claude-sonnet"}}"#).unwrap();
236 drop(file);
237
238 let result = timeout(Duration::from_secs(2), rx.recv()).await;
240
241 match result {
243 Ok(Some(event)) => {
244 assert!(!event.changed_paths.is_empty());
245 }
246 Ok(None) => {
247 }
249 Err(_) => {
250 tracing::warn!("Watcher test timed out - this can happen in CI environments");
252 }
253 }
254 }
255}