claude_code_acp/settings/
watcher.rs

1//! Settings file watcher
2//!
3//! Monitors settings files for changes and triggers reloads.
4
5use 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/// Settings file watcher
14///
15/// Watches settings files for changes and sends notifications via a channel.
16#[allow(missing_debug_implementations)]
17pub struct SettingsWatcher {
18    /// The file watcher (held to keep it alive)
19    _watcher: Debouncer<RecommendedWatcher>,
20    /// Paths being watched
21    watched_paths: Vec<PathBuf>,
22}
23
24/// Event sent when settings files change
25#[derive(Debug, Clone)]
26pub struct SettingsChangeEvent {
27    /// Paths that changed
28    pub changed_paths: Vec<PathBuf>,
29}
30
31impl SettingsWatcher {
32    /// Create a new settings watcher
33    ///
34    /// # Arguments
35    ///
36    /// * `project_dir` - The project working directory
37    /// * `debounce_ms` - Debounce duration in milliseconds (default 100)
38    /// * `on_change` - Callback when settings change
39    ///
40    /// # Returns
41    ///
42    /// A watcher instance and a receiver for change events
43    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        // Collect paths to watch
51        let mut watched_paths = Vec::new();
52
53        // User settings directory
54        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        // Project settings directory
62        let project_settings_dir = project_dir.join(".claude");
63        if project_settings_dir.exists() {
64            watched_paths.push(project_settings_dir);
65        }
66
67        // Create debounced watcher
68        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                        // Filter for settings files
76                        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        // Start watching directories
97        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    /// Get the paths being watched
115    pub fn watched_paths(&self) -> &[PathBuf] {
116        &self.watched_paths
117    }
118
119    /// Create a settings watcher that automatically reloads settings
120    ///
121    /// Returns a task handle that can be awaited or aborted.
122    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/// Handle to a running watcher task
145#[allow(missing_debug_implementations)]
146pub struct WatcherHandle {
147    /// The watcher (kept alive)
148    _watcher: SettingsWatcher,
149    /// The reload task
150    task: tokio::task::JoinHandle<()>,
151}
152
153impl WatcherHandle {
154    /// Stop the watcher
155    pub fn stop(self) {
156        self.task.abort();
157    }
158
159    /// Check if the watcher is still running
160    pub fn is_running(&self) -> bool {
161        !self.task.is_finished()
162    }
163}
164
165/// Check if a path is a settings file
166fn 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/// Errors that can occur during settings watching
172#[derive(Debug, thiserror::Error)]
173pub enum WatcherError {
174    /// Failed to initialize the watcher
175    #[error("Failed to initialize watcher: {0}")]
176    Init(String),
177
178    /// Failed to watch a path
179    #[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        // Create initial settings file
221        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        // Create watcher
227        let (watcher, mut rx) = SettingsWatcher::new(temp_dir.path(), 50).unwrap();
228        assert!(!watcher.watched_paths().is_empty());
229
230        // Give watcher time to start
231        tokio::time::sleep(Duration::from_millis(100)).await;
232
233        // Modify the file
234        let mut file = File::create(&settings_file).unwrap();
235        writeln!(file, r#"{{"model": "claude-sonnet"}}"#).unwrap();
236        drop(file);
237
238        // Wait for change event (with timeout)
239        let result = timeout(Duration::from_secs(2), rx.recv()).await;
240
241        // Note: File watching can be flaky in tests, so we accept both outcomes
242        match result {
243            Ok(Some(event)) => {
244                assert!(!event.changed_paths.is_empty());
245            }
246            Ok(None) => {
247                // Channel closed, acceptable in test
248            }
249            Err(_) => {
250                // Timeout - file watching can be slow/unreliable in CI
251                tracing::warn!("Watcher test timed out - this can happen in CI environments");
252            }
253        }
254    }
255}