1use std::io::{self, Write};
53use std::path::{Path, PathBuf};
54use std::time::{Duration, SystemTime, UNIX_EPOCH};
55
56use serde::{Serialize, de::DeserializeOwned};
57
58#[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#[derive(Debug, Clone)]
81pub struct WorkspaceCache {
82 dir: PathBuf,
83}
84
85pub const CACHE_SIZE_CAP: u64 = 512 * 1024 * 1024;
92
93pub const TOTAL_CACHE_SIZE_CAP: u64 = 2 * 1024 * 1024 * 1024;
97
98const WORKSPACE_MAX_IDLE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
99
100const NO_META_GRACE: Duration = Duration::from_secs(24 * 60 * 60);
103
104const OTHER_SCHEMA_GRACE: Duration = Duration::from_secs(24 * 60 * 60);
106
107const META_FILE: &str = "workspace.json";
108
109impl WorkspaceCache {
110 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 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 pub fn cache_dir(&self) -> &std::path::Path {
153 &self.dir
154 }
155
156 pub fn size_bytes(&self) -> io::Result<u64> {
161 dir_size(&self.dir)
162 }
163
164 pub fn with_dir(dir: PathBuf) -> Self {
168 Self { dir }
169 }
170
171 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 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 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 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
243fn 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
268fn 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 ¤t_schema,
294 ¤t_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
321fn 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
330fn 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
385fn 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
406fn 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
418pub(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
441fn 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 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 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 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 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 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(¤t).unwrap();
725
726 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 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 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 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}