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 last_used_at: SystemTime,
30 pub size_bytes: u64,
31 pub is_canonical: bool,
32}
33
34pub fn calculate_dir_size(path: &Path) -> u64 {
36 let mut total = 0u64;
37 if let Ok(entries) = fs::read_dir(path) {
38 for entry in entries.flatten() {
39 if let Ok(meta) = entry.metadata() {
40 if meta.is_dir() {
41 total += calculate_dir_size(&entry.path());
42 } else {
43 total += meta.len();
44 }
45 }
46 }
47 }
48 total
49}
50
51pub fn touch_workspace(workspace_dir: &Path, _project_name: &str) {
53 if let Some(mut state) = read_state(workspace_dir) {
54 state.last_installed_at = SystemTime::now();
55 let _ = write_state(workspace_dir, &state);
56 }
57
58 let stamp = workspace_dir.join(".farhand-last-used");
60 let _ = fs::write(stamp, format!("{:?}", SystemTime::now()));
61}
62
63pub fn get_workspace_last_used(workspace_dir: &Path) -> SystemTime {
65 if let Ok(meta) = fs::metadata(workspace_dir.join(".farhand-last-used")) {
66 if let Ok(mtime) = meta.modified() {
67 return mtime;
68 }
69 }
70
71 if let Some(state) = read_state(workspace_dir) {
72 return state.last_installed_at;
73 }
74
75 if let Ok(meta) = fs::metadata(workspace_dir.join(".farhand-state.json")) {
76 if let Ok(mtime) = meta.modified() {
77 return mtime;
78 }
79 }
80
81 if let Ok(meta) = fs::metadata(workspace_dir) {
82 if let Ok(mtime) = meta.modified() {
83 return mtime;
84 }
85 }
86
87 SystemTime::UNIX_EPOCH
88}
89
90pub fn trim_workspace_caches(workspace_dir: &Path) -> u64 {
93 let cache_dirs = [
94 "target/debug/incremental",
95 "target/release/incremental",
96 "node_modules/.cache",
97 ".next/cache",
98 "DerivedData",
99 ".gradle/caches",
100 ];
101
102 let mut freed_bytes = 0u64;
103 for rel_dir in cache_dirs {
104 let dir = workspace_dir.join(rel_dir);
105 if dir.is_dir() {
106 let size = calculate_dir_size(&dir);
107 if fs::remove_dir_all(&dir).is_ok() {
108 freed_bytes += size;
109 info!("Trimmed cache {} (freed {} bytes)", dir.display(), size);
110 }
111 }
112 }
113
114 freed_bytes
115}
116
117pub fn scan_workspaces(workspaces_root: &Path) -> Vec<WorkspaceMetadata> {
119 let mut workspaces = Vec::new();
120
121 let entries = match fs::read_dir(workspaces_root) {
122 Ok(e) => e,
123 Err(_) => return workspaces,
124 };
125
126 for entry in entries.flatten() {
127 let path = entry.path();
128 if path.is_dir() {
129 let file_name = path
130 .file_name()
131 .and_then(|s| s.to_str())
132 .unwrap_or("")
133 .to_string();
134
135 if file_name == "cas" || file_name.starts_with('.') {
137 continue;
138 }
139
140 let last_used = get_workspace_last_used(&path);
141 let size = calculate_dir_size(&path);
142
143 let is_canonical = parse_base_project_name(&file_name).is_none()
146 || file_name.contains("__main")
147 || file_name.contains("__master")
148 || file_name.contains(":main")
149 || file_name.contains(":master");
150
151 workspaces.push(WorkspaceMetadata {
152 path,
153 name: file_name,
154 last_used_at: last_used,
155 size_bytes: size,
156 is_canonical,
157 });
158 }
159 }
160
161 workspaces
162}
163
164pub fn run_garbage_collection(
170 workspaces_root: &Path,
171 max_disk_bytes: Option<u64>,
172 ttl: Option<Duration>,
173 skip_locked: &dyn Fn(&str) -> bool,
174) -> GcReport {
175 let mut report = GcReport::default();
176 let mut workspaces = scan_workspaces(workspaces_root);
177 report.total_workspaces_scanned = workspaces.len();
178
179 let now = SystemTime::now();
180
181 if let Some(ttl_dur) = ttl {
183 workspaces.retain(|ws| {
184 if !ws.is_canonical {
185 if skip_locked(&ws.name) {
186 debug!("GC: skipping locked workspace {} (active run)", ws.name);
187 return true;
188 }
189 if let Ok(age) = now.duration_since(ws.last_used_at) {
190 if age > ttl_dur {
191 info!(
192 "TTL expired for workspace {} (age {:?} > {:?}). Purging...",
193 ws.path.display(),
194 age,
195 ttl_dur
196 );
197 let _ = fs::remove_dir_all(&ws.path);
198 report.workspaces_deleted += 1;
199 report.workspaces_deleted_bytes += ws.size_bytes;
200 return false;
201 }
202 }
203 }
204 true
205 });
206 }
207
208 let current_total: u64 = workspaces.iter().map(|w| w.size_bytes).sum();
210 let mut current_usage = current_total;
211
212 if let Some(max_bytes) = max_disk_bytes {
213 if current_usage > max_bytes {
214 workspaces.sort_by_key(|a| a.last_used_at);
216
217 for ws in &mut workspaces {
219 if current_usage <= max_bytes {
220 break;
221 }
222 if !ws.is_canonical && !skip_locked(&ws.name) {
223 let trimmed = trim_workspace_caches(&ws.path);
224 report.caches_trimmed_bytes += trimmed;
225 current_usage = current_usage.saturating_sub(trimmed);
226 ws.size_bytes = ws.size_bytes.saturating_sub(trimmed);
227 }
228 }
229
230 for ws in &workspaces {
232 if current_usage <= max_bytes {
233 break;
234 }
235 if !ws.is_canonical && !skip_locked(&ws.name) {
236 info!(
237 "Quota exceeded. Purging LRU workspace {} (size {} bytes)...",
238 ws.path.display(),
239 ws.size_bytes
240 );
241 let _ = fs::remove_dir_all(&ws.path);
242 report.workspaces_deleted += 1;
243 report.workspaces_deleted_bytes += ws.size_bytes;
244 current_usage = current_usage.saturating_sub(ws.size_bytes);
245 }
246 }
247 }
248 }
249
250 report.remaining_disk_bytes = current_usage;
251 report
252}
253
254pub fn run_emergency_disk_gc(
260 workspaces_root: &Path,
261 target_bytes_to_free: u64,
262 skip_locked: &dyn Fn(&str) -> bool,
263) -> GcReport {
264 let mut report = GcReport::default();
265 let mut workspaces = scan_workspaces(workspaces_root);
266 report.total_workspaces_scanned = workspaces.len();
267
268 workspaces.sort_by_key(|w| w.last_used_at);
270
271 let mut freed_bytes = 0u64;
272
273 for ws in &mut workspaces {
275 if freed_bytes >= target_bytes_to_free {
276 break;
277 }
278 if !ws.is_canonical && !skip_locked(&ws.name) {
279 let trimmed = trim_workspace_caches(&ws.path);
280 report.caches_trimmed_bytes += trimmed;
281 freed_bytes += trimmed;
282 ws.size_bytes = ws.size_bytes.saturating_sub(trimmed);
283 }
284 }
285
286 for ws in &workspaces {
288 if freed_bytes >= target_bytes_to_free {
289 break;
290 }
291 if !ws.is_canonical && !skip_locked(&ws.name) {
292 info!(
293 "Emergency GC: Purging LRU workspace {} (size {} bytes)...",
294 ws.path.display(),
295 ws.size_bytes
296 );
297 let _ = fs::remove_dir_all(&ws.path);
298 report.workspaces_deleted += 1;
299 report.workspaces_deleted_bytes += ws.size_bytes;
300 freed_bytes += ws.size_bytes;
301 }
302 }
303
304 report.remaining_disk_bytes = freed_bytes;
305 report
306}
307
308pub fn gc_cas(
321 cas_objects_dir: &Path,
322 max_bytes: Option<u64>,
323 ttl: Option<Duration>,
324) -> CasGcReport {
325 let mut report = CasGcReport::default();
326
327 let mut objects: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
328 for entry in walkdir::WalkDir::new(cas_objects_dir)
329 .follow_links(false)
330 .into_iter()
331 .flatten()
332 {
333 let path = entry.path();
334 if !path.is_file() {
335 continue;
336 }
337 let meta = match entry.metadata() {
338 Ok(m) => m,
339 Err(_) => continue,
340 };
341 objects.push((
342 path.to_path_buf(),
343 meta.len(),
344 meta.modified().unwrap_or(SystemTime::now()),
345 ));
346 }
347
348 let now = SystemTime::now();
349 let mut remaining: Vec<(PathBuf, u64, SystemTime)> = Vec::new();
350 let is_tmp = |p: &Path| {
351 p.file_name()
352 .and_then(|s| s.to_str())
353 .map(|s| s.contains(".tmp."))
354 .unwrap_or(false)
355 };
356
357 for (path, size, mtime) in objects {
358 let age = now.duration_since(mtime).unwrap_or_default();
359 let stale_tmp = is_tmp(&path) && age > Duration::from_secs(3600);
360 let expired = ttl.map(|t| age > t).unwrap_or(false);
361
362 if stale_tmp || expired {
363 if fs::remove_file(&path).is_ok() {
364 report.objects_deleted += 1;
365 report.bytes_freed += size;
366 } else {
367 remaining.push((path, size, mtime));
368 }
369 } else {
370 remaining.push((path, size, mtime));
371 }
372 }
373
374 if let Some(max_bytes) = max_bytes {
376 let mut total: u64 = remaining.iter().map(|(_, s, _)| *s).sum();
377 if total > max_bytes {
378 remaining.sort_by_key(|(_, _, mtime)| *mtime);
379 let mut survivors = Vec::new();
380 for (path, size, mtime) in remaining {
381 if total > max_bytes && fs::remove_file(&path).is_ok() {
382 report.objects_deleted += 1;
383 report.bytes_freed += size;
384 total = total.saturating_sub(size);
385 } else {
386 survivors.push((path, size, mtime));
387 }
388 }
389 remaining = survivors;
390 }
391 }
392
393 report.remaining_bytes = remaining.iter().map(|(_, s, _)| *s).sum();
394 report
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use tempfile::tempdir;
401
402 #[test]
403 fn test_trim_workspace_caches() {
404 let temp = tempdir().unwrap();
405 let ws = temp.path().join("my-ws");
406
407 let cache_dir = ws.join("target/debug/incremental");
408 fs::create_dir_all(&cache_dir).unwrap();
409 fs::write(cache_dir.join("cache.dat"), "1234567890").unwrap();
410
411 let src_file = ws.join("src/main.rs");
412 fs::create_dir_all(ws.join("src")).unwrap();
413 fs::write(&src_file, "fn main() {}").unwrap();
414
415 let freed = trim_workspace_caches(&ws);
416 assert!(freed >= 10);
417 assert!(!cache_dir.exists());
418 assert!(src_file.exists());
419 }
420
421 #[test]
422 fn test_run_garbage_collection_quota_eviction() {
423 let temp = tempdir().unwrap();
424 let root = temp.path();
425
426 let main_ws = root.join("my-repo__main-12345678");
428 fs::create_dir_all(&main_ws).unwrap();
429 fs::write(main_ws.join("data.bin"), vec![0u8; 1000]).unwrap();
430
431 let feat_ws = root.join("my-repo__feat1-87654321");
433 fs::create_dir_all(&feat_ws).unwrap();
434 fs::write(feat_ws.join("data.bin"), vec![0u8; 1000]).unwrap();
435
436 let report = run_garbage_collection(root, Some(1200), None, &|_| false);
438
439 assert_eq!(report.workspaces_deleted, 1);
440 assert!(!feat_ws.exists());
441 assert!(main_ws.exists(), "Canonical workspace must be preserved");
442 }
443
444 #[test]
445 fn test_run_garbage_collection_skips_locked_workspaces() {
446 let temp = tempdir().unwrap();
447 let root = temp.path();
448
449 let feat_ws = root.join("my-repo__feat1-87654321");
450 fs::create_dir_all(&feat_ws).unwrap();
451 fs::write(feat_ws.join("data.bin"), vec![0u8; 1000]).unwrap();
452
453 let report = run_garbage_collection(root, Some(500), None, &|name| {
456 name.starts_with("my-repo__feat1")
457 });
458
459 assert_eq!(report.workspaces_deleted, 0);
460 assert!(feat_ws.exists(), "locked workspace must survive GC");
461 }
462
463 #[test]
464 fn test_run_emergency_disk_gc() {
465 let temp = tempdir().unwrap();
466 let root = temp.path();
467
468 let feat_ws = root.join("my-repo__feat1-87654321");
469 fs::create_dir_all(&feat_ws).unwrap();
470 fs::write(feat_ws.join("data.bin"), vec![0u8; 2000]).unwrap();
471
472 let report = run_emergency_disk_gc(root, 1000, &|_| false);
473 assert_eq!(report.workspaces_deleted, 1);
474 assert!(!feat_ws.exists());
475 }
476
477 #[test]
478 fn test_run_emergency_disk_gc_skips_locked() {
479 let temp = tempdir().unwrap();
480 let root = temp.path();
481
482 let feat_ws = root.join("my-repo__feat1-87654321");
483 fs::create_dir_all(&feat_ws).unwrap();
484 fs::write(feat_ws.join("data.bin"), vec![0u8; 2000]).unwrap();
485
486 let report = run_emergency_disk_gc(root, 1000, &|_| true);
487 assert_eq!(report.workspaces_deleted, 0);
488 assert!(feat_ws.exists());
489 }
490}
491
492#[cfg(test)]
493mod cas_gc_tests {
494 use super::*;
495 use std::time::Duration;
496
497 fn make_object(dir: &Path, hash: &str, size: usize, age_secs: u64) -> PathBuf {
499 let path = dir.join(&hash[..2]).join(&hash[2..4]).join(hash);
500 fs::create_dir_all(path.parent().unwrap()).unwrap();
501 fs::write(&path, vec![0u8; size]).unwrap();
502 let old = SystemTime::now() - Duration::from_secs(age_secs);
503 let f = fs::File::options().write(true).open(&path).unwrap();
504 f.set_modified(old).unwrap();
505 path
506 }
507
508 #[test]
509 fn test_gc_cas_ttl_evicts_unused_objects() {
510 let dir = tempfile::tempdir().unwrap();
511 let fresh = make_object(dir.path(), "aa11fresh_object_1", 100, 0);
512 let stale = make_object(dir.path(), "bb22stale_object_2", 100, 40 * 86400);
513
514 let report = gc_cas(dir.path(), None, Some(Duration::from_secs(30 * 86400)));
515
516 assert_eq!(report.objects_deleted, 1);
517 assert_eq!(report.bytes_freed, 100);
518 assert!(fresh.is_file(), "fresh object must survive");
519 assert!(!stale.exists(), "stale object must be evicted");
520 }
521
522 #[test]
523 fn test_gc_cas_quota_evicts_lru_first() {
524 let dir = tempfile::tempdir().unwrap();
525 let old = make_object(dir.path(), "cc33old_object_1111", 400, 86400);
526 let new = make_object(dir.path(), "dd44new_object_1111", 400, 1);
527
528 let report = gc_cas(dir.path(), Some(500), None);
530
531 assert_eq!(report.objects_deleted, 1);
532 assert!(!old.exists(), "oldest object evicted under quota");
533 assert!(new.is_file(), "newest object survives under quota");
534 }
535
536 #[test]
537 fn test_gc_cas_removes_stale_tmp_files() {
538 let dir = tempfile::tempdir().unwrap();
539 let tmp = dir.path().join("ab").join("cd");
540 fs::create_dir_all(&tmp).unwrap();
541 let tmp_file = tmp.join("abcddeadbeef.tmp.12345.7");
542 fs::write(&tmp_file, vec![0u8; 50]).unwrap();
543 let old = SystemTime::now() - Duration::from_secs(7200);
544 let f = fs::File::options().write(true).open(&tmp_file).unwrap();
545 f.set_modified(old).unwrap();
546
547 let report = gc_cas(dir.path(), None, None);
548
549 assert!(!tmp_file.exists(), "stale tmp file must be cleaned");
550 assert_eq!(report.objects_deleted, 1);
551 }
552
553 #[test]
554 fn test_scan_workspaces_skips_cas_dir() {
555 let root = tempfile::tempdir().unwrap();
556 fs::create_dir_all(root.path().join("myrepo")).unwrap();
557 fs::create_dir_all(root.path().join("cas").join("objects")).unwrap();
558
559 let scanned = scan_workspaces(root.path());
560 let names: Vec<&str> = scanned.iter().map(|w| w.name.as_str()).collect();
561 assert_eq!(
562 names,
563 vec!["myrepo"],
564 "cas dir must not count as a workspace"
565 );
566 }
567}