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 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}