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        let config = ConfigManager::load_from_workspace(&self.workspace_path)
83            .ok()
84            .map(|manager| manager.config().clone());
85
86        let mut current = self
87            .current_config
88            .lock()
89            .map_err(|_| anyhow!("config watcher state lock poisoned"))?;
90        *current = config;
91        drop(current);
92
93        let mut last_load = self
94            .last_load_time
95            .lock()
96            .map_err(|_| anyhow!("config watcher timestamp lock poisoned"))?;
97        *last_load = Instant::now();
98
99        Ok(())
100    }
101
102    /// Get the current configuration, reloading if the watcher detected changes.
103    pub async fn get_config(&mut self) -> Option<VTCodeConfig> {
104        if self.should_reload().await
105            && let Err(err) = self.load_config().await
106        {
107            tracing::warn!("Failed to reload config: {}", err);
108        }
109
110        self.current_config
111            .lock()
112            .ok()
113            .and_then(|current| current.clone())
114    }
115
116    async fn should_reload(&self) -> bool {
117        let Ok(last_event) = self.last_event_time.lock() else {
118            return false;
119        };
120        let Ok(last_load) = self.last_load_time.lock() else {
121            return false;
122        };
123
124        *last_event > *last_load
125    }
126
127    /// Get the last load time for debugging.
128    #[must_use]
129    pub async fn last_load_time(&self) -> Instant {
130        self.last_load_time
131            .lock()
132            .map(|instant| *instant)
133            .unwrap_or_else(|_| Instant::now())
134    }
135}
136
137/// Simple config watcher that polls file mtimes instead of using filesystem events.
138pub struct SimpleConfigWatcher {
139    workspace_path: PathBuf,
140    last_load_time: Instant,
141    last_check_time: Instant,
142    check_interval: Duration,
143    last_modified_times: HashMap<PathBuf, SystemTime>,
144    debounce_duration: Duration,
145    last_reload_attempt: Option<Instant>,
146}
147
148impl SimpleConfigWatcher {
149    #[must_use]
150    pub fn new(workspace_path: PathBuf) -> Self {
151        Self {
152            workspace_path,
153            last_load_time: Instant::now(),
154            last_check_time: Instant::now(),
155            check_interval: Duration::from_secs(10),
156            last_modified_times: HashMap::new(),
157            debounce_duration: Duration::from_millis(1000),
158            last_reload_attempt: None,
159        }
160    }
161
162    pub fn should_reload(&mut self) -> bool {
163        let now = Instant::now();
164
165        if now.duration_since(self.last_check_time) < self.check_interval {
166            return false;
167        }
168        self.last_check_time = now;
169
170        let mut saw_change = false;
171        for target in get_config_file_paths(&self.workspace_path) {
172            if !target.exists() {
173                continue;
174            }
175
176            if let Some(current_modified) = latest_modified(&target) {
177                let previous = self.last_modified_times.get(&target).copied();
178                self.last_modified_times.insert(target, current_modified);
179                if let Some(last_modified) = previous
180                    && current_modified > last_modified
181                {
182                    saw_change = true;
183                }
184            }
185        }
186
187        if !saw_change {
188            return false;
189        }
190
191        if let Some(last_attempt) = self.last_reload_attempt
192            && now.duration_since(last_attempt) < self.debounce_duration
193        {
194            return false;
195        }
196        self.last_reload_attempt = Some(now);
197        true
198    }
199
200    pub fn load_config(&mut self) -> Option<VTCodeConfig> {
201        let config = ConfigManager::load_from_workspace(&self.workspace_path)
202            .ok()
203            .map(|manager| manager.config().clone());
204
205        self.last_load_time = Instant::now();
206        self.last_modified_times.clear();
207        for target in get_config_file_paths(&self.workspace_path) {
208            if let Some(modified) = latest_modified(&target) {
209                self.last_modified_times.insert(target, modified);
210            }
211        }
212
213        config
214    }
215
216    pub fn set_check_interval(&mut self, seconds: u64) {
217        self.check_interval = Duration::from_secs(seconds);
218    }
219
220    pub fn set_debounce_duration(&mut self, millis: u64) {
221        self.debounce_duration = Duration::from_millis(millis);
222    }
223}
224
225fn is_relevant_config_event(event: &notify::Event) -> bool {
226    let relevant_files = ["vtcode.toml", "theme.toml"];
227
228    match &event.kind {
229        notify::EventKind::Create(_)
230        | notify::EventKind::Modify(_)
231        | notify::EventKind::Remove(_) => event.paths.iter().any(|path| {
232            path.file_name()
233                .and_then(|file_name| file_name.to_str())
234                .is_some_and(|file_name| relevant_files.contains(&file_name))
235        }),
236        _ => false,
237    }
238}
239
240fn get_config_file_paths(workspace_path: &Path) -> Vec<PathBuf> {
241    vec![
242        workspace_path.join("vtcode.toml"),
243        workspace_path.join(".vtcode").join("theme.toml"),
244    ]
245}
246
247fn latest_modified(path: &Path) -> Option<SystemTime> {
248    std::fs::metadata(path).ok()?.modified().ok()
249}