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
10pub 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 #[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 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 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 if current.is_none() {
102 if let Err(err) = reloaded {
103 return Err(err);
104 }
105 }
106 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 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 #[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
156pub 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 #[must_use]
194 pub fn new_with_user_config_paths(workspace_path: PathBuf) -> Self {
195 let mut watcher = Self::new(workspace_path.clone());
196 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 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 changed_paths.push((target, current_modified));
256 }
257 Some(_) => {}
258 None => {
259 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 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 pub fn take_reload_error(&mut self) -> Option<String> {
321 self.last_reload_error.take()
322 }
323
324 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: ¬ify::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 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}