1use crate::cow::parse_base_project_name;
2use crate::state::{read_state, write_state};
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::time::{Duration, SystemTime};
6use tracing::{debug, info};
7
8#[derive(Debug, Clone, Default)]
9pub struct GcReport {
10 pub total_workspaces_scanned: usize,
11 pub caches_trimmed_bytes: u64,
12 pub workspaces_deleted: usize,
13 pub workspaces_deleted_bytes: u64,
14 pub remaining_disk_bytes: u64,
15}
16
17#[derive(Debug, Clone, Default)]
19pub struct CasGcReport {
20 pub objects_deleted: usize,
21 pub bytes_freed: u64,
22 pub remaining_bytes: u64,
23}
24
25#[derive(Debug, Clone)]
26pub struct WorkspaceMetadata {
27 pub path: PathBuf,
28 pub name: String,
29 pub project: String,
36 pub last_used_at: SystemTime,
37 pub size_bytes: u64,
38 pub is_canonical: bool,
39}
40
41pub fn calculate_dir_size(path: &Path) -> u64 {
43 let mut total = 0u64;
44 if let Ok(entries) = fs::read_dir(path) {
45 for entry in entries.flatten() {
46 if let Ok(meta) = entry.metadata() {
53 if meta.is_dir() {
54 total += calculate_dir_size(&entry.path());
55 } else {
56 total += meta.len();
57 }
58 }
59 }
60 }
61 total
62}
63
64pub fn touch_workspace(workspace_dir: &Path, _project_name: &str) {
66 if let Some(mut state) = read_state(workspace_dir) {
67 state.last_installed_at = SystemTime::now();
68 let _ = write_state(workspace_dir, &state);
69 }
70
71 let stamp = workspace_dir.join(".farhand-last-used");
73 let _ = fs::write(stamp, format!("{:?}", SystemTime::now()));
74}
75
76pub fn get_workspace_last_used(workspace_dir: &Path) -> SystemTime {
78 if let Ok(meta) = fs::metadata(workspace_dir.join(".farhand-last-used")) {
79 if let Ok(mtime) = meta.modified() {
80 return mtime;
81 }
82 }
83
84 if let Some(state) = read_state(workspace_dir) {
85 return state.last_installed_at;
86 }
87
88 if let Ok(meta) = fs::metadata(workspace_dir.join(".farhand-state.json")) {
89 if let Ok(mtime) = meta.modified() {
90 return mtime;
91 }
92 }
93
94 if let Ok(meta) = fs::metadata(workspace_dir) {
95 if let Ok(mtime) = meta.modified() {
96 return mtime;
97 }
98 }
99
100 SystemTime::UNIX_EPOCH
101}
102
103pub fn trim_workspace_caches(workspace_dir: &Path) -> u64 {
106 let cache_dirs = [
107 "target/debug/incremental",
108 "target/release/incremental",
109 "node_modules/.cache",
110 ".next/cache",
111 "DerivedData",
112 ".gradle/caches",
113 ];
114
115 let mut freed_bytes = 0u64;
116 for rel_dir in cache_dirs {
117 let dir = workspace_dir.join(rel_dir);
118 if dir.is_dir() {
119 let size = calculate_dir_size(&dir);
120 if fs::remove_dir_all(&dir).is_ok() {
121 freed_bytes += size;
122 info!("Trimmed cache {} (freed {} bytes)", dir.display(), size);
123 }
124 }
125 }
126
127 freed_bytes
128}
129
130pub fn scan_workspaces(workspaces_root: &Path) -> Vec<WorkspaceMetadata> {
132 let mut workspaces = Vec::new();
133
134 let entries = match fs::read_dir(workspaces_root) {
135 Ok(e) => e,
136 Err(_) => return workspaces,
137 };
138
139 for entry in entries.flatten() {
140 let path = entry.path();
141 if path.is_dir() {
142 let file_name = path
143 .file_name()
144 .and_then(|s| s.to_str())
145 .unwrap_or("")
146 .to_string();
147
148 if file_name == "cas" || file_name.starts_with('.') {
150 continue;
151 }
152
153 let last_used = get_workspace_last_used(&path);
154 let size = calculate_dir_size(&path);
155
156 let is_canonical = parse_base_project_name(&file_name).is_none()
159 || file_name.contains("__main")
160 || file_name.contains("__master")
161 || file_name.contains(":main")
162 || file_name.contains(":master");
163
164 let project = read_state(&path)
169 .map(|s| s.project)
170 .filter(|p| !p.is_empty())
171 .unwrap_or_else(|| file_name.clone());
172
173 workspaces.push(WorkspaceMetadata {
174 path,
175 name: file_name,
176 project,
177 last_used_at: last_used,
178 size_bytes: size,
179 is_canonical,
180 });
181 }
182 }
183
184 workspaces
185}
186
187pub fn run_garbage_collection(
193 workspaces_root: &Path,
194 max_disk_bytes: Option<u64>,
195 ttl: Option<Duration>,
196 skip_locked: &dyn Fn(&str) -> bool,
197) -> GcReport {
198 let mut report = GcReport::default();
199 let mut workspaces = scan_workspaces(workspaces_root);
200 report.total_workspaces_scanned = workspaces.len();
201
202 let now = SystemTime::now();
203
204 if let Some(ttl_dur) = ttl {
206 workspaces.retain(|ws| {
207 if !ws.is_canonical {
208 if skip_locked(&ws.project) {
209 debug!("GC: skipping locked workspace {} (active run)", ws.name);
210 return true;
211 }
212 if let Ok(age) = now.duration_since(ws.last_used_at) {
213 if age > ttl_dur {
214 info!(
215 "TTL expired for workspace {} (age {:?} > {:?}). Purging...",
216 ws.path.display(),
217 age,
218 ttl_dur
219 );
220 let _ = fs::remove_dir_all(&ws.path);
221 report.workspaces_deleted += 1;
222 report.workspaces_deleted_bytes += ws.size_bytes;
223 return false;
224 }
225 }
226 }
227 true
228 });
229 }
230
231 let current_total: u64 = workspaces.iter().map(|w| w.size_bytes).sum();
233 let mut current_usage = current_total;
234
235 if let Some(max_bytes) = max_disk_bytes {
236 if current_usage > max_bytes {
237 workspaces.sort_by_key(|a| a.last_used_at);
239
240 for ws in &mut workspaces {
242 if current_usage <= max_bytes {
243 break;
244 }
245 if !ws.is_canonical && !skip_locked(&ws.project) {
246 let trimmed = trim_workspace_caches(&ws.path);
247 report.caches_trimmed_bytes += trimmed;
248 current_usage = current_usage.saturating_sub(trimmed);
249 ws.size_bytes = ws.size_bytes.saturating_sub(trimmed);
250 }
251 }
252
253 for ws in &workspaces {
255 if current_usage <= max_bytes {
256 break;
257 }
258 if !ws.is_canonical && !skip_locked(&ws.project) {
259 info!(
260 "Quota exceeded. Purging LRU workspace {} (size {} bytes)...",
261 ws.path.display(),
262 ws.size_bytes
263 );
264 let _ = fs::remove_dir_all(&ws.path);
265 report.workspaces_deleted += 1;
266 report.workspaces_deleted_bytes += ws.size_bytes;
267 current_usage = current_usage.saturating_sub(ws.size_bytes);
268 }
269 }
270 }
271 }
272
273 report.remaining_disk_bytes = current_usage;
274 report
275}
276
277pub fn run_emergency_disk_gc(
283 workspaces_root: &Path,
284 target_bytes_to_free: u64,
285 skip_locked: &dyn Fn(&str) -> bool,
286) -> GcReport {
287 let mut report = GcReport::default();
288 let mut workspaces = scan_workspaces(workspaces_root);
289 report.total_workspaces_scanned = workspaces.len();
290
291 workspaces.sort_by_key(|w| w.last_used_at);
293
294 let mut freed_bytes = 0u64;
295
296 for ws in &mut workspaces {
298 if freed_bytes >= target_bytes_to_free {
299 break;
300 }
301 if !ws.is_canonical && !skip_locked(&ws.project) {
302 let trimmed = trim_workspace_caches(&ws.path);
303 report.caches_trimmed_bytes += trimmed;
304 freed_bytes += trimmed;
305 ws.size_bytes = ws.size_bytes.saturating_sub(trimmed);
306 }
307 }
308
309 for ws in &workspaces {
311 if freed_bytes >= target_bytes_to_free {
312 break;
313 }
314 if !ws.is_canonical && !skip_locked(&ws.project) {
315 info!(
316 "Emergency GC: Purging LRU workspace {} (size {} bytes)...",
317 ws.path.display(),
318 ws.size_bytes
319 );
320 let _ = fs::remove_dir_all(&ws.path);
321 report.workspaces_deleted += 1;
322 report.workspaces_deleted_bytes += ws.size_bytes;
323 freed_bytes += ws.size_bytes;
324 }
325 }
326
327 report.remaining_disk_bytes = freed_bytes;
328 report
329}
330
331pub fn gc_cas(
344 cas_objects_dir: &Path,
345 max_bytes: Option<u64>,
346 ttl: Option<Duration>,
347) -> CasGcReport {
348 let mut report = CasGcReport::default();
349
350 let mut objects: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
351 for entry in walkdir::WalkDir::new(cas_objects_dir)
352 .follow_links(false)
353 .into_iter()
354 .flatten()
355 {
356 let path = entry.path();
357 if !path.is_file() {
358 continue;
359 }
360 let meta = match entry.metadata() {
361 Ok(m) => m,
362 Err(_) => continue,
363 };
364 objects.push((
365 path.to_path_buf(),
366 meta.len(),
367 meta.modified().unwrap_or(SystemTime::now()),
368 ));
369 }
370
371 let now = SystemTime::now();
372 let mut remaining: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
373 let is_tmp = |p: &Path| {
374 p.file_name()
375 .and_then(|s| s.to_str())
376 .map(|s| s.contains(".tmp."))
377 .unwrap_or(false)
378 };
379
380 for (path, size, mtime) in objects {
381 let age = now.duration_since(mtime).unwrap_or_default();
382 let stale_tmp = is_tmp(&path) && age > Duration::from_secs(3600);
383 let expired = ttl.map(|t| age > t).unwrap_or(false);
384
385 if stale_tmp || expired {
386 if fs::remove_file(&path).is_ok() {
387 report.objects_deleted += 1;
388 report.bytes_freed += size;
389 } else {
390 remaining.push((path, size, mtime));
391 }
392 } else {
393 remaining.push((path, size, mtime));
394 }
395 }
396
397 if let Some(max_bytes) = max_bytes {
399 let mut total: u64 = remaining.iter().map(|(_, s, _)| *s).sum();
400 if total > max_bytes {
401 remaining.sort_by_key(|(_, _, mtime)| *mtime);
402 let mut survivors = Vec::new();
403 for (path, size, mtime) in remaining {
404 if total > max_bytes && fs::remove_file(&path).is_ok() {
405 report.objects_deleted += 1;
406 report.bytes_freed += size;
407 total = total.saturating_sub(size);
408 } else {
409 survivors.push((path, size, mtime));
410 }
411 }
412 remaining = survivors;
413 }
414 }
415
416 report.remaining_bytes = remaining.iter().map(|(_, s, _)| *s).sum();
417 report
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use tempfile::tempdir;
424
425 #[test]
426 fn test_trim_workspace_caches() {
427 let temp = tempdir().unwrap();
428 let ws = temp.path().join("my-ws");
429
430 let cache_dir = ws.join("target/debug/incremental");
431 fs::create_dir_all(&cache_dir).unwrap();
432 fs::write(cache_dir.join("cache.dat"), "1234567890").unwrap();
433
434 let src_file = ws.join("src/main.rs");
435 fs::create_dir_all(ws.join("src")).unwrap();
436 fs::write(&src_file, "fn main() {}").unwrap();
437
438 let freed = trim_workspace_caches(&ws);
439 assert!(freed >= 10);
440 assert!(!cache_dir.exists());
441 assert!(src_file.exists());
442 }
443
444 #[test]
445 fn test_run_garbage_collection_quota_eviction() {
446 let temp = tempdir().unwrap();
447 let root = temp.path();
448
449 let main_ws = root.join("my-repo__main-12345678");
451 fs::create_dir_all(&main_ws).unwrap();
452 fs::write(main_ws.join("data.bin"), vec![0u8; 1000]).unwrap();
453
454 let feat_ws = root.join("my-repo__feat1-87654321");
456 fs::create_dir_all(&feat_ws).unwrap();
457 fs::write(feat_ws.join("data.bin"), vec![0u8; 1000]).unwrap();
458
459 let report = run_garbage_collection(root, Some(1200), None, &|_| false);
461
462 assert_eq!(report.workspaces_deleted, 1);
463 assert!(!feat_ws.exists());
464 assert!(main_ws.exists(), "Canonical workspace must be preserved");
465 }
466
467 #[test]
468 fn test_run_garbage_collection_skips_locked_workspaces() {
469 let temp = tempdir().unwrap();
470 let root = temp.path();
471
472 let feat_ws = root.join("my-repo__feat1-87654321");
473 fs::create_dir_all(&feat_ws).unwrap();
474 fs::write(feat_ws.join("data.bin"), vec![0u8; 1000]).unwrap();
475
476 let report = run_garbage_collection(root, Some(500), None, &|name| {
479 name.starts_with("my-repo__feat1")
480 });
481
482 assert_eq!(report.workspaces_deleted, 0);
483 assert!(feat_ws.exists(), "locked workspace must survive GC");
484 }
485
486 #[test]
494 fn test_gc_lock_check_matches_the_project_name_not_the_directory() {
495 let temp = tempdir().unwrap();
496 let root = temp.path();
497
498 let feat_ws = root.join("my-repo__feat1-87654321");
501 fs::create_dir_all(&feat_ws).unwrap();
502 fs::write(feat_ws.join("data.bin"), vec![0u8; 2000]).unwrap();
503
504 write_state(
506 &feat_ws,
507 &crate::state::WorkspaceState {
508 version: 1,
509 last_success_lockfile_hash: String::new(),
510 last_installed_at: SystemTime::now(),
511 template: "npm".to_string(),
512 project: "my-repo:feat1".to_string(),
513 toolchain: std::collections::BTreeMap::new(),
514 },
515 )
516 .unwrap();
517
518 let locked: std::collections::HashSet<String> =
520 ["my-repo:feat1".to_string()].into_iter().collect();
521
522 let report = run_garbage_collection(root, Some(500), None, &|name| locked.contains(name));
523
524 assert_eq!(report.workspaces_deleted, 0, "GC deleted a live workspace");
525 assert!(
526 feat_ws.exists(),
527 "a workspace with an active run was evicted by quota"
528 );
529 }
530
531 #[test]
535 fn test_gc_still_evicts_when_no_run_holds_the_lock() {
536 let temp = tempdir().unwrap();
537 let root = temp.path();
538
539 let feat_ws = root.join("my-repo__feat1-87654321");
540 fs::create_dir_all(&feat_ws).unwrap();
541 fs::write(feat_ws.join("data.bin"), vec![0u8; 2000]).unwrap();
542 write_state(
543 &feat_ws,
544 &crate::state::WorkspaceState {
545 version: 1,
546 last_success_lockfile_hash: String::new(),
547 last_installed_at: SystemTime::now(),
548 template: "npm".to_string(),
549 project: "my-repo:feat1".to_string(),
550 toolchain: std::collections::BTreeMap::new(),
551 },
552 )
553 .unwrap();
554
555 let report = run_garbage_collection(root, Some(500), None, &|_| false);
556
557 assert_eq!(report.workspaces_deleted, 1);
558 assert!(!feat_ws.exists());
559 }
560
561 #[test]
562 fn test_run_emergency_disk_gc() {
563 let temp = tempdir().unwrap();
564 let root = temp.path();
565
566 let feat_ws = root.join("my-repo__feat1-87654321");
567 fs::create_dir_all(&feat_ws).unwrap();
568 fs::write(feat_ws.join("data.bin"), vec![0u8; 2000]).unwrap();
569
570 let report = run_emergency_disk_gc(root, 1000, &|_| false);
571 assert_eq!(report.workspaces_deleted, 1);
572 assert!(!feat_ws.exists());
573 }
574
575 #[test]
576 fn test_run_emergency_disk_gc_skips_locked() {
577 let temp = tempdir().unwrap();
578 let root = temp.path();
579
580 let feat_ws = root.join("my-repo__feat1-87654321");
581 fs::create_dir_all(&feat_ws).unwrap();
582 fs::write(feat_ws.join("data.bin"), vec![0u8; 2000]).unwrap();
583
584 let report = run_emergency_disk_gc(root, 1000, &|_| true);
585 assert_eq!(report.workspaces_deleted, 0);
586 assert!(feat_ws.exists());
587 }
588}
589
590#[cfg(test)]
591mod cas_gc_tests {
592 use super::*;
593 use std::time::Duration;
594
595 fn make_object(dir: &Path, hash: &str, size: usize, age_secs: u64) -> PathBuf {
597 let path = dir.join(&hash[..2]).join(&hash[2..4]).join(hash);
598 fs::create_dir_all(path.parent().unwrap()).unwrap();
599 fs::write(&path, vec![0u8; size]).unwrap();
600 let old = SystemTime::now() - Duration::from_secs(age_secs);
601 let f = fs::File::options().write(true).open(&path).unwrap();
602 f.set_modified(old).unwrap();
603 path
604 }
605
606 #[test]
607 fn test_gc_cas_ttl_evicts_unused_objects() {
608 let dir = tempfile::tempdir().unwrap();
609 let fresh = make_object(dir.path(), "aa11fresh_object_1", 100, 0);
610 let stale = make_object(dir.path(), "bb22stale_object_2", 100, 40 * 86400);
611
612 let report = gc_cas(dir.path(), None, Some(Duration::from_secs(30 * 86400)));
613
614 assert_eq!(report.objects_deleted, 1);
615 assert_eq!(report.bytes_freed, 100);
616 assert!(fresh.is_file(), "fresh object must survive");
617 assert!(!stale.exists(), "stale object must be evicted");
618 }
619
620 #[test]
621 fn test_gc_cas_quota_evicts_lru_first() {
622 let dir = tempfile::tempdir().unwrap();
623 let old = make_object(dir.path(), "cc33old_object_1111", 400, 86400);
624 let new = make_object(dir.path(), "dd44new_object_1111", 400, 1);
625
626 let report = gc_cas(dir.path(), Some(500), None);
628
629 assert_eq!(report.objects_deleted, 1);
630 assert!(!old.exists(), "oldest object evicted under quota");
631 assert!(new.is_file(), "newest object survives under quota");
632 }
633
634 #[test]
635 fn test_gc_cas_removes_stale_tmp_files() {
636 let dir = tempfile::tempdir().unwrap();
637 let tmp = dir.path().join("ab").join("cd");
638 fs::create_dir_all(&tmp).unwrap();
639 let tmp_file = tmp.join("abcddeadbeef.tmp.12345.7");
640 fs::write(&tmp_file, vec![0u8; 50]).unwrap();
641 let old = SystemTime::now() - Duration::from_secs(7200);
642 let f = fs::File::options().write(true).open(&tmp_file).unwrap();
643 f.set_modified(old).unwrap();
644
645 let report = gc_cas(dir.path(), None, None);
646
647 assert!(!tmp_file.exists(), "stale tmp file must be cleaned");
648 assert_eq!(report.objects_deleted, 1);
649 }
650
651 #[test]
652 fn test_scan_workspaces_skips_cas_dir() {
653 let root = tempfile::tempdir().unwrap();
654 fs::create_dir_all(root.path().join("myrepo")).unwrap();
655 fs::create_dir_all(root.path().join("cas").join("objects")).unwrap();
656
657 let scanned = scan_workspaces(root.path());
658 let names: Vec<&str> = scanned.iter().map(|w| w.name.as_str()).collect();
659 assert_eq!(
660 names,
661 vec!["myrepo"],
662 "cas dir must not count as a workspace"
663 );
664 }
665}
666
667#[cfg(all(test, unix))]
670mod symlink_accounting_tests {
671 use super::*;
672 use tempfile::tempdir;
673
674 #[cfg(unix)]
682 #[test]
683 fn symlinks_are_counted_as_links_and_never_recursed_into() {
684 let dir = tempdir().unwrap();
685 let root = dir.path();
686 fs::write(root.join("real.bin"), vec![7u8; 4096]).unwrap();
687 let before = calculate_dir_size(root);
688
689 let big = tempdir().unwrap();
691 fs::write(big.path().join("payload.bin"), vec![1u8; 4 * 1024 * 1024]).unwrap();
692 std::os::unix::fs::symlink(root, root.join("self")).unwrap();
693 std::os::unix::fs::symlink(big.path(), root.join("big")).unwrap();
694
695 let after = calculate_dir_size(root);
696
697 assert!(
698 after < 64 * 1024,
699 "a 4 MiB tree behind a link was counted: {before} -> {after}"
700 );
701 assert!(
702 after >= before,
703 "the real file's own bytes must still count"
704 );
705 }
706}