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 workspace_path = self.workspace_path.clone();
45        let last_event_time = Arc::clone(&self.last_event_time);
46        let debounce_duration = self.debounce_duration;
47
48        let mut watcher = RecommendedWatcher::new(
49            move |res: Result<notify::Event, notify::Error>| {
50                if let Ok(event) = res {
51                    let now = Instant::now();
52                    if let Ok(mut last_time) = last_event_time.lock()
53                        && now.duration_since(*last_time) >= debounce_duration
54                    {
55                        *last_time = now;
56                        if is_relevant_config_event(&event, &workspace_path) {
57                            tracing::debug!("Config file changed: {:?}", event);
58                        }
59                    }
60                }
61            },
62            notify::Config::default(),
63        )?;
64
65        for path in get_config_file_paths(&self.workspace_path) {
66            if let Some(parent) = path.parent() {
67                watcher
68                    .watch(parent, RecursiveMode::NonRecursive)
69                    .with_context(|| format!("Failed to watch config directory: {:?}", parent))?;
70            }
71        }
72
73        self.watcher = Some(watcher);
74        Ok(())
75    }
76
77    /// Load or reload configuration.
78    ///
79    /// # Errors
80    ///
81    /// Returns an error when internal watcher state cannot be updated.
82    pub async fn load_config(&mut self) -> Result<()> {
83        let config = ConfigManager::load_from_workspace(&self.workspace_path)
84            .ok()
85            .map(|manager| manager.config().clone());
86
87        let mut current = self
88            .current_config
89            .lock()
90            .map_err(|_| anyhow!("config watcher state lock poisoned"))?;
91        *current = config;
92        drop(current);
93
94        let mut last_load = self
95            .last_load_time
96            .lock()
97            .map_err(|_| anyhow!("config watcher timestamp lock poisoned"))?;
98        *last_load = Instant::now();
99
100        Ok(())
101    }
102
103    /// Get the current configuration, reloading if the watcher detected changes.
104    pub async fn get_config(&mut self) -> Option<VTCodeConfig> {
105        if self.should_reload().await
106            && let Err(err) = self.load_config().await
107        {
108            tracing::warn!("Failed to reload config: {}", err);
109        }
110
111        self.current_config
112            .lock()
113            .ok()
114            .and_then(|current| current.clone())
115    }
116
117    async fn should_reload(&self) -> bool {
118        let Ok(last_event) = self.last_event_time.lock() else {
119            return false;
120        };
121        let Ok(last_load) = self.last_load_time.lock() else {
122            return false;
123        };
124
125        *last_event > *last_load
126    }
127
128    /// Get the last load time for debugging.
129    #[must_use]
130    pub async fn last_load_time(&self) -> Instant {
131        self.last_load_time
132            .lock()
133            .map(|instant| *instant)
134            .unwrap_or_else(|_| Instant::now())
135    }
136}
137
138/// Simple config watcher that polls file mtimes instead of using filesystem events.
139pub struct SimpleConfigWatcher {
140    workspace_path: PathBuf,
141    last_load_time: Instant,
142    last_check_time: Instant,
143    check_interval: Duration,
144    last_modified_times: HashMap<PathBuf, SystemTime>,
145    debounce_duration: Duration,
146    last_reload_attempt: Option<Instant>,
147}
148
149impl SimpleConfigWatcher {
150    #[must_use]
151    pub fn new(workspace_path: PathBuf) -> Self {
152        Self {
153            workspace_path,
154            last_load_time: Instant::now(),
155            last_check_time: Instant::now(),
156            check_interval: Duration::from_secs(10),
157            last_modified_times: HashMap::new(),
158            debounce_duration: Duration::from_millis(1000),
159            last_reload_attempt: None,
160        }
161    }
162
163    pub fn should_reload(&mut self) -> bool {
164        let now = Instant::now();
165
166        if now.duration_since(self.last_check_time) < self.check_interval {
167            return false;
168        }
169        self.last_check_time = now;
170
171        let mut saw_change = false;
172        for target in get_config_file_paths(&self.workspace_path) {
173            if !target.exists() {
174                continue;
175            }
176
177            if let Some(current_modified) = latest_modified(&target) {
178                let previous = self.last_modified_times.get(&target).copied();
179                self.last_modified_times.insert(target, current_modified);
180                if let Some(last_modified) = previous
181                    && current_modified > last_modified
182                {
183                    saw_change = true;
184                }
185            }
186        }
187
188        if !saw_change {
189            return false;
190        }
191
192        if let Some(last_attempt) = self.last_reload_attempt
193            && now.duration_since(last_attempt) < self.debounce_duration
194        {
195            return false;
196        }
197        self.last_reload_attempt = Some(now);
198        true
199    }
200
201    pub fn load_config(&mut self) -> Option<VTCodeConfig> {
202        let config = ConfigManager::load_from_workspace(&self.workspace_path)
203            .ok()
204            .map(|manager| manager.config().clone());
205
206        self.last_load_time = Instant::now();
207        self.last_modified_times.clear();
208        for target in get_config_file_paths(&self.workspace_path) {
209            if let Some(modified) = latest_modified(&target) {
210                self.last_modified_times.insert(target, modified);
211            }
212        }
213
214        config
215    }
216
217    pub fn set_check_interval(&mut self, seconds: u64) {
218        self.check_interval = Duration::from_secs(seconds);
219    }
220
221    pub fn set_debounce_duration(&mut self, millis: u64) {
222        self.debounce_duration = Duration::from_millis(millis);
223    }
224}
225
226fn is_relevant_config_event(event: &notify::Event, _workspace_path: &Path) -> bool {
227    let relevant_files = ["vtcode.toml", ".vtcode.toml", "config.toml", "theme.toml"];
228    let relevant_dirs = ["config", "theme"];
229
230    match &event.kind {
231        notify::EventKind::Create(_)
232        | notify::EventKind::Modify(_)
233        | notify::EventKind::Remove(_) => event.paths.iter().any(|path| {
234            path.file_name()
235                .and_then(|file_name| file_name.to_str())
236                .is_some_and(|file_name| {
237                    relevant_files.contains(&file_name) || relevant_dirs.contains(&file_name)
238                })
239        }),
240        _ => false,
241    }
242}
243
244fn get_config_file_paths(workspace_path: &Path) -> Vec<PathBuf> {
245    let mut paths = vec![
246        workspace_path.join("vtcode.toml"),
247        workspace_path.join(".vtcode.toml"),
248        workspace_path.join(".vtcode").join("theme.toml"),
249        workspace_path.join("config"),
250        workspace_path.join("theme"),
251        workspace_path.join(".vtcode").join("config"),
252        workspace_path.join(".vtcode").join("theme"),
253    ];
254
255    if let Some(home_dir) = dirs::home_dir() {
256        paths.push(home_dir.join(".vtcode.toml"));
257    }
258
259    paths
260}
261
262fn latest_modified(path: &Path) -> Option<SystemTime> {
263    if path.is_file() {
264        return std::fs::metadata(path).ok()?.modified().ok();
265    }
266
267    if !path.is_dir() {
268        return None;
269    }
270
271    let mut newest = None;
272    for entry in walkdir::WalkDir::new(path)
273        .into_iter()
274        .filter_map(|item| item.ok())
275    {
276        if !entry.file_type().is_file() {
277            continue;
278        }
279        let Ok(metadata) = entry.metadata() else {
280            continue;
281        };
282        let Ok(modified) = metadata.modified() else {
283            continue;
284        };
285        newest = match newest {
286            Some(current) if modified <= current => Some(current),
287            _ => Some(modified),
288        };
289    }
290    newest
291}