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