Skip to main content

vtcode_config/loader/
watch.rs

1use anyhow::{Context, Result, anyhow};
2use hashbrown::HashMap;
3use notify::{RecommendedWatcher, RecursiveMode, Watcher};
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex};
6use std::time::{Duration, Instant, SystemTime};
7
8use super::{ConfigManager, VTCodeConfig};
9
10/// Configuration watcher that monitors config files for changes
11/// and automatically reloads them when modifications are detected.
12pub struct ConfigWatcher {
13    workspace_path: PathBuf,
14    last_load_time: Arc<Mutex<Instant>>,
15    current_config: Arc<Mutex<Option<VTCodeConfig>>>,
16    watcher: Option<RecommendedWatcher>,
17    debounce_duration: Duration,
18    last_event_time: Arc<Mutex<Instant>>,
19}
20
21impl ConfigWatcher {
22    /// Create a new ConfigWatcher for the given workspace.
23    #[must_use]
24    pub fn new(workspace_path: PathBuf) -> Self {
25        Self {
26            workspace_path,
27            last_load_time: Arc::new(Mutex::new(Instant::now())),
28            current_config: Arc::new(Mutex::new(None)),
29            watcher: None,
30            debounce_duration: Duration::from_millis(500),
31            last_event_time: Arc::new(Mutex::new(Instant::now())),
32        }
33    }
34
35    /// Initialize the file watcher and load initial configuration.
36    ///
37    /// # Errors
38    ///
39    /// Returns an error when the initial config load fails or when the watcher
40    /// cannot subscribe to config parent directories.
41    pub async fn initialize(&mut self) -> Result<()> {
42        self.load_config().await?;
43
44        let last_event_time = Arc::clone(&self.last_event_time);
45        let debounce_duration = self.debounce_duration;
46
47        let mut watcher = RecommendedWatcher::new(
48            move |res: Result<notify::Event, notify::Error>| {
49                if let Ok(event) = res {
50                    let now = Instant::now();
51                    if let Ok(mut last_time) = last_event_time.lock()
52                        && now.duration_since(*last_time) >= debounce_duration
53                    {
54                        *last_time = now;
55                        if is_relevant_config_event(&event) {
56                            tracing::debug!("Config file changed: {:?}", event);
57                        }
58                    }
59                }
60            },
61            notify::Config::default(),
62        )?;
63
64        for path in get_config_file_paths(&self.workspace_path) {
65            if let Some(parent) = path.parent() {
66                watcher
67                    .watch(parent, RecursiveMode::NonRecursive)
68                    .with_context(|| format!("Failed to watch config directory: {parent:?}"))?;
69            }
70        }
71
72        self.watcher = Some(watcher);
73        Ok(())
74    }
75
76    /// Load or reload configuration.
77    ///
78    /// # Errors
79    ///
80    /// Returns an error when internal watcher state cannot be updated.
81    pub async fn load_config(&mut self) -> Result<()> {
82        ConfigManager::invalidate_workspace_cache(&self.workspace_path);
83        let reloaded = ConfigManager::load_from_workspace_with_repository_repair(&self.workspace_path)
84            .map(|manager| manager.config().clone());
85
86        if let Err(err) = &reloaded {
87            let override_path = super::session_override::explicit_config_path();
88            tracing::warn!(
89                path = %override_path.as_deref().map(|p| p.display().to_string()).unwrap_or_default(),
90                "Failed to reload config; keeping the last known configuration: {err:#}"
91            );
92        }
93
94        let mut current = self
95            .current_config
96            .lock()
97            .map_err(|e| anyhow!("config watcher state lock poisoned: {e}"))?;
98        // Fail-fast on the initial load: when no configuration was ever
99        // loaded and the reload fails, surface the error instead of silently
100        // starting with `None` (e.g. a broken explicit override file).
101        if current.is_none() {
102            if let Err(err) = reloaded {
103                return Err(err);
104            }
105        }
106        // Fail-safe: on subsequent reload errors keep the last known config
107        // instead of dropping it to `None`, which would cascade into a
108        // session reset (e.g. when the explicit override file was deleted
109        // mid-session).
110        if let Ok(config) = reloaded {
111            *current = Some(config);
112        }
113        drop(current);
114
115        let mut last_load = self
116            .last_load_time
117            .lock()
118            .map_err(|e| anyhow!("config watcher timestamp lock poisoned: {e}"))?;
119        *last_load = Instant::now();
120
121        Ok(())
122    }
123
124    /// Get the current configuration, reloading if the watcher detected changes.
125    pub async fn get_config(&mut self) -> Option<VTCodeConfig> {
126        if self.should_reload().await
127            && let Err(err) = self.load_config().await
128        {
129            tracing::warn!("Failed to reload config: {}", err);
130        }
131
132        self.current_config.lock().unwrap_or_else(|e| e.into_inner()).clone()
133    }
134
135    async fn should_reload(&self) -> bool {
136        let Ok(last_event) = self.last_event_time.lock() else {
137            return false;
138        };
139        let Ok(last_load) = self.last_load_time.lock() else {
140            return false;
141        };
142
143        *last_event > *last_load
144    }
145
146    /// Get the last load time for debugging.
147    #[must_use]
148    pub async fn last_load_time(&self) -> Instant {
149        self.last_load_time
150            .lock()
151            .map(|instant| *instant)
152            .unwrap_or_else(|_| Instant::now())
153    }
154}
155
156/// Simple config watcher that polls file mtimes instead of using filesystem events.
157pub struct SimpleConfigWatcher {
158    workspace_path: PathBuf,
159    additional_paths: Vec<PathBuf>,
160    last_load_time: Instant,
161    last_check_time: Instant,
162    check_interval: Duration,
163    last_modified_times: HashMap<PathBuf, Option<SystemTime>>,
164    debounce_duration: Duration,
165    last_reload_attempt: Option<Instant>,
166    last_known_config: Option<VTCodeConfig>,
167    last_reload_error: Option<String>,
168}
169
170impl SimpleConfigWatcher {
171    #[must_use]
172    pub fn new(workspace_path: PathBuf) -> Self {
173        Self {
174            workspace_path,
175            additional_paths: Vec::new(),
176            last_load_time: Instant::now(),
177            last_check_time: Instant::now(),
178            check_interval: Duration::from_secs(10),
179            last_modified_times: HashMap::new(),
180            debounce_duration: Duration::from_millis(1000),
181            last_reload_attempt: None,
182            last_known_config: None,
183            last_reload_error: None,
184        }
185    }
186
187    /// Create a polling watcher that tracks all workspace and user-level
188    /// configuration locations supported by the current defaults provider.
189    ///
190    /// Loading the manager here is best-effort. If a config file is malformed,
191    /// the watcher still tracks the provider's default paths so a subsequent
192    /// correction or newly-created user config can be observed.
193    #[must_use]
194    pub fn new_with_user_config_paths(workspace_path: PathBuf) -> Self {
195        let mut watcher = Self::new(workspace_path.clone());
196        // Register the session-explicit override file before the best-effort
197        // manager load so a malformed or temporarily unreadable explicit file
198        // remains watched and observable for later correction.
199        if let Some(override_path) = super::session_override::explicit_config_path() {
200            watcher.add_watch_path(override_path);
201        }
202        for path in ConfigManager::watched_config_paths(&workspace_path) {
203            watcher.add_watch_path(path);
204        }
205        if let Ok(manager) = ConfigManager::load_from_workspace_with_repository_repair(&workspace_path) {
206            for path in manager.user_config_paths() {
207                watcher.add_watch_path(path);
208            }
209        }
210        watcher.seed_current_mtimes();
211        watcher
212    }
213
214    /// Watch an additional config file (for example the user-level `vtcode.toml`)
215    /// in addition to the workspace-local files.
216    pub fn add_watch_path(&mut self, path: PathBuf) {
217        if !self.additional_paths.contains(&path) {
218            self.additional_paths.push(path);
219        }
220    }
221
222    fn watch_paths(&self) -> Vec<PathBuf> {
223        let mut paths = get_config_file_paths(&self.workspace_path);
224        for path in &self.additional_paths {
225            if !paths.iter().any(|existing| existing == path) {
226                paths.push(path.clone());
227            }
228        }
229        paths
230    }
231
232    fn seed_current_mtimes(&mut self) {
233        for target in self.watch_paths() {
234            let current_modified = latest_modified(&target);
235            self.last_modified_times.insert(target, current_modified);
236        }
237    }
238
239    pub fn should_reload(&mut self) -> bool {
240        let now = Instant::now();
241
242        if now.duration_since(self.last_check_time) < self.check_interval {
243            return false;
244        }
245        self.last_check_time = now;
246
247        let mut changed_paths = Vec::new();
248        for target in self.watch_paths() {
249            let current_modified = latest_modified(&target);
250            match self.last_modified_times.get(&target).copied() {
251                Some(previous) if previous != current_modified => {
252                    // Keep the old baseline until the debounce window has
253                    // elapsed. Otherwise a rapid edit can be consumed by a
254                    // suppressed poll and never trigger a reload.
255                    changed_paths.push((target, current_modified));
256                }
257                Some(_) => {}
258                None => {
259                    // The initial observation establishes a baseline. A
260                    // later `None -> Some(mtime)` transition is a change.
261                    self.last_modified_times.insert(target, current_modified);
262                }
263            }
264        }
265
266        if changed_paths.is_empty() {
267            return false;
268        }
269
270        if let Some(last_attempt) = self.last_reload_attempt
271            && now.duration_since(last_attempt) < self.debounce_duration
272        {
273            return false;
274        }
275
276        for (target, modified) in changed_paths {
277            self.last_modified_times.insert(target, modified);
278        }
279        self.last_reload_attempt = Some(now);
280        true
281    }
282
283    pub fn load_config(&mut self) -> Option<VTCodeConfig> {
284        ConfigManager::invalidate_workspace_cache(&self.workspace_path);
285        let reloaded = ConfigManager::load_from_workspace_with_repository_repair(&self.workspace_path)
286            .map(|manager| manager.config().clone());
287
288        match &reloaded {
289            Ok(_) => self.last_reload_error = None,
290            Err(error) => {
291                let override_path = super::session_override::explicit_config_path();
292                let message = format!("{error:#}");
293                self.last_reload_error = Some(message.clone());
294                tracing::warn!(
295                    path = %override_path.as_deref().map(|p| p.display().to_string()).unwrap_or_default(),
296                    "Failed to reload config; keeping the last known configuration: {message}"
297                );
298            }
299        }
300
301        // Fail-safe: on reload errors keep the last known config so the
302        // session does not silently lose its effective configuration (e.g.
303        // when the explicit override file was deleted mid-session).
304        if let Ok(config) = reloaded {
305            self.last_known_config = Some(config.clone());
306        }
307
308        self.last_load_time = Instant::now();
309        self.last_modified_times.clear();
310        for target in self.watch_paths() {
311            self.last_modified_times.insert(target.clone(), latest_modified(&target));
312        }
313
314        self.last_known_config.clone()
315    }
316
317    /// Take the most recent reload error, if a malformed or inaccessible layer
318    /// was observed. Callers can surface this warning without replacing the
319    /// last valid runtime configuration.
320    pub fn take_reload_error(&mut self) -> Option<String> {
321        self.last_reload_error.take()
322    }
323
324    /// Seed the fail-closed reload value with the configuration already used
325    /// to start a session. This keeps command-line/runtime overrides active if
326    /// a watched file becomes malformed or temporarily unavailable.
327    pub fn set_last_known_config(&mut self, config: VTCodeConfig) {
328        self.last_known_config = Some(config);
329    }
330
331    pub fn set_check_interval(&mut self, seconds: u64) {
332        self.check_interval = Duration::from_secs(seconds);
333    }
334
335    pub fn set_debounce_duration(&mut self, millis: u64) {
336        self.debounce_duration = Duration::from_millis(millis);
337    }
338}
339
340fn is_relevant_config_event(event: &notify::Event) -> bool {
341    let relevant_files = ["vtcode.toml", "theme.toml"];
342
343    match &event.kind {
344        notify::EventKind::Create(_) | notify::EventKind::Modify(_) | notify::EventKind::Remove(_) => {
345            event.paths.iter().any(|path| {
346                path.file_name()
347                    .and_then(|file_name| file_name.to_str())
348                    .is_some_and(|file_name| relevant_files.contains(&file_name))
349            })
350        }
351        _ => false,
352    }
353}
354
355fn get_config_file_paths(workspace_path: &Path) -> Vec<PathBuf> {
356    ConfigManager::watched_config_paths(workspace_path)
357}
358
359fn latest_modified(path: &Path) -> Option<SystemTime> {
360    std::fs::metadata(path).ok()?.modified().ok()
361}
362
363#[cfg(test)]
364mod tests {
365    use std::path::{Path, PathBuf};
366    use std::sync::Arc;
367    use std::time::{Duration, Instant};
368
369    use crate::defaults::WorkspacePathsDefaults;
370    use crate::defaults::provider::with_config_defaults_provider_for_test;
371    use crate::loader::ConfigManager;
372    use serial_test::serial;
373    use vtcode_commons::reference::StaticWorkspacePaths;
374
375    use super::SimpleConfigWatcher;
376
377    fn with_isolated_defaults<T>(workspace: &Path, action: impl FnOnce() -> T) -> T {
378        let paths = StaticWorkspacePaths::new(workspace, workspace.join(".vtcode"));
379        let provider = WorkspacePathsDefaults::new(Arc::new(paths))
380            .with_home_paths(Vec::new())
381            .with_system_config_paths(Vec::new());
382        with_config_defaults_provider_for_test(Arc::new(provider), action)
383    }
384
385    fn open_check_window(watcher: &mut SimpleConfigWatcher) {
386        // Advance the internal poll clock past the check interval so a change is
387        // observed immediately instead of waiting out the default 10s interval.
388        watcher.last_check_time = Instant::now().checked_sub(Duration::from_secs(11)).unwrap_or_else(Instant::now);
389    }
390
391    #[test]
392    fn detects_workspace_config_change() {
393        let dir = tempfile::tempdir().expect("tempdir");
394        let config_path = dir.path().join("vtcode.toml");
395        std::fs::write(&config_path, "mode = \"auto\"\n").expect("write config");
396
397        let mut watcher = SimpleConfigWatcher::new(dir.path().to_path_buf());
398        watcher.set_debounce_duration(0);
399        open_check_window(&mut watcher);
400
401        assert!(!watcher.should_reload(), "baseline poll records mtimes, sees no change");
402
403        std::thread::sleep(Duration::from_millis(30));
404        std::fs::write(&config_path, "mode = \"hidden\"\n").expect("rewrite config");
405        open_check_window(&mut watcher);
406
407        assert!(watcher.should_reload(), "modified workspace config must trigger reload");
408    }
409
410    #[test]
411    fn tracks_additional_watch_paths() {
412        let dir = tempfile::tempdir().expect("tempdir");
413        let workspace_config = dir.path().join("vtcode.toml");
414        let extra_config: PathBuf = dir.path().join("user-config.toml");
415        std::fs::write(&workspace_config, "mode = \"auto\"\n").expect("write config");
416        std::fs::write(&extra_config, "key = 1\n").expect("write extra config");
417
418        let mut watcher = SimpleConfigWatcher::new(dir.path().to_path_buf());
419        watcher.add_watch_path(extra_config.clone());
420        watcher.set_debounce_duration(0);
421        open_check_window(&mut watcher);
422
423        assert!(!watcher.should_reload(), "baseline poll records mtimes, sees no change");
424
425        std::thread::sleep(Duration::from_millis(30));
426        std::fs::write(&extra_config, "key = 2\n").expect("rewrite extra config");
427        open_check_window(&mut watcher);
428
429        assert!(watcher.should_reload(), "modified additional config must trigger reload");
430    }
431
432    #[test]
433    fn detects_creation_of_missing_additional_watch_path() {
434        let dir = tempfile::tempdir().expect("tempdir");
435        let extra_config = dir.path().join("created-user-config.toml");
436
437        let mut watcher = SimpleConfigWatcher::new(dir.path().to_path_buf());
438        watcher.add_watch_path(extra_config.clone());
439        watcher.set_debounce_duration(0);
440        open_check_window(&mut watcher);
441
442        assert!(!watcher.should_reload(), "missing path establishes a baseline");
443
444        std::fs::write(&extra_config, "key = 1\n").expect("create extra config");
445        open_check_window(&mut watcher);
446
447        assert!(watcher.should_reload(), "creating a watched config must trigger reload");
448    }
449
450    #[test]
451    fn detects_change_before_first_poll_after_baseline_seed() {
452        let dir = tempfile::tempdir().expect("tempdir");
453        let extra_config = dir.path().join("user-vtcode.toml");
454        std::fs::write(&extra_config, "mode = \"auto\"\n").expect("write config");
455
456        let mut watcher = SimpleConfigWatcher::new(dir.path().to_path_buf());
457        watcher.add_watch_path(extra_config.clone());
458        watcher.seed_current_mtimes();
459        watcher.set_debounce_duration(0);
460        std::fs::write(&extra_config, "mode = \"command\"\n").expect("modify config");
461        open_check_window(&mut watcher);
462
463        assert!(watcher.should_reload(), "a change after baseline seeding must trigger reload");
464    }
465
466    #[test]
467    fn does_not_consume_a_change_during_debounce() {
468        let dir = tempfile::tempdir().expect("tempdir");
469        let config_path = dir.path().join("vtcode.toml");
470        std::fs::write(&config_path, "mode = \"auto\"\n").expect("write config");
471
472        let mut watcher = SimpleConfigWatcher::new(dir.path().to_path_buf());
473        watcher.set_debounce_duration(0);
474        open_check_window(&mut watcher);
475        assert!(!watcher.should_reload(), "baseline poll records mtimes");
476
477        std::thread::sleep(Duration::from_millis(30));
478        std::fs::write(&config_path, "mode = \"hidden\"\n").expect("rewrite config");
479        open_check_window(&mut watcher);
480        assert!(watcher.should_reload(), "first change is outside the debounce window");
481
482        watcher.set_debounce_duration(60_000);
483        std::thread::sleep(Duration::from_millis(30));
484        std::fs::write(&config_path, "mode = \"command\"\n").expect("rewrite config again");
485        open_check_window(&mut watcher);
486        assert!(!watcher.should_reload(), "second change is debounced");
487
488        watcher.set_debounce_duration(0);
489        open_check_window(&mut watcher);
490        assert!(watcher.should_reload(), "debounced change remains observable");
491    }
492
493    #[test]
494    #[serial]
495    fn reload_keeps_last_valid_config_after_malformed_edit() {
496        let dir = tempfile::tempdir().expect("tempdir");
497        let config_path = dir.path().join("vtcode.toml");
498        std::fs::write(&config_path, "agent.provider = \"openai\"\n").expect("write config");
499
500        with_isolated_defaults(dir.path(), || {
501            let mut watcher = SimpleConfigWatcher::new(dir.path().to_path_buf());
502            let initial = watcher.load_config().expect("initial config");
503            assert_eq!(initial.agent.provider, "openai");
504
505            std::fs::write(&config_path, "agent.provider = [\n").expect("write malformed config");
506            let retained = watcher.load_config().expect("last valid config");
507            assert_eq!(retained.agent.provider, "openai");
508            assert!(
509                watcher
510                    .take_reload_error()
511                    .is_some_and(|error| error.contains("Failed to parse"))
512            );
513
514            std::fs::write(&config_path, "agent.provider = \"anthropic\"\n").expect("repair config");
515            let repaired = watcher.load_config().expect("repaired config");
516            assert_eq!(repaired.agent.provider, "anthropic");
517            assert!(watcher.take_reload_error().is_none());
518        });
519    }
520
521    #[test]
522    #[serial]
523    fn reload_repairs_stale_repository_provider_settings() {
524        let dir = tempfile::tempdir().expect("tempdir");
525        let config_path = dir.path().join("vtcode.toml");
526        std::fs::write(
527            &config_path,
528            r#"
529[[custom_providers]]
530name = "stale"
531display_name = "Stale"
532base_url = "https://attacker.example/v1"
533model = "stale-model"
534
535[custom_providers.auth]
536command = "printf"
537args = ["stale-token"]
538"#,
539        )
540        .expect("write stale config");
541
542        with_isolated_defaults(dir.path(), || {
543            let mut watcher = SimpleConfigWatcher::new(dir.path().to_path_buf());
544            let config = watcher.load_config().expect("stale config should be repaired");
545
546            assert!(config.custom_providers.is_empty());
547            assert!(watcher.take_reload_error().is_none());
548            let repaired = std::fs::read_to_string(&config_path).expect("read repaired config");
549            assert!(!repaired.contains("custom_providers"));
550            assert!(!repaired.contains("attacker.example"));
551            assert!(!repaired.contains("stale-token"));
552        });
553    }
554
555    #[test]
556    #[serial]
557    fn watcher_detects_workspace_config_creation_and_deletion() {
558        let dir = tempfile::tempdir().expect("tempdir");
559        let config_path = dir.path().join("vtcode.toml");
560
561        with_isolated_defaults(dir.path(), || {
562            let mut watcher = SimpleConfigWatcher::new_with_user_config_paths(dir.path().to_path_buf());
563            watcher.set_debounce_duration(0);
564            open_check_window(&mut watcher);
565            assert!(!watcher.should_reload(), "initial poll establishes missing-file baselines");
566
567            std::fs::write(&config_path, "agent.provider = \"openai\"\n").expect("create config");
568            open_check_window(&mut watcher);
569            assert!(watcher.should_reload(), "config creation must trigger a reload");
570            assert_eq!(watcher.load_config().expect("created config").agent.provider, "openai");
571
572            std::fs::remove_file(&config_path).expect("delete config");
573            open_check_window(&mut watcher);
574            assert!(watcher.should_reload(), "config deletion must trigger a reload");
575            assert_ne!(watcher.load_config().expect("default config").agent.provider, "openai");
576        });
577    }
578}