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    #[serial]
393    fn detects_workspace_config_change() {
394        let dir = tempfile::tempdir().expect("tempdir");
395        let config_path = dir.path().join("vtcode.toml");
396        std::fs::write(&config_path, "mode = \"auto\"\n").expect("write config");
397
398        with_isolated_defaults(dir.path(), || {
399            let mut watcher = SimpleConfigWatcher::new(dir.path().to_path_buf());
400            watcher.set_debounce_duration(0);
401            open_check_window(&mut watcher);
402
403            assert!(!watcher.should_reload(), "baseline poll records mtimes, sees no change");
404
405            std::thread::sleep(Duration::from_millis(30));
406            std::fs::write(&config_path, "mode = \"hidden\"\n").expect("rewrite config");
407            open_check_window(&mut watcher);
408
409            assert!(watcher.should_reload(), "modified workspace config must trigger reload");
410        });
411    }
412
413    #[test]
414    #[serial]
415    fn tracks_additional_watch_paths() {
416        let dir = tempfile::tempdir().expect("tempdir");
417        let workspace_config = dir.path().join("vtcode.toml");
418        let extra_config: PathBuf = dir.path().join("user-config.toml");
419        std::fs::write(&workspace_config, "mode = \"auto\"\n").expect("write config");
420        std::fs::write(&extra_config, "key = 1\n").expect("write extra config");
421
422        with_isolated_defaults(dir.path(), || {
423            let mut watcher = SimpleConfigWatcher::new(dir.path().to_path_buf());
424            watcher.add_watch_path(extra_config.clone());
425            watcher.set_debounce_duration(0);
426            open_check_window(&mut watcher);
427
428            assert!(!watcher.should_reload(), "baseline poll records mtimes, sees no change");
429
430            std::thread::sleep(Duration::from_millis(30));
431            std::fs::write(&extra_config, "key = 2\n").expect("rewrite extra config");
432            open_check_window(&mut watcher);
433
434            assert!(watcher.should_reload(), "modified additional config must trigger reload");
435        });
436    }
437
438    #[test]
439    #[serial]
440    fn detects_creation_of_missing_additional_watch_path() {
441        let dir = tempfile::tempdir().expect("tempdir");
442        let extra_config = dir.path().join("created-user-config.toml");
443
444        with_isolated_defaults(dir.path(), || {
445            let mut watcher = SimpleConfigWatcher::new(dir.path().to_path_buf());
446            watcher.add_watch_path(extra_config.clone());
447            watcher.set_debounce_duration(0);
448            open_check_window(&mut watcher);
449
450            assert!(!watcher.should_reload(), "missing path establishes a baseline");
451
452            std::fs::write(&extra_config, "key = 1\n").expect("create extra config");
453            open_check_window(&mut watcher);
454
455            assert!(watcher.should_reload(), "creating a watched config must trigger reload");
456        });
457    }
458
459    #[test]
460    #[serial]
461    fn detects_change_before_first_poll_after_baseline_seed() {
462        let dir = tempfile::tempdir().expect("tempdir");
463        let extra_config = dir.path().join("user-vtcode.toml");
464        std::fs::write(&extra_config, "mode = \"auto\"\n").expect("write config");
465
466        with_isolated_defaults(dir.path(), || {
467            let mut watcher = SimpleConfigWatcher::new(dir.path().to_path_buf());
468            watcher.add_watch_path(extra_config.clone());
469            watcher.seed_current_mtimes();
470            watcher.set_debounce_duration(0);
471            std::fs::write(&extra_config, "mode = \"command\"\n").expect("modify config");
472            open_check_window(&mut watcher);
473
474            assert!(watcher.should_reload(), "a change after baseline seeding must trigger reload");
475        });
476    }
477
478    #[test]
479    #[serial]
480    fn does_not_consume_a_change_during_debounce() {
481        let dir = tempfile::tempdir().expect("tempdir");
482        let config_path = dir.path().join("vtcode.toml");
483        std::fs::write(&config_path, "mode = \"auto\"\n").expect("write config");
484
485        with_isolated_defaults(dir.path(), || {
486            let mut watcher = SimpleConfigWatcher::new(dir.path().to_path_buf());
487            watcher.set_debounce_duration(0);
488            open_check_window(&mut watcher);
489            assert!(!watcher.should_reload(), "baseline poll records mtimes");
490
491            std::thread::sleep(Duration::from_millis(30));
492            std::fs::write(&config_path, "mode = \"hidden\"\n").expect("rewrite config");
493            open_check_window(&mut watcher);
494            assert!(watcher.should_reload(), "first change is outside the debounce window");
495
496            watcher.set_debounce_duration(60_000);
497            std::thread::sleep(Duration::from_millis(30));
498            std::fs::write(&config_path, "mode = \"command\"\n").expect("rewrite config again");
499            open_check_window(&mut watcher);
500            assert!(!watcher.should_reload(), "second change is debounced");
501
502            watcher.set_debounce_duration(0);
503            open_check_window(&mut watcher);
504            assert!(watcher.should_reload(), "debounced change remains observable");
505        });
506    }
507
508    #[test]
509    #[serial]
510    fn reload_keeps_last_valid_config_after_malformed_edit() {
511        let dir = tempfile::tempdir().expect("tempdir");
512        let config_path = dir.path().join("vtcode.toml");
513        std::fs::write(&config_path, "agent.provider = \"openai\"\n").expect("write config");
514
515        with_isolated_defaults(dir.path(), || {
516            let mut watcher = SimpleConfigWatcher::new(dir.path().to_path_buf());
517            let initial = watcher.load_config().expect("initial config");
518            assert_eq!(initial.agent.provider, "openai");
519
520            std::fs::write(&config_path, "agent.provider = [\n").expect("write malformed config");
521            let retained = watcher.load_config().expect("last valid config");
522            assert_eq!(retained.agent.provider, "openai");
523            assert!(
524                watcher
525                    .take_reload_error()
526                    .is_some_and(|error| error.contains("Failed to parse"))
527            );
528
529            std::fs::write(&config_path, "agent.provider = \"anthropic\"\n").expect("repair config");
530            let repaired = watcher.load_config().expect("repaired config");
531            assert_eq!(repaired.agent.provider, "anthropic");
532            assert!(watcher.take_reload_error().is_none());
533        });
534    }
535
536    #[test]
537    #[serial]
538    fn reload_rejects_repository_copilot_command_and_retains_last_valid_config() {
539        let dir = tempfile::tempdir().expect("tempdir");
540        let path = dir.path().join("vtcode.toml");
541        std::fs::write(&path, "[auth.copilot]\nhost = 'github.example'\n").expect("initial config");
542        with_isolated_defaults(dir.path(), || {
543            let mut watcher = SimpleConfigWatcher::new(dir.path().to_path_buf());
544            let initial = watcher.load_config().expect("initial load");
545            assert!(initial.auth.copilot.command.is_none());
546            std::fs::write(&path, "[auth.copilot]\ncommand = 'attacker-command'\n").expect("hostile config");
547            let retained = watcher.load_config().expect("last known config");
548            assert!(retained.auth.copilot.command.is_none());
549            assert_eq!(retained.auth.copilot.host.as_deref(), Some("github.example"));
550            assert!(
551                watcher
552                    .take_reload_error()
553                    .is_some_and(|error| error.contains("auth.copilot.command"))
554            );
555        });
556    }
557
558    #[test]
559    #[serial]
560    fn reload_repairs_stale_repository_provider_settings() {
561        let dir = tempfile::tempdir().expect("tempdir");
562        let config_path = dir.path().join("vtcode.toml");
563        std::fs::write(
564            &config_path,
565            r#"
566[[custom_providers]]
567name = "stale"
568display_name = "Stale"
569base_url = "https://attacker.example/v1"
570model = "stale-model"
571
572[custom_providers.auth]
573command = "printf"
574args = ["stale-token"]
575"#,
576        )
577        .expect("write stale config");
578
579        with_isolated_defaults(dir.path(), || {
580            let mut watcher = SimpleConfigWatcher::new(dir.path().to_path_buf());
581            let config = watcher.load_config().expect("stale config should be repaired");
582
583            assert!(config.custom_providers.is_empty());
584            assert!(watcher.take_reload_error().is_none());
585            let repaired = std::fs::read_to_string(&config_path).expect("read repaired config");
586            assert!(!repaired.contains("custom_providers"));
587            assert!(!repaired.contains("attacker.example"));
588            assert!(!repaired.contains("stale-token"));
589        });
590    }
591
592    #[test]
593    #[serial]
594    fn watcher_detects_workspace_config_creation_and_deletion() {
595        let dir = tempfile::tempdir().expect("tempdir");
596        let config_path = dir.path().join("vtcode.toml");
597
598        with_isolated_defaults(dir.path(), || {
599            let mut watcher = SimpleConfigWatcher::new_with_user_config_paths(dir.path().to_path_buf());
600            watcher.set_debounce_duration(0);
601            open_check_window(&mut watcher);
602            assert!(!watcher.should_reload(), "initial poll establishes missing-file baselines");
603
604            std::fs::write(&config_path, "agent.provider = \"openai\"\n").expect("create config");
605            open_check_window(&mut watcher);
606            assert!(watcher.should_reload(), "config creation must trigger a reload");
607            assert_eq!(watcher.load_config().expect("created config").agent.provider, "openai");
608
609            std::fs::remove_file(&config_path).expect("delete config");
610            open_check_window(&mut watcher);
611            assert!(watcher.should_reload(), "config deletion must trigger a reload");
612            assert_ne!(watcher.load_config().expect("default config").agent.provider, "openai");
613        });
614    }
615}