1use std::collections::HashMap;
30use std::path::{Path, PathBuf};
31use std::sync::atomic::{AtomicU64, Ordering};
32use std::sync::{Arc, Mutex};
33use std::time::{Duration, Instant};
34
35use dashmap::DashMap;
36use notify::{RecommendedWatcher, RecursiveMode, Watcher};
37use notify_debouncer_full::{new_debouncer, DebounceEventResult, Debouncer, FileIdMap};
38
39use super::workspace::{SessionScope, WorkspaceBundle, WorkspaceLoader};
40
41#[derive(Debug, Clone, Hash, PartialEq, Eq)]
45struct CacheKey {
46 root: PathBuf,
47 scope: SessionScope,
48 extras: Vec<String>,
49}
50
51#[derive(Debug)]
52struct Entry {
53 bundle: Arc<WorkspaceBundle>,
54 loaded_at: Instant,
55}
56
57#[derive(Debug, Default)]
58pub struct WorkspaceCacheMetrics {
59 pub hits: DashMap<String, AtomicU64>,
61 pub misses: DashMap<String, AtomicU64>,
62 pub invalidations: DashMap<String, AtomicU64>,
63}
64
65impl WorkspaceCacheMetrics {
66 fn bump(map: &DashMap<String, AtomicU64>, key: &str) {
67 if let Some(c) = map.get(key) {
68 c.value().fetch_add(1, Ordering::Relaxed);
69 return;
70 }
71 let entry = map
72 .entry(key.to_string())
73 .or_insert_with(|| AtomicU64::new(0));
74 entry.value().fetch_add(1, Ordering::Relaxed);
75 }
76 pub fn snapshot(&self) -> HashMap<String, (u64, u64, u64)> {
77 let mut out: HashMap<String, (u64, u64, u64)> = HashMap::new();
78 for kv in self.hits.iter() {
79 out.entry(kv.key().clone()).or_default().0 = kv.value().load(Ordering::Relaxed);
80 }
81 for kv in self.misses.iter() {
82 out.entry(kv.key().clone()).or_default().1 = kv.value().load(Ordering::Relaxed);
83 }
84 for kv in self.invalidations.iter() {
85 out.entry(kv.key().clone()).or_default().2 = kv.value().load(Ordering::Relaxed);
86 }
87 out
88 }
89}
90
91pub struct WorkspaceCache {
92 bundles: DashMap<CacheKey, Arc<Entry>>,
93 metrics: Arc<WorkspaceCacheMetrics>,
94 roots: Vec<PathBuf>,
97 max_age: Option<Duration>,
98 _watcher: Mutex<Option<Debouncer<RecommendedWatcher, FileIdMap>>>,
99}
100
101impl WorkspaceCache {
102 pub fn new(
112 roots: &[PathBuf],
113 debounce_ms: u32,
114 max_age_seconds: u32,
115 ) -> anyhow::Result<Arc<Self>> {
116 let metrics = Arc::new(WorkspaceCacheMetrics::default());
117 let bundles: DashMap<CacheKey, Arc<Entry>> = DashMap::new();
118 let max_age = if max_age_seconds == 0 {
119 None
120 } else {
121 Some(Duration::from_secs(max_age_seconds as u64))
122 };
123 let cache = Arc::new(Self {
124 bundles,
125 metrics,
126 roots: roots.iter().map(|p| p.to_path_buf()).collect(),
127 max_age,
128 _watcher: Mutex::new(None),
129 });
130 cache.start_watcher(debounce_ms)?;
131 Ok(cache)
132 }
133
134 fn start_watcher(self: &Arc<Self>, debounce_ms: u32) -> anyhow::Result<()> {
135 if self.roots.is_empty() {
136 return Ok(());
137 }
138 let weak = Arc::downgrade(self);
139 let timeout = Duration::from_millis(debounce_ms.max(50) as u64);
140 let mut debouncer = new_debouncer(timeout, None, move |res: DebounceEventResult| {
141 let Some(this) = weak.upgrade() else {
142 return;
143 };
144 match res {
145 Ok(events) => {
146 let mut roots_to_drop: Vec<PathBuf> = Vec::new();
147 for ev in events {
148 for path in &ev.event.paths {
149 if path.extension().and_then(|e| e.to_str()) != Some("md") {
154 continue;
155 }
156 for root in &this.roots {
158 if path.starts_with(root) && !roots_to_drop.contains(root) {
159 roots_to_drop.push(root.clone());
160 }
161 }
162 }
163 }
164 for root in roots_to_drop {
165 this.invalidate_root(&root);
166 }
167 }
168 Err(errs) => {
169 for e in errs {
170 tracing::warn!(error = ?e, "workspace_cache: watcher error");
171 }
172 }
173 }
174 })?;
175 for root in &self.roots {
176 if let Err(e) = debouncer.watcher().watch(root, RecursiveMode::Recursive) {
179 tracing::warn!(
180 root = %root.display(),
181 error = %e,
182 "workspace_cache: failed to start watcher (cache will still serve hits, just no auto-invalidation)"
183 );
184 }
185 }
186 if let Ok(mut slot) = self._watcher.lock() {
187 *slot = Some(debouncer);
188 }
189 Ok(())
190 }
191
192 pub fn invalidate_root(&self, root: &Path) {
194 let key = root.display().to_string();
195 let before = self.bundles.len();
196 self.bundles.retain(|k, _| k.root != root);
197 let dropped = before - self.bundles.len();
198 if dropped > 0 {
199 WorkspaceCacheMetrics::bump(&self.metrics.invalidations, &key);
200 tracing::info!(
201 root = %root.display(),
202 dropped,
203 "workspace_cache: invalidated entries"
204 );
205 }
206 }
207
208 pub fn clear(&self) {
210 self.bundles.clear();
211 }
212
213 pub fn metrics(&self) -> Arc<WorkspaceCacheMetrics> {
215 Arc::clone(&self.metrics)
216 }
217
218 pub async fn get(
222 &self,
223 root: &Path,
224 scope: SessionScope,
225 extras: &[String],
226 ) -> anyhow::Result<Arc<WorkspaceBundle>> {
227 let mut key_extras: Vec<String> = extras
228 .iter()
229 .map(|s| s.trim().to_string())
230 .filter(|s| !s.is_empty())
231 .collect();
232 key_extras.sort();
233 let key = CacheKey {
234 root: root.to_path_buf(),
235 scope,
236 extras: key_extras,
237 };
238 let root_label = root.display().to_string();
239 if let Some(entry) = self.bundles.get(&key) {
240 if let Some(max_age) = self.max_age {
244 if entry.value().loaded_at.elapsed() > max_age {
245 drop(entry);
246 self.bundles.remove(&key);
247 WorkspaceCacheMetrics::bump(&self.metrics.invalidations, &root_label);
248 } else {
249 WorkspaceCacheMetrics::bump(&self.metrics.hits, &root_label);
250 return Ok(Arc::clone(&entry.value().bundle));
251 }
252 } else {
253 WorkspaceCacheMetrics::bump(&self.metrics.hits, &root_label);
254 return Ok(Arc::clone(&entry.value().bundle));
255 }
256 }
257 WorkspaceCacheMetrics::bump(&self.metrics.misses, &root_label);
259 let bundle = WorkspaceLoader::new(root)
260 .load_with_extras(scope, extras)
261 .await?;
262 let entry = Arc::new(Entry {
263 bundle: Arc::new(bundle),
264 loaded_at: Instant::now(),
265 });
266 let stored = self
269 .bundles
270 .entry(key)
271 .or_insert_with(|| Arc::clone(&entry))
272 .value()
273 .clone();
274 Ok(Arc::clone(&stored.bundle))
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use tempfile::tempdir;
282
283 fn write(p: &Path, name: &str, text: &str) {
284 std::fs::write(p.join(name), text).unwrap();
285 }
286
287 #[tokio::test]
288 async fn cold_then_warm() {
289 let dir = tempdir().unwrap();
290 write(dir.path(), "IDENTITY.md", "- **Name:** Ana");
291 let cache = WorkspaceCache::new(&[dir.path().to_path_buf()], 100, 0).unwrap();
292 let a = cache
293 .get(dir.path(), SessionScope::Main, &[])
294 .await
295 .unwrap();
296 let b = cache
297 .get(dir.path(), SessionScope::Main, &[])
298 .await
299 .unwrap();
300 assert!(Arc::ptr_eq(&a, &b));
302 let snap = cache.metrics().snapshot();
303 let (hits, misses, _) = snap
304 .get(&dir.path().display().to_string())
305 .copied()
306 .unwrap_or_default();
307 assert_eq!(misses, 1);
308 assert_eq!(hits, 1);
309 }
310
311 #[tokio::test]
312 async fn different_scope_separate_entry() {
313 let dir = tempdir().unwrap();
314 write(dir.path(), "IDENTITY.md", "- **Name:** Ana");
315 let cache = WorkspaceCache::new(&[dir.path().to_path_buf()], 100, 0).unwrap();
316 let main = cache
317 .get(dir.path(), SessionScope::Main, &[])
318 .await
319 .unwrap();
320 let shared = cache
321 .get(dir.path(), SessionScope::Shared, &[])
322 .await
323 .unwrap();
324 assert!(!Arc::ptr_eq(&main, &shared));
325 }
326
327 #[tokio::test]
328 async fn extras_order_does_not_split_cache() {
329 let dir = tempdir().unwrap();
330 write(dir.path(), "A.md", "a");
331 write(dir.path(), "B.md", "b");
332 let cache = WorkspaceCache::new(&[dir.path().to_path_buf()], 100, 0).unwrap();
333 let one = cache
334 .get(
335 dir.path(),
336 SessionScope::Main,
337 &["A.md".to_string(), "B.md".to_string()],
338 )
339 .await
340 .unwrap();
341 let two = cache
342 .get(
343 dir.path(),
344 SessionScope::Main,
345 &["B.md".to_string(), "A.md".to_string()],
346 )
347 .await
348 .unwrap();
349 assert!(Arc::ptr_eq(&one, &two));
350 }
351
352 #[tokio::test]
353 async fn manual_invalidate_drops_entries() {
354 let dir = tempdir().unwrap();
355 write(dir.path(), "IDENTITY.md", "- **Name:** Ana");
356 let cache = WorkspaceCache::new(&[dir.path().to_path_buf()], 100, 0).unwrap();
357 let _ = cache
358 .get(dir.path(), SessionScope::Main, &[])
359 .await
360 .unwrap();
361 cache.invalidate_root(dir.path());
362 let snap = cache.metrics().snapshot();
363 let (_, _, inv) = snap
364 .get(&dir.path().display().to_string())
365 .copied()
366 .unwrap_or_default();
367 assert_eq!(inv, 1);
368 }
369
370 #[tokio::test]
371 async fn watcher_invalidates_after_md_write() {
372 let dir = tempdir().unwrap();
373 write(dir.path(), "IDENTITY.md", "- **Name:** Ana");
374 let cache = WorkspaceCache::new(&[dir.path().to_path_buf()], 80, 0).unwrap();
375 let a = cache
376 .get(dir.path(), SessionScope::Main, &[])
377 .await
378 .unwrap();
379 std::fs::write(
381 dir.path().join("IDENTITY.md"),
382 "- **Name:** Ana\n- **Vibe:** updated",
383 )
384 .unwrap();
385 tokio::time::sleep(Duration::from_millis(500)).await;
387 let b = cache
388 .get(dir.path(), SessionScope::Main, &[])
389 .await
390 .unwrap();
391 assert!(
392 !Arc::ptr_eq(&a, &b),
393 "expected cache miss after MD modification"
394 );
395 let snap = cache.metrics().snapshot();
396 let (_, _, inv) = snap
397 .get(&dir.path().display().to_string())
398 .copied()
399 .unwrap_or_default();
400 assert!(inv >= 1, "expected at least one invalidation, got {inv}");
401 }
402
403 #[tokio::test]
404 async fn max_age_forces_refresh() {
405 let dir = tempdir().unwrap();
406 write(dir.path(), "IDENTITY.md", "- **Name:** Ana");
407 let cache = WorkspaceCache::new(&[dir.path().to_path_buf()], 100, 1).unwrap();
409 let a = cache
410 .get(dir.path(), SessionScope::Main, &[])
411 .await
412 .unwrap();
413 tokio::time::sleep(Duration::from_millis(1100)).await;
414 let b = cache
415 .get(dir.path(), SessionScope::Main, &[])
416 .await
417 .unwrap();
418 assert!(!Arc::ptr_eq(&a, &b));
419 }
420
421 #[tokio::test]
422 async fn non_md_file_ignored_by_watcher() {
423 let dir = tempdir().unwrap();
424 write(dir.path(), "IDENTITY.md", "- **Name:** Ana");
425 let cache = WorkspaceCache::new(&[dir.path().to_path_buf()], 80, 0).unwrap();
426 let a = cache
427 .get(dir.path(), SessionScope::Main, &[])
428 .await
429 .unwrap();
430 std::fs::write(dir.path().join("note.txt"), "ignored").unwrap();
431 tokio::time::sleep(Duration::from_millis(400)).await;
432 let b = cache
433 .get(dir.path(), SessionScope::Main, &[])
434 .await
435 .unwrap();
436 assert!(Arc::ptr_eq(&a, &b));
438 }
439}