Skip to main content

php_lsp/index/
cache.rs

1//! Persistent on-disk cache for Phase K.
2//!
3//! The cache stores a serialized `FileIndex` per PHP file, keyed on
4//! `(uri, content)`.  On a warm start `scan_workspace` reads the cached index
5//! instead of parsing the file, shrinking cold-start I/O from O(parse) to
6//! O(read + bincode-decode) — roughly 10–50× faster per file.
7//!
8//! ## Layout
9//!
10//! ```text
11//! ~/.cache/php-lsp/<schema-version>/<workspace-hash>/<uri-hash>.bin
12//! ```
13//!
14//! - `<schema-version>` — `php-lsp` crate version; bumping it rotates the
15//!   entire cache so old entries are never decoded against a newer schema.
16//!   The previous version's directory is pruned by the next `WorkspaceCache::new`
17//!   call rather than left to accumulate release over release.
18//! - `<workspace-hash>` — blake3 of the canonicalized absolute path of the
19//!   first workspace root, truncated to 16 hex chars. Two separate projects
20//!   get isolated caches; two checkouts of the same project at the same
21//!   absolute path share one. Holds a `workspace.json` recording the root
22//!   path and last-used time, so the background pruner can drop caches for
23//!   roots that no longer exist or haven't been opened in
24//!   [`WORKSPACE_MAX_IDLE`].
25//! - `<uri-hash>` — blake3 of the URI, truncated to 32 hex chars. One file
26//!   per URI, not per `(uri, content)` pair: a content hash is stored inside
27//!   the entry and checked on read, so re-indexing an edited file overwrites
28//!   its existing slot in place instead of leaving the previous revision's
29//!   entry to rot on disk.
30//!
31//! `<workspace-hash>` also holds a `session/` subdirectory: mir's own
32//! `AnalysisCache`, nested here (not under mir's default `.mir-cache/`) so
33//! both caches share schema/workspace rotation and cleanup. `size_bytes`
34//! recurses into it for the size cap.
35//!
36//! ## Format
37//!
38//! `bincode` v2 (binary, fast, schema-stable via serde derives on
39//! `FileIndex` et al), prefixed with the entry's content hash so a stale
40//! revision reads back as a miss. Files are written atomically via a
41//! temp-file rename to avoid half-written entries on an interrupted
42//! shutdown.
43//!
44//! ## Invalidation
45//!
46//! Rotating the schema version invalidates everything; rotating the content
47//! invalidates just that entry's slot (overwritten on next write). The size
48//! cap in [`WorkspaceCache::new`] is a backstop for workspaces that outgrow
49//! it, not the primary cleanup mechanism.
50
51use std::io::{self, Write};
52use std::path::{Path, PathBuf};
53use std::time::{Duration, SystemTime, UNIX_EPOCH};
54
55use serde::{Serialize, de::DeserializeOwned};
56
57/// Identifies a single cache entry. Opaque — callers produce it via
58/// [`WorkspaceCache::key_for`] and pass it straight back to read/write.
59///
60/// `uri_hash` is the on-disk filename, so every revision of a given URI
61/// lands in the same slot. `content_hash` is stored inside the entry and
62/// checked on read, so an edit invalidates the slot without orphaning a
63/// separate file for the old content.
64#[derive(Debug, Clone, PartialEq, Eq, Hash)]
65pub struct CacheKey {
66    uri_hash: String,
67    content_hash: String,
68}
69
70impl CacheKey {
71    fn as_filename(&self) -> &str {
72        &self.uri_hash
73    }
74}
75
76/// Handle to the cache directory for a single workspace. Construction is
77/// cheap (creates directories on demand); the same handle can be shared
78/// across threads via `Arc` — it holds no mutable state.
79#[derive(Debug, Clone)]
80pub struct WorkspaceCache {
81    dir: PathBuf,
82}
83
84/// Size cap (bytes) for a single workspace's cache directory. At
85/// startup, if the directory exceeds this, we reset it — simpler than
86/// LRU eviction and the rebuild cost is bounded (it's just the next
87/// workspace scan running as if cold). 512 MiB fits a mega-workspace
88/// (50 k files × ~10 KB average `FileIndex`) with headroom and is
89/// small enough that no reasonable disk will choke on it.
90pub const CACHE_SIZE_CAP: u64 = 512 * 1024 * 1024;
91
92/// A workspace cache unused for this long is deleted on the next startup.
93/// Cap (bytes) on all workspace caches together. Past it, the least recently
94/// used workspace directories are deleted until the total fits again.
95pub const TOTAL_CACHE_SIZE_CAP: u64 = 2 * 1024 * 1024 * 1024;
96
97const WORKSPACE_MAX_IDLE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
98
99/// Age after which a workspace directory without a meta file is deleted;
100/// shields one that another instance has just created but not yet stamped.
101const NO_META_GRACE: Duration = Duration::from_secs(24 * 60 * 60);
102
103const META_FILE: &str = "workspace.json";
104
105impl WorkspaceCache {
106    /// Create (or re-open) the cache directory for a workspace rooted at
107    /// `root`. Returns `None` when the system has no usable home/cache
108    /// directory — callers should treat that as "cache disabled" and
109    /// proceed without persistence.
110    ///
111    /// If the existing cache directory exceeds [`CACHE_SIZE_CAP`], it is
112    /// cleared before the handle is returned. That's a coarse knob —
113    /// K3 could refine to LRU-by-mtime — but crossing 512 MiB at
114    /// startup indicates the workspace itself has more files than the
115    /// cap fits, and the rebuild cost is bounded to one full
116    /// re-scan.
117    pub fn new(root: &Path) -> Option<Self> {
118        let base = cache_base_dir()?;
119        let schema = schema_version();
120        let php_lsp_dir = base.join("php-lsp");
121        let workspace = workspace_hash(root);
122        let dir = php_lsp_dir.join(schema).join(&workspace);
123        std::fs::create_dir_all(&dir).ok()?;
124        let _ = write_workspace_meta(&dir, root);
125        drop(prune_stale_dirs(php_lsp_dir, schema.to_string(), workspace));
126        let cache = Self { dir };
127        if cache.size_bytes().unwrap_or(0) > CACHE_SIZE_CAP {
128            let _ = cache.clear();
129        }
130        Some(cache)
131    }
132
133    /// The filesystem path of this workspace's cache directory.
134    pub fn cache_dir(&self) -> &std::path::Path {
135        &self.dir
136    }
137
138    /// Total bytes consumed by this workspace's cache directory, including
139    /// nested subdirectories — mir's `AnalysisCache` lives under a `session/`
140    /// subdirectory of this one (see `set_session_cache_dir`), so the layout
141    /// isn't flat even though this directory's own entries are.
142    pub fn size_bytes(&self) -> io::Result<u64> {
143        dir_size(&self.dir)
144    }
145
146    /// Override the root directory directly. The directory is used verbatim
147    /// (no schema / workspace subdirectories are appended). Use for tests or
148    /// when the caller provides an explicit `cachePath` initializationOption.
149    pub fn with_dir(dir: PathBuf) -> Self {
150        Self { dir }
151    }
152
153    /// Build a cache key for `(uri, content)`. The filename half (`uri_hash`)
154    /// stays fixed across edits to the same URI so a re-index overwrites its
155    /// existing slot; the validation half (`content_hash`) changes on every
156    /// edit so a read against stale content misses.
157    pub fn key_for(uri: &str, content: &str) -> CacheKey {
158        let uri_hash = blake3::hash(uri.as_bytes()).to_hex().as_str()[..32].to_string();
159        let content_hash = blake3::hash(content.as_bytes()).to_hex().as_str()[..32].to_string();
160        CacheKey {
161            uri_hash,
162            content_hash,
163        }
164    }
165
166    /// Deserialize a previously-cached value. Returns `None` on any I/O
167    /// or decode failure, or when the entry's stored content hash no
168    /// longer matches `key` (the slot holds a stale revision) — all of
169    /// which should look identical to a missing entry so callers fall
170    /// through to the recompute path.
171    pub fn read<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<T> {
172        let path = self.path_for(key);
173        let bytes = std::fs::read(&path).ok()?;
174        let config = bincode::config::standard();
175        let (stored_hash, value): (String, T) =
176            bincode::serde::decode_from_slice(&bytes, config).ok()?.0;
177        if stored_hash != key.content_hash {
178            return None;
179        }
180        Some(value)
181    }
182
183    /// Atomically publish an entry to the cache. Writes to a sibling
184    /// temp file then renames, so readers never see a half-written
185    /// payload even if the process dies mid-write. Because the filename
186    /// is keyed on URI alone, this overwrites whatever revision (if any)
187    /// previously occupied the slot rather than leaving it behind.
188    ///
189    /// No fsync: the cache is advisory-only — a crash that loses a write
190    /// just produces a cache miss on the next startup, which safely falls
191    /// back to re-parsing. Skipping sync_all() avoids 5–15 ms per file on
192    /// macOS, which on a 1,500-file project accounts for most of the cold
193    /// indexing time.
194    pub fn write<T: Serialize>(&self, key: &CacheKey, value: &T) -> io::Result<()> {
195        let path = self.path_for(key);
196        let tmp = path.with_extension("tmp");
197        let config = bincode::config::standard();
198        let bytes = bincode::serde::encode_to_vec((&key.content_hash, value), config)
199            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
200        {
201            let mut f = std::fs::File::create(&tmp)?;
202            f.write_all(&bytes)?;
203        }
204        std::fs::rename(&tmp, &path)?;
205        Ok(())
206    }
207
208    /// Drop every entry in this workspace's cache. Safe to call while
209    /// other threads are reading — individual `read` calls that race
210    /// with a `clear` will see `None` rather than garbage, and the next
211    /// `write` recreates the entry.
212    pub fn clear(&self) -> io::Result<()> {
213        if self.dir.exists() {
214            std::fs::remove_dir_all(&self.dir)?;
215            std::fs::create_dir_all(&self.dir)?;
216        }
217        Ok(())
218    }
219
220    fn path_for(&self, key: &CacheKey) -> PathBuf {
221        self.dir.join(format!("{}.bin", key.as_filename()))
222    }
223}
224
225/// Recursively sums file sizes under `dir`. Used for the size cap: the
226/// workspace cache directory isn't flat — mir's `AnalysisCache` lives in a
227/// `session/` subdirectory underneath it — so a top-level-only scan would
228/// never see it grow.
229fn dir_size(dir: &Path) -> io::Result<u64> {
230    let mut total = 0u64;
231    let entries = match std::fs::read_dir(dir) {
232        Ok(e) => e,
233        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
234        Err(e) => return Err(e),
235    };
236    for entry in entries.flatten() {
237        let meta = match entry.metadata() {
238            Ok(m) => m,
239            Err(_) => continue,
240        };
241        if meta.is_dir() {
242            total = total.saturating_add(dir_size(&entry.path())?);
243        } else if meta.is_file() {
244            total = total.saturating_add(meta.len());
245        }
246    }
247    Ok(total)
248}
249
250/// Removes sibling schema-version directories under `php_lsp_dir` other than
251/// `current_schema`, plus workspace directories under `current_schema` other
252/// than `current_workspace` that [`is_stale_workspace_dir`] rejects, then
253/// evicts least recently used workspaces past [`TOTAL_CACHE_SIZE_CAP`].
254/// `schema_version()` embeds `CARGO_PKG_VERSION`, so every release moves to a
255/// new schema directory, and every workspace ever opened (including temp
256/// directories) gets its own; without this, nothing ever reads or removes
257/// them. Only directories are touched — `php_lsp_dir` also holds flat sibling
258/// files (e.g. `php-binary-version.json`, see
259/// `autoload::detect_php_binary_version`) that must survive. Best-effort:
260/// errors are ignored, same advisory-cache posture as the rest of this module.
261///
262/// Everything runs on a detached background thread: a stale schema directory
263/// on a long-lived dev machine can hold millions of entries, and deleting it
264/// inside `WorkspaceCache::new()` — on the same path as the workspace scan —
265/// stalled startup for 20+ minutes before `indexReady` ever fired. Returns the
266/// join handle so tests can wait for completion; normal callers drop it.
267fn prune_stale_dirs(
268    php_lsp_dir: PathBuf,
269    current_schema: String,
270    current_workspace: String,
271) -> std::thread::JoinHandle<()> {
272    std::thread::spawn(move || {
273        let now = SystemTime::now();
274        for dir in subdirs(&php_lsp_dir) {
275            if dir.file_name() != Some(current_schema.as_ref()) {
276                let _ = std::fs::remove_dir_all(dir);
277            }
278        }
279        let schema_dir = php_lsp_dir.join(&current_schema);
280        for dir in subdirs(&schema_dir) {
281            if dir.file_name() != Some(current_workspace.as_ref())
282                && is_stale_workspace_dir(&dir, now)
283            {
284                let _ = std::fs::remove_dir_all(dir);
285            }
286        }
287        evict_lru_workspaces(&schema_dir, &current_workspace, TOTAL_CACHE_SIZE_CAP);
288    })
289}
290
291/// Deletes workspace directories under `schema_dir`, least recently used
292/// first, until their combined size is at most `cap`. `current_workspace`
293/// counts toward the total but is never deleted.
294fn evict_lru_workspaces(schema_dir: &Path, current_workspace: &str, cap: u64) {
295    let mut total = 0u64;
296    let mut candidates = Vec::new();
297    for dir in subdirs(schema_dir) {
298        let size = dir_size(&dir).unwrap_or(0);
299        total = total.saturating_add(size);
300        if dir.file_name() != Some(current_workspace.as_ref()) {
301            let used = last_used(&dir, read_workspace_meta(&dir).as_ref());
302            candidates.push((used, size, dir));
303        }
304    }
305    candidates.sort_by_key(|(used, _, _)| *used);
306    for (_, size, dir) in candidates {
307        if total <= cap {
308            break;
309        }
310        if std::fs::remove_dir_all(&dir).is_ok() {
311            total -= size;
312        }
313    }
314}
315
316fn subdirs(dir: &Path) -> Vec<PathBuf> {
317    let Ok(entries) = std::fs::read_dir(dir) else {
318        return Vec::new();
319    };
320    entries
321        .flatten()
322        .filter(|entry| entry.file_type().is_ok_and(|t| t.is_dir()))
323        .map(|entry| entry.path())
324        .collect()
325}
326
327#[derive(Serialize, serde::Deserialize)]
328struct WorkspaceMeta {
329    root: PathBuf,
330    last_used_secs: u64,
331}
332
333fn write_workspace_meta(dir: &Path, root: &Path) -> io::Result<()> {
334    let meta = WorkspaceMeta {
335        root: root.canonicalize().unwrap_or_else(|_| root.to_path_buf()),
336        last_used_secs: SystemTime::now()
337            .duration_since(UNIX_EPOCH)
338            .map_or(0, |d| d.as_secs()),
339    };
340    let path = dir.join(META_FILE);
341    let tmp = path.with_extension("json.tmp");
342    std::fs::write(&tmp, serde_json::to_vec(&meta).map_err(io::Error::other)?)?;
343    std::fs::rename(&tmp, &path)
344}
345
346/// A workspace cache directory is stale when its recorded root is gone, or
347/// when neither its meta stamp nor its own mtime (bumped by every entry
348/// write, so a long-running instance keeps its directory fresh) falls within
349/// [`WORKSPACE_MAX_IDLE`]. Without a readable meta file it only survives
350/// [`NO_META_GRACE`].
351fn is_stale_workspace_dir(dir: &Path, now: SystemTime) -> bool {
352    let idle_since = |t: SystemTime| now.duration_since(t).unwrap_or(Duration::ZERO);
353    let Some(meta) = read_workspace_meta(dir) else {
354        return idle_since(last_used(dir, None)) > NO_META_GRACE;
355    };
356    if !meta.root.exists() {
357        return true;
358    }
359    idle_since(last_used(dir, Some(&meta))) > WORKSPACE_MAX_IDLE
360}
361
362fn read_workspace_meta(dir: &Path) -> Option<WorkspaceMeta> {
363    let bytes = std::fs::read(dir.join(META_FILE)).ok()?;
364    serde_json::from_slice(&bytes).ok()
365}
366
367/// The later of the meta stamp and the directory's mtime; an unreadable
368/// mtime counts as "now" so an error never makes a directory look old.
369fn last_used(dir: &Path, meta: Option<&WorkspaceMeta>) -> SystemTime {
370    let mtime = std::fs::metadata(dir)
371        .and_then(|m| m.modified())
372        .unwrap_or_else(|_| SystemTime::now());
373    let stamped = meta.map_or(UNIX_EPOCH, |m| {
374        UNIX_EPOCH + Duration::from_secs(m.last_used_secs)
375    });
376    stamped.max(mtime)
377}
378
379/// Platform cache directory: `$XDG_CACHE_HOME` or `$HOME/.cache` on Unix,
380/// `%LOCALAPPDATA%` on Windows. Deliberately doesn't depend on the `dirs`
381/// crate — keeps the footprint small and the behaviour predictable.
382pub(crate) fn cache_base_dir() -> Option<PathBuf> {
383    if let Some(xdg) = std::env::var_os("XDG_CACHE_HOME")
384        && !xdg.is_empty()
385    {
386        return Some(PathBuf::from(xdg));
387    }
388    if cfg!(windows) {
389        if let Some(local) = std::env::var_os("LOCALAPPDATA")
390            && !local.is_empty()
391        {
392            return Some(PathBuf::from(local));
393        }
394    } else if let Some(home) = std::env::var_os("HOME")
395        && !home.is_empty()
396    {
397        return Some(PathBuf::from(home).join(".cache"));
398    }
399    None
400}
401
402/// Schema marker: bumping `php-lsp`'s crate version invalidates every cached
403/// entry (a new release moves to a new directory; see
404/// `prune_stale_dirs` for cleanup of the old one). `fi-vN` is the one
405/// manual knob — bump it whenever `FileIndex` or any type it contains gains,
406/// loses, or renames a field, including a `php_ast`/parser upgrade that
407/// changes what gets extracted. `FileIndex` has no dependency on any mir
408/// type, so a mir version has no bearing on this cache's validity.
409fn schema_version() -> &'static str {
410    concat!(env!("CARGO_PKG_VERSION"), "-fi-v6")
411}
412
413fn workspace_hash(root: &Path) -> String {
414    let canonical = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
415    let hex = blake3::hash(canonical.as_os_str().as_encoded_bytes()).to_hex();
416    hex.as_str()[..16].to_string()
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use tempfile::TempDir;
423
424    #[derive(Serialize, serde::Deserialize, PartialEq, Debug)]
425    struct SamplePayload {
426        name: String,
427        values: Vec<u32>,
428    }
429
430    #[test]
431    fn key_for_is_deterministic_per_uri_and_content() {
432        let k1 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
433        let k2 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
434        assert_eq!(k1, k2);
435    }
436
437    #[test]
438    fn key_for_differs_when_content_differs() {
439        let k1 = WorkspaceCache::key_for("file:///a.php", "<?php echo 1;");
440        let k2 = WorkspaceCache::key_for("file:///a.php", "<?php echo 2;");
441        assert_ne!(k1, k2);
442    }
443
444    #[test]
445    fn key_for_differs_when_uri_differs() {
446        let k1 = WorkspaceCache::key_for("file:///a.php", "<?php");
447        let k2 = WorkspaceCache::key_for("file:///b.php", "<?php");
448        assert_ne!(k1, k2);
449    }
450
451    #[test]
452    fn write_overwrites_slot_instead_of_orphaning_old_revision() {
453        let dir = TempDir::new().unwrap();
454        let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
455        let uri = "file:///churn.php";
456
457        let key_v1 = WorkspaceCache::key_for(uri, "<?php echo 1;");
458        cache
459            .write(
460                &key_v1,
461                &SamplePayload {
462                    name: "v1".into(),
463                    values: vec![1],
464                },
465            )
466            .unwrap();
467
468        let key_v2 = WorkspaceCache::key_for(uri, "<?php echo 2;");
469        cache
470            .write(
471                &key_v2,
472                &SamplePayload {
473                    name: "v2".into(),
474                    values: vec![2],
475                },
476            )
477            .unwrap();
478
479        // Same URI → same on-disk slot, so the second write replaces the
480        // first rather than leaving a second, now-unreachable .bin file.
481        let bin_files: Vec<_> = std::fs::read_dir(dir.path())
482            .unwrap()
483            .filter_map(|e| e.ok())
484            .filter(|e| e.path().extension().is_some_and(|ext| ext == "bin"))
485            .collect();
486        assert_eq!(
487            bin_files.len(),
488            1,
489            "editing a file must not orphan its previous cache entry"
490        );
491
492        // The stale key (old content) now misses instead of returning v1.
493        let stale: Option<SamplePayload> = cache.read(&key_v1);
494        assert!(stale.is_none());
495
496        let current: SamplePayload = cache.read(&key_v2).unwrap();
497        assert_eq!(current.name, "v2");
498    }
499
500    #[test]
501    fn write_then_read_round_trips() {
502        let dir = TempDir::new().unwrap();
503        let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
504        let key = WorkspaceCache::key_for("file:///x.php", "<?php");
505        let payload = SamplePayload {
506            name: "x".into(),
507            values: vec![1, 2, 3],
508        };
509        cache.write(&key, &payload).unwrap();
510        let decoded: SamplePayload = cache.read(&key).unwrap();
511        assert_eq!(decoded, payload);
512    }
513
514    #[test]
515    fn read_returns_none_for_missing_key() {
516        let dir = TempDir::new().unwrap();
517        let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
518        let missing = WorkspaceCache::key_for("file:///nope.php", "");
519        let decoded: Option<SamplePayload> = cache.read(&missing);
520        assert!(decoded.is_none());
521    }
522
523    #[test]
524    fn read_returns_none_for_corrupted_entry() {
525        let dir = TempDir::new().unwrap();
526        let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
527        let key = WorkspaceCache::key_for("file:///c.php", "<?php");
528        // Write garbage bytes directly into the slot the cache would use.
529        std::fs::write(cache.path_for(&key), b"not valid bincode").unwrap();
530        let decoded: Option<SamplePayload> = cache.read(&key);
531        assert!(
532            decoded.is_none(),
533            "corrupted entry must look missing, not panic"
534        );
535    }
536
537    #[test]
538    fn write_is_atomic_via_rename() {
539        // If the write path didn't go through a temp file, a crash
540        // mid-`write_all` could leave a half-written `.bin`. We can't
541        // easily simulate a crash, but we can at least assert the
542        // temp-file doesn't linger on success.
543        let dir = TempDir::new().unwrap();
544        let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
545        let key = WorkspaceCache::key_for("file:///atomic.php", "<?php");
546        let payload = SamplePayload {
547            name: "a".into(),
548            values: vec![],
549        };
550        cache.write(&key, &payload).unwrap();
551        let tmp = cache.path_for(&key).with_extension("tmp");
552        assert!(!tmp.exists(), "tmp file should be removed by rename");
553    }
554
555    #[test]
556    fn clear_drops_all_entries() {
557        let dir = TempDir::new().unwrap();
558        let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
559        for i in 0..3 {
560            let k = WorkspaceCache::key_for(&format!("file:///c{i}.php"), "");
561            cache
562                .write(
563                    &k,
564                    &SamplePayload {
565                        name: i.to_string(),
566                        values: vec![],
567                    },
568                )
569                .unwrap();
570        }
571        cache.clear().unwrap();
572        for i in 0..3 {
573            let k = WorkspaceCache::key_for(&format!("file:///c{i}.php"), "");
574            let decoded: Option<SamplePayload> = cache.read(&k);
575            assert!(decoded.is_none());
576        }
577    }
578
579    #[test]
580    fn size_bytes_sums_flat_bin_files() {
581        let dir = TempDir::new().unwrap();
582        let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
583        assert_eq!(cache.size_bytes().unwrap(), 0);
584
585        let key1 = WorkspaceCache::key_for("file:///s1.php", "<?php");
586        cache
587            .write(
588                &key1,
589                &SamplePayload {
590                    name: "s1".into(),
591                    values: vec![0u32; 16],
592                },
593            )
594            .unwrap();
595        let key2 = WorkspaceCache::key_for("file:///s2.php", "<?php");
596        cache
597            .write(
598                &key2,
599                &SamplePayload {
600                    name: "s2".into(),
601                    values: vec![0u32; 16],
602                },
603            )
604            .unwrap();
605
606        let total = cache.size_bytes().unwrap();
607        let expected1 = cache.path_for(&key1).metadata().unwrap().len();
608        let expected2 = cache.path_for(&key2).metadata().unwrap().len();
609        assert_eq!(total, expected1 + expected2);
610    }
611
612    #[test]
613    fn size_bytes_recurses_into_nested_session_dir() {
614        // Simulates mir's AnalysisCache living under `<workspace-hash>/session/`:
615        // size_bytes must see it, not just this directory's own flat entries.
616        let dir = TempDir::new().unwrap();
617        let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
618
619        let key = WorkspaceCache::key_for("file:///s.php", "<?php");
620        cache
621            .write(
622                &key,
623                &SamplePayload {
624                    name: "s".into(),
625                    values: vec![0u32; 16],
626                },
627            )
628            .unwrap();
629
630        let session_dir = dir.path().join("session");
631        std::fs::create_dir_all(&session_dir).unwrap();
632        std::fs::write(session_dir.join("cache.bin"), vec![0u8; 128]).unwrap();
633
634        let flat_len = cache.path_for(&key).metadata().unwrap().len();
635        assert_eq!(cache.size_bytes().unwrap(), flat_len + 128);
636    }
637
638    #[test]
639    fn file_index_round_trips() {
640        use crate::document::ast::ParsedDoc;
641        use crate::index::file_index::FileIndex;
642
643        let dir = TempDir::new().unwrap();
644        let cache = WorkspaceCache::with_dir(dir.path().to_path_buf());
645        let src = "<?php\nnamespace App;\nclass Foo { public function bar(): string {} }";
646        let key = WorkspaceCache::key_for("file:///Foo.php", src);
647
648        let doc = ParsedDoc::parse(src.to_string());
649        let index = FileIndex::extract(&doc);
650        cache.write(&key, &index).unwrap();
651
652        let decoded: FileIndex = cache.read(&key).unwrap();
653        assert_eq!(decoded.namespace.as_deref(), Some("App"));
654        assert_eq!(decoded.classes.len(), 1);
655        assert_eq!(decoded.classes[0].name.as_ref(), "Foo");
656        assert_eq!(decoded.classes[0].methods.len(), 1);
657        assert_eq!(decoded.classes[0].methods[0].name.as_ref(), "bar");
658    }
659
660    #[test]
661    fn prune_stale_dirs_removes_old_schemas_keeps_current_and_flat_files() {
662        let dir = TempDir::new().unwrap();
663        let php_lsp_dir = dir.path();
664
665        let stale = php_lsp_dir.join("0.21.0-fi-v3");
666        std::fs::create_dir_all(stale.join("workspacehash")).unwrap();
667        std::fs::write(stale.join("workspacehash").join("a.bin"), b"old").unwrap();
668
669        let current = php_lsp_dir.join("0.22.0-fi-v4");
670        std::fs::create_dir_all(&current).unwrap();
671
672        // Sibling flat file, like `php-binary-version.json` — must survive.
673        std::fs::write(php_lsp_dir.join("php-binary-version.json"), b"{}").unwrap();
674
675        prune_stale_dirs(
676            php_lsp_dir.to_path_buf(),
677            "0.22.0-fi-v4".into(),
678            "current".into(),
679        )
680        .join()
681        .unwrap();
682
683        assert!(!stale.exists(), "stale schema directory must be removed");
684        assert!(current.exists(), "current schema directory must survive");
685        assert!(
686            php_lsp_dir.join("php-binary-version.json").exists(),
687            "flat sibling files must not be touched"
688        );
689    }
690
691    fn workspace_dir_with_meta(parent: &Path, root: &Path) -> PathBuf {
692        let dir = parent.join(workspace_hash(root));
693        std::fs::create_dir_all(&dir).unwrap();
694        write_workspace_meta(&dir, root).unwrap();
695        dir
696    }
697
698    #[test]
699    fn workspace_dir_is_stale_once_its_root_is_deleted() {
700        let cache = TempDir::new().unwrap();
701        let root = TempDir::new().unwrap();
702        let dir = workspace_dir_with_meta(cache.path(), root.path());
703        assert!(!is_stale_workspace_dir(&dir, SystemTime::now()));
704
705        drop(root);
706        assert!(is_stale_workspace_dir(&dir, SystemTime::now()));
707    }
708
709    #[test]
710    fn workspace_dir_is_stale_after_max_idle() {
711        let cache = TempDir::new().unwrap();
712        let root = TempDir::new().unwrap();
713        let dir = workspace_dir_with_meta(cache.path(), root.path());
714        let now = SystemTime::now();
715        assert!(!is_stale_workspace_dir(&dir, now + WORKSPACE_MAX_IDLE / 2));
716        assert!(is_stale_workspace_dir(
717            &dir,
718            now + WORKSPACE_MAX_IDLE + Duration::from_secs(60)
719        ));
720    }
721
722    #[test]
723    fn workspace_dir_without_meta_survives_only_the_grace_period() {
724        let cache = TempDir::new().unwrap();
725        let dir = cache.path().join("legacy");
726        std::fs::create_dir_all(&dir).unwrap();
727        let now = SystemTime::now();
728        assert!(!is_stale_workspace_dir(&dir, now));
729        assert!(is_stale_workspace_dir(
730            &dir,
731            now + NO_META_GRACE + Duration::from_secs(60)
732        ));
733    }
734
735    #[test]
736    fn prune_stale_dirs_removes_orphaned_workspaces_keeps_current_and_live() {
737        let php_lsp_dir = TempDir::new().unwrap();
738        let schema_dir = php_lsp_dir.path().join("0.22.0-fi-v4");
739
740        let live_root = TempDir::new().unwrap();
741        let live = workspace_dir_with_meta(&schema_dir, live_root.path());
742
743        let gone_root = TempDir::new().unwrap();
744        let orphaned = workspace_dir_with_meta(&schema_dir, gone_root.path());
745        drop(gone_root);
746
747        // The current workspace survives even with a deleted root.
748        let current_root = TempDir::new().unwrap();
749        let current = workspace_dir_with_meta(&schema_dir, current_root.path());
750        drop(current_root);
751
752        prune_stale_dirs(
753            php_lsp_dir.path().to_path_buf(),
754            "0.22.0-fi-v4".into(),
755            current.file_name().unwrap().to_string_lossy().into_owned(),
756        )
757        .join()
758        .unwrap();
759
760        assert!(live.exists());
761        assert!(current.exists());
762        assert!(!orphaned.exists());
763    }
764
765    #[test]
766    fn evict_lru_workspaces_drops_oldest_until_under_cap() {
767        let schema_dir = TempDir::new().unwrap();
768        let root = TempDir::new().unwrap();
769        // Directory mtimes are all "now", so only a future stamp sorts newer.
770        let stamps = [u64::from(u32::MAX), 0, 0];
771        let dirs: Vec<PathBuf> = stamps
772            .iter()
773            .enumerate()
774            .map(|(i, &last_used_secs)| {
775                let dir = schema_dir.path().join(format!("ws{i}"));
776                std::fs::create_dir_all(&dir).unwrap();
777                std::fs::write(dir.join("a.bin"), vec![0u8; 1000]).unwrap();
778                let meta = WorkspaceMeta {
779                    root: root.path().to_path_buf(),
780                    last_used_secs,
781                };
782                std::fs::write(dir.join(META_FILE), serde_json::to_vec(&meta).unwrap()).unwrap();
783                dir
784            })
785            .collect();
786
787        // ws2 is current: counted, never deleted, so the older ws1 goes.
788        let per_dir = dir_size(&dirs[0]).unwrap();
789        evict_lru_workspaces(schema_dir.path(), "ws2", 2 * per_dir + per_dir / 2);
790
791        assert!(dirs[0].exists());
792        assert!(!dirs[1].exists());
793        assert!(dirs[2].exists());
794    }
795}