1use std::path::{Path, PathBuf};
11
12use anyhow::{Context as _, Result, bail};
13
14use crate::proc::Quiet as _;
15
16pub fn enough_space(free: u64, min_free: u64) -> bool {
21 free >= min_free
22}
23
24pub fn gate(free: u64, min_free: u64) -> Option<String> {
31 if enough_space(free, min_free) {
32 None
33 } else {
34 Some(format!(
35 "not enough free space to start a run: {free} bytes free, \
36 {min_free} required by `[disk] min_free_bytes`"
37 ))
38 }
39}
40
41pub fn over_limit(size: u64, limit: u64) -> bool {
44 size > limit
45}
46
47pub fn extract_cargo_target_dir(command: &str) -> Option<PathBuf> {
61 const KEY: &str = "CARGO_TARGET_DIR=";
62 let rest = command.split_once(KEY)?.1.trim_start();
63 let value = if let Some(s) = rest.strip_prefix('\'') {
64 s.split('\'').next().unwrap_or("")
65 } else if let Some(s) = rest.strip_prefix('"') {
66 s.split('"').next().unwrap_or("")
67 } else {
68 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
69 &rest[..end]
70 };
71 if value.is_empty() {
72 None
73 } else {
74 Some(PathBuf::from(value))
75 }
76}
77
78pub fn free_bytes(path: &Path) -> Result<u64> {
84 free_bytes_by_os(path)
85}
86
87#[cfg(unix)]
89fn free_bytes_by_os(path: &Path) -> Result<u64> {
90 let out = std::process::Command::new("df")
91 .args(["-k", "-P"])
92 .arg(path)
93 .quiet()
94 .output()
95 .with_context(|| format!("run `df` for {}", path.display()))?;
96 if !out.status.success() {
97 bail!(
98 "`df` failed: {}",
99 String::from_utf8_lossy(&out.stderr).trim()
100 );
101 }
102 let text = String::from_utf8_lossy(&out.stdout);
103 text.lines()
104 .skip(1)
105 .find_map(parse_df_available)
106 .with_context(|| format!("parse `df` output for {}", path.display()))
107}
108
109#[cfg(windows)]
111fn free_bytes_by_os(path: &Path) -> Result<u64> {
112 let abs = std::path::absolute(path)
123 .with_context(|| format!("absolute path for {}", path.display()))?;
124 let root = volume_root(&abs)
125 .with_context(|| format!("no volume root in {} to measure", abs.display()))?;
126 let quoted = root.replace('\'', "''");
127 let script = format!("[System.IO.DriveInfo]::new('{quoted}').AvailableFreeSpace");
128 let out = std::process::Command::new("powershell")
129 .args(["-NoProfile", "-NonInteractive", "-Command", &script])
130 .quiet()
135 .output()
136 .with_context(|| format!("run PowerShell for {}", abs.display()))?;
137 if !out.status.success() {
138 bail!(
139 "PowerShell failed: {}",
140 String::from_utf8_lossy(&out.stderr).trim()
141 );
142 }
143 parse_u64(&String::from_utf8_lossy(&out.stdout))
144 .with_context(|| format!("parse PowerShell bytes for {}", abs.display()))
145}
146
147pub fn parse_df_available(line: &str) -> Option<u64> {
151 let mut fields = line.split_whitespace();
152 fields.next()?; fields.next()?; fields.next()?; let blocks: u64 = fields.next()?.parse().ok()?;
156 Some(blocks.saturating_mul(1024))
157}
158
159pub fn volume_root(path: &Path) -> Option<String> {
168 let text = path.to_str()?;
169 let bare = text
171 .strip_prefix(r"\\?\")
172 .or_else(|| text.strip_prefix("//?/"))
173 .unwrap_or(text);
174 let mut chars = bare.chars();
175 let letter = chars.next()?;
176 if !letter.is_ascii_alphabetic() || chars.next()? != ':' {
177 return None;
178 }
179 Some(format!(r"{letter}:\"))
180}
181
182pub fn parse_u64(text: &str) -> Option<u64> {
184 text.trim().parse().ok()
185}
186
187pub fn dir_size(path: &Path) -> u64 {
193 let Ok(meta) = std::fs::symlink_metadata(path) else {
194 return 0;
195 };
196 if meta.is_file() {
197 return meta.len();
198 }
199 if !meta.is_dir() {
200 return 0;
201 }
202 let mut total = 0u64;
203 let mut stack = vec![path.to_path_buf()];
204 while let Some(dir) = stack.pop() {
205 let Ok(rd) = std::fs::read_dir(&dir) else {
206 continue;
207 };
208 for entry in rd.flatten() {
209 let Ok(meta) = entry.metadata() else {
212 continue;
213 };
214 if meta.is_dir() {
215 stack.push(entry.path());
216 } else if meta.is_file() {
217 total += meta.len();
218 }
219 }
220 }
221 total
222}
223
224#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
226pub struct Prune {
227 pub freed: u64,
229 pub files: usize,
231 pub remaining: u64,
233}
234
235#[derive(Debug, Clone, Default, PartialEq, Eq)]
241pub struct PrunePlan {
242 pub files: Vec<(PathBuf, u64)>,
244 pub freed: u64,
246 pub remaining: u64,
249}
250
251#[must_use]
266pub fn plan_prune(dir: &Path, limit: u64) -> PrunePlan {
267 let Some(tree) = Tree::of(dir) else {
268 return PrunePlan::default();
269 };
270 let mut total = tree.total;
271 if !over_limit(total, limit) {
272 return PrunePlan {
273 files: Vec::new(),
274 freed: 0,
275 remaining: total,
276 };
277 }
278 let mut freed = 0u64;
279 let mut files = Vec::new();
280 for (_, size, path) in &tree.files {
281 if !over_limit(total, limit) {
282 break;
283 }
284 total = total.saturating_sub(*size);
285 freed += *size;
286 files.push((path.clone(), *size));
287 }
288 PrunePlan {
289 files,
290 freed,
291 remaining: total,
292 }
293}
294
295pub fn prune_dir(dir: &Path, limit: u64) -> Result<Prune> {
309 let Some(tree) = Tree::of(dir) else {
310 return Ok(Prune {
311 freed: 0,
312 files: 0,
313 remaining: 0,
314 });
315 };
316 let mut total = tree.total;
317 if !over_limit(total, limit) {
318 return Ok(Prune {
319 freed: 0,
320 files: 0,
321 remaining: total,
322 });
323 }
324 let mut freed = 0u64;
325 let mut removed = 0usize;
326 for (_, size, path) in &tree.files {
336 if !over_limit(total, limit) {
337 break;
338 }
339 if std::fs::remove_file(path).is_ok() {
340 total = total.saturating_sub(*size);
341 freed += *size;
342 removed += 1;
343 }
344 }
345 strip_empty_dirs(&tree.dirs);
346 Ok(Prune {
347 freed,
348 files: removed,
349 remaining: total,
350 })
351}
352
353const PRESERVED_AT_ROOT: &str = "CACHEDIR.TAG";
362
363struct Tree {
367 total: u64,
368 files: Vec<(u128, u64, PathBuf)>,
369 dirs: Vec<(usize, PathBuf)>,
370}
371
372impl Tree {
373 fn of(dir: &Path) -> Option<Tree> {
376 if dir.symlink_metadata().ok()?.is_dir() {
377 Some(Tree::from_dir(dir))
378 } else {
379 None
380 }
381 }
382
383 fn from_dir(dir: &Path) -> Tree {
384 let mut total = 0u64;
385 let mut files = Vec::new();
386 let mut dirs = Vec::new();
387 let mut stack: Vec<(usize, PathBuf)> = vec![(0, dir.to_path_buf())];
390 while let Some((depth, d)) = stack.pop() {
391 let Ok(rd) = std::fs::read_dir(&d) else {
392 continue;
393 };
394 for entry in rd.flatten() {
395 let Ok(meta) = entry.metadata() else {
396 continue;
397 };
398 let path = entry.path();
399 if meta.is_dir() {
400 dirs.push((depth + 1, path.clone()));
401 stack.push((depth + 1, path));
402 } else if meta.is_file() {
403 let size = meta.len();
404 total += size;
405 if depth == 0 && entry.file_name() == PRESERVED_AT_ROOT {
406 continue;
407 }
408 let mtime = meta
409 .modified()
410 .ok()
411 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
412 .map(|d| d.as_nanos())
413 .unwrap_or(0);
414 files.push((mtime, size, path));
415 }
416 }
417 }
418 files.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)).then(a.2.cmp(&b.2)));
426 Tree { total, files, dirs }
427 }
428}
429
430fn strip_empty_dirs(dirs: &[(usize, PathBuf)]) {
432 let mut by_depth: Vec<&PathBuf> = dirs.iter().map(|(_, d)| d).collect();
433 by_depth.sort_unstable_by_key(|d| std::cmp::Reverse(d.iter().count()));
434 for d in by_depth {
435 let _ = std::fs::remove_dir(d);
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use std::fs;
443
444 #[test]
445 fn the_free_space_predicate_is_the_boundary() {
446 assert!(enough_space(100, 100));
447 assert!(enough_space(101, 100));
448 assert!(!enough_space(99, 100));
449 assert!(enough_space(0, 0));
451 }
452
453 #[test]
454 fn the_gate_text_conveys_both_numbers_and_opens_with_room() {
455 assert_eq!(
456 gate(9, 10).expect("closed"),
457 "not enough free space to start a run: 9 bytes free, 10 required by `[disk] min_free_bytes`"
458 );
459 assert_eq!(gate(10, 10), None, "exactly at the floor is open");
460 assert_eq!(gate(10_000, 0), None, "a zero floor is an opt-out");
461 }
462
463 #[test]
464 fn over_limit_uses_strict_greater_than() {
465 assert!(over_limit(11, 10));
466 assert!(!over_limit(10, 10));
467 assert!(!over_limit(9, 10));
468 }
469
470 #[test]
471 fn df_row_parses_1024_blocks_into_bytes() {
472 let row = "/dev/sda1 976762584 808522388 168240196 83% /home";
473 assert_eq!(parse_df_available(row), Some(168_240_196 * 1024));
474 assert_eq!(parse_df_available("garbage"), None);
475 assert_eq!(parse_df_available("a b c x"), None);
476 }
477
478 #[test]
479 fn a_powershell_number_is_one_unsigned_integer() {
480 assert_eq!(parse_u64(" 82072211456\r\n"), Some(82_072_211_456));
481 assert_eq!(parse_u64("nah"), None);
482 }
483
484 #[test]
486 fn the_volume_root_is_a_drive_not_the_path_it_came_from() {
487 assert_eq!(
490 volume_root(Path::new(
491 r"\\?\C:\Users\yukimemi\src\github.com\yukimemi\magi"
492 )),
493 Some(r"C:\".to_owned())
494 );
495 assert_eq!(
496 volume_root(Path::new(r"C:\Users\yukimemi")),
497 Some(r"C:\".to_owned())
498 );
499 assert_eq!(volume_root(Path::new(r"D:\")), Some(r"D:\".to_owned()));
500 assert_eq!(
502 volume_root(Path::new("C:/Users/yukimemi/src")),
503 Some(r"C:\".to_owned())
504 );
505 assert_eq!(volume_root(Path::new(r"\\server\share\dir")), None);
508 assert_eq!(volume_root(Path::new(r"\\?\UNC\server\share")), None);
509 assert_eq!(volume_root(Path::new("/home/yukimemi")), None);
510 }
511
512 #[test]
513 fn the_cache_dir_is_read_back_out_of_a_rendered_command() {
514 let cmd = r"CARGO_TARGET_DIR=C:\Users\me\Temp\magi-target cargo make check";
515 assert_eq!(
516 extract_cargo_target_dir(cmd),
517 Some(PathBuf::from(r"C:\Users\me\Temp\magi-target"))
518 );
519 assert_eq!(
521 extract_cargo_target_dir(r"CARGO_TARGET_DIR='/tmp/a b' cargo test"),
522 Some(PathBuf::from("/tmp/a b"))
523 );
524 assert_eq!(
525 extract_cargo_target_dir(r#"CARGO_TARGET_DIR="/tmp/qq" cargo test"#),
526 Some(PathBuf::from("/tmp/qq"))
527 );
528 assert_eq!(extract_cargo_target_dir("cargo make check"), None);
529 assert_eq!(extract_cargo_target_dir("CARGO_TARGET_DIR="), None);
530 let two = "CARGO_TARGET_DIR=/first and CARGO_TARGET_DIR=/second cargo x";
533 assert_eq!(extract_cargo_target_dir(two), Some(PathBuf::from("/first")));
534 }
535
536 #[test]
537 fn dir_size_is_zero_for_missing_and_counts_files_without_following_links() {
538 let t = tempfile::TempDir::new().expect("temp");
539 assert_eq!(dir_size(&t.path().join("nope")), 0);
540 fs::write(t.path().join("a"), b"12345").expect("write");
541 fs::create_dir(t.path().join("sub")).expect("dir");
542 fs::write(t.path().join("sub").join("b"), b"678").expect("write");
543 assert_eq!(dir_size(t.path()), 8);
544 #[cfg(unix)]
545 {
546 std::os::unix::fs::symlink(t.path().join("sub"), t.path().join("link"))
547 .expect("symlink");
548 assert_eq!(dir_size(t.path()), 8, "a link is counted as a link");
549 }
550 }
551
552 #[test]
553 fn prune_deletes_oldest_first_until_the_cap_is_met() {
554 let t = tempfile::TempDir::new().expect("temp");
555 let old = t.path().join("old");
556 fs::write(&old, b"yyyy").expect("write");
557 std::thread::sleep(std::time::Duration::from_millis(1_200));
560 fs::write(t.path().join("new"), b"xxxxx").expect("write");
561
562 let keep = prune_dir(t.path(), 9).expect("prune");
564 assert_eq!(
565 keep,
566 Prune {
567 freed: 0,
568 files: 0,
569 remaining: 9
570 }
571 );
572
573 let pruned = prune_dir(t.path(), 6).expect("prune");
575 assert!(pruned.freed > 0);
576 assert_eq!(pruned.files, 1);
577 assert_eq!(pruned.remaining, 5);
578 assert!(!old.exists(), "the older file is the one shed");
579 assert!(t.path().join("new").exists());
580 }
581
582 #[test]
583 fn plan_prune_selects_what_prune_dir_would_delete_without_deleting_it() {
584 let t = tempfile::TempDir::new().expect("temp");
585 let old = t.path().join("old");
586 fs::write(&old, b"yyyy").expect("write");
587 std::thread::sleep(std::time::Duration::from_millis(1_200));
588 fs::write(t.path().join("new"), b"xxxxx").expect("write");
589
590 let plan = plan_prune(t.path(), 6);
591 assert_eq!(plan.files, vec![(old.clone(), 4)]);
592 assert_eq!(plan.freed, 4);
593 assert_eq!(plan.remaining, 5);
594 assert!(old.exists(), "a plan never deletes anything");
595 assert!(t.path().join("new").exists());
596
597 let pruned = prune_dir(t.path(), 6).expect("prune");
599 assert_eq!(pruned.freed, plan.freed);
600 assert_eq!(pruned.remaining, plan.remaining);
601 assert!(!old.exists());
602 }
603
604 #[test]
605 fn a_root_cachedir_tag_survives_even_as_the_oldest_file() {
606 let t = tempfile::TempDir::new().expect("temp");
609 let tag = t.path().join("CACHEDIR.TAG");
610 fs::write(&tag, b"Signature: x").expect("write");
611 let f = fs::File::options().write(true).open(&tag).expect("open");
612 f.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1))
613 .expect("mtime");
614 drop(f);
615 fs::write(t.path().join("a"), b"aaaa").expect("write");
616 fs::create_dir(t.path().join("sub")).expect("mkdir");
617 let nested = t.path().join("sub").join("CACHEDIR.TAG");
619 fs::write(&nested, b"nn").expect("write");
620
621 let limit = 12; let plan = plan_prune(t.path(), limit);
623 assert!(plan.files.iter().all(|(p, _)| p != &tag));
624 assert!(plan.remaining <= limit);
625
626 let pruned = prune_dir(t.path(), limit).expect("prune");
627 assert!(tag.exists(), "the root tag is never pruned");
628 assert!(pruned.remaining <= limit);
629 assert_eq!(pruned.freed, plan.freed);
630 assert_eq!(pruned.remaining, plan.remaining);
631 assert!(!nested.exists());
632 }
633
634 #[test]
635 fn plan_prune_is_empty_under_the_cap_and_for_a_missing_dir() {
636 let t = tempfile::TempDir::new().expect("temp");
637 fs::write(t.path().join("a"), b"12345").expect("write");
638 let plan = plan_prune(t.path(), 100);
639 assert_eq!(
640 plan,
641 PrunePlan {
642 files: Vec::new(),
643 freed: 0,
644 remaining: 5,
645 }
646 );
647
648 assert_eq!(
649 plan_prune(&t.path().join("absent"), 0),
650 PrunePlan::default()
651 );
652 }
653
654 #[cfg(windows)]
661 #[test]
662 fn prune_keeps_reaching_past_an_undeletable_file_to_still_reach_the_cap() {
663 use std::os::windows::fs::OpenOptionsExt as _;
664
665 let t = tempfile::TempDir::new().expect("temp");
666 let old = t.path().join("old");
667 fs::write(&old, b"yyyy").expect("write");
668 std::thread::sleep(std::time::Duration::from_millis(1_200));
669 let mid = t.path().join("mid");
670 fs::write(&mid, b"zzzz").expect("write");
671 std::thread::sleep(std::time::Duration::from_millis(1_200));
672 let new = t.path().join("new");
673 fs::write(&new, b"xxxxx").expect("write");
674
675 let lock = std::fs::OpenOptions::new()
679 .read(true)
680 .share_mode(0)
681 .open(&old)
682 .expect("lock the old file exclusively");
683
684 let pruned = prune_dir(t.path(), 8).expect("prune");
685 drop(lock);
686
687 assert!(old.exists(), "the locked file could not be deleted");
688 assert!(!mid.exists(), "the next-oldest file was tried and removed");
689 assert!(
690 !new.exists(),
691 "pruning kept reaching for newer files until the cap was actually met, \
692 not just until a fixed selection ran out"
693 );
694 assert!(
695 pruned.remaining <= 8,
696 "the achieved total must reach the cap: {pruned:?}"
697 );
698 }
699
700 #[test]
701 fn prune_leaves_a_missing_dir_alone() {
702 let t = tempfile::TempDir::new().expect("temp");
703 let out = prune_dir(&t.path().join("absent"), 1).expect("prune");
704 assert_eq!(out, Prune::default());
705 }
706
707 #[test]
708 fn prune_sweeps_directories_the_files_leave_empty() {
709 let t = tempfile::TempDir::new().expect("temp");
710 let deep = t.path().join("a").join("b").join("c");
711 fs::create_dir_all(&deep).expect("dirs");
712 fs::write(deep.join("f"), b"1234").expect("write");
713 let out = prune_dir(t.path(), 0).expect("prune");
714 assert_eq!(out.files, 1);
715 assert_eq!(out.remaining, 0);
716 assert!(!t.path().join("a").exists(), "empty chain swept");
717 }
718}