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 gate_in(free, min_free, "en")
32}
33
34const SHORT_EN: &str = "not enough free space to start a run:";
37const SHORT_JA: &str = "run を開始するための空き容量が不足しています:";
38pub const RUNNING_PRESSURE: &str = "disk pressure while a run was writing: ";
40pub const UNMEASURED_EN: &str = "could not measure free space on ";
42pub const UNMEASURED_JA: &str = "空き容量を測定できませんでした: ";
44
45pub fn gate_in(free: u64, min_free: u64, language: &str) -> Option<String> {
48 if enough_space(free, min_free) {
49 None
50 } else if crate::lang::is_japanese(language) {
51 Some(format!(
52 "{SHORT_JA} 空き {free} バイト、`[disk] min_free_bytes` の要求は {min_free} バイト"
53 ))
54 } else {
55 Some(format!(
56 "{SHORT_EN} {free} bytes free, \
57 {min_free} required by `[disk] min_free_bytes`"
58 ))
59 }
60}
61
62pub fn unmeasured_in(
64 path: &std::path::Path,
65 err: &dyn std::fmt::Display,
66 language: &str,
67) -> String {
68 if crate::lang::is_japanese(language) {
69 format!(
70 "{UNMEASURED_JA}{} ({err}); ディスクゲートは測定できないまま run を開始させません",
71 path.display()
72 )
73 } else {
74 format!(
75 "{UNMEASURED_EN}{} ({err}); the disk gate refuses \
76 to let a run start blind",
77 path.display()
78 )
79 }
80}
81
82pub fn is_gate_reason(reason: &str) -> bool {
85 [
86 SHORT_EN,
87 SHORT_JA,
88 UNMEASURED_EN,
89 UNMEASURED_JA,
90 RUNNING_PRESSURE,
91 ]
92 .iter()
93 .any(|p| reason.starts_with(p))
94}
95
96pub fn over_limit(size: u64, limit: u64) -> bool {
99 size > limit
100}
101
102pub fn pressure_limit(size: u64, cap: u64, min_free: u64, free: u64) -> u64 {
105 cap.min(size.saturating_sub(min_free.saturating_sub(free)))
106}
107
108pub fn extract_cargo_target_dir(command: &str) -> Option<PathBuf> {
122 const KEY: &str = "CARGO_TARGET_DIR=";
123 let rest = command.split_once(KEY)?.1.trim_start();
124 let value = if let Some(s) = rest.strip_prefix('\'') {
125 s.split('\'').next().unwrap_or("")
126 } else if let Some(s) = rest.strip_prefix('"') {
127 s.split('"').next().unwrap_or("")
128 } else {
129 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
130 &rest[..end]
131 };
132 if value.is_empty() {
133 None
134 } else {
135 Some(PathBuf::from(value))
136 }
137}
138
139pub fn free_bytes(path: &Path) -> Result<u64> {
145 free_bytes_by_os(path)
146}
147
148#[cfg(unix)]
150fn free_bytes_by_os(path: &Path) -> Result<u64> {
151 let out = std::process::Command::new("df")
152 .args(["-k", "-P"])
153 .arg(path)
154 .quiet()
155 .output()
156 .with_context(|| format!("run `df` for {}", path.display()))?;
157 if !out.status.success() {
158 bail!(
159 "`df` failed: {}",
160 String::from_utf8_lossy(&out.stderr).trim()
161 );
162 }
163 let text = String::from_utf8_lossy(&out.stdout);
164 text.lines()
165 .skip(1)
166 .find_map(parse_df_available)
167 .with_context(|| format!("parse `df` output for {}", path.display()))
168}
169
170#[cfg(windows)]
172fn free_bytes_by_os(path: &Path) -> Result<u64> {
173 let abs = std::path::absolute(path)
184 .with_context(|| format!("absolute path for {}", path.display()))?;
185 let root = volume_root(&abs)
186 .with_context(|| format!("no volume root in {} to measure", abs.display()))?;
187 let quoted = root.replace('\'', "''");
188 let script = format!("[System.IO.DriveInfo]::new('{quoted}').AvailableFreeSpace");
189 let out = std::process::Command::new("powershell")
190 .args(["-NoProfile", "-NonInteractive", "-Command", &script])
191 .quiet()
196 .output()
197 .with_context(|| format!("run PowerShell for {}", abs.display()))?;
198 if !out.status.success() {
199 bail!(
200 "PowerShell failed: {}",
201 String::from_utf8_lossy(&out.stderr).trim()
202 );
203 }
204 parse_u64(&String::from_utf8_lossy(&out.stdout))
205 .with_context(|| format!("parse PowerShell bytes for {}", abs.display()))
206}
207
208pub fn parse_df_available(line: &str) -> Option<u64> {
212 let mut fields = line.split_whitespace();
213 fields.next()?; fields.next()?; fields.next()?; let blocks: u64 = fields.next()?.parse().ok()?;
217 Some(blocks.saturating_mul(1024))
218}
219
220pub fn volume_root(path: &Path) -> Option<String> {
229 let text = path.to_str()?;
230 let bare = text
232 .strip_prefix(r"\\?\")
233 .or_else(|| text.strip_prefix("//?/"))
234 .unwrap_or(text);
235 let mut chars = bare.chars();
236 let letter = chars.next()?;
237 if !letter.is_ascii_alphabetic() || chars.next()? != ':' {
238 return None;
239 }
240 Some(format!(r"{letter}:\"))
241}
242
243pub fn parse_u64(text: &str) -> Option<u64> {
245 text.trim().parse().ok()
246}
247
248pub fn dir_size(path: &Path) -> u64 {
254 let Ok(meta) = std::fs::symlink_metadata(path) else {
255 return 0;
256 };
257 if meta.is_file() {
258 return meta.len();
259 }
260 if !meta.is_dir() {
261 return 0;
262 }
263 let mut total = 0u64;
264 let mut stack = vec![path.to_path_buf()];
265 while let Some(dir) = stack.pop() {
266 let Ok(rd) = std::fs::read_dir(&dir) else {
267 continue;
268 };
269 for entry in rd.flatten() {
270 let Ok(meta) = entry.metadata() else {
273 continue;
274 };
275 if meta.is_dir() {
276 stack.push(entry.path());
277 } else if meta.is_file() {
278 total += meta.len();
279 }
280 }
281 }
282 total
283}
284
285#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
287pub struct Prune {
288 pub freed: u64,
290 pub files: usize,
292 pub remaining: u64,
294}
295
296#[derive(Debug, Clone, Default, PartialEq, Eq)]
302pub struct PrunePlan {
303 pub files: Vec<(PathBuf, u64)>,
305 pub freed: u64,
307 pub remaining: u64,
310}
311
312#[must_use]
327pub fn plan_prune(dir: &Path, limit: u64) -> PrunePlan {
328 let Some(tree) = Tree::of(dir) else {
329 return PrunePlan::default();
330 };
331 let mut total = tree.total;
332 if !over_limit(total, limit) {
333 return PrunePlan {
334 files: Vec::new(),
335 freed: 0,
336 remaining: total,
337 };
338 }
339 let mut freed = 0u64;
340 let mut files = Vec::new();
341 for (_, size, path) in &tree.files {
342 if !over_limit(total, limit) {
343 break;
344 }
345 total = total.saturating_sub(*size);
346 freed += *size;
347 files.push((path.clone(), *size));
348 }
349 PrunePlan {
350 files,
351 freed,
352 remaining: total,
353 }
354}
355
356pub fn prune_dir(dir: &Path, limit: u64) -> Result<Prune> {
370 let Some(tree) = Tree::of(dir) else {
371 return Ok(Prune {
372 freed: 0,
373 files: 0,
374 remaining: 0,
375 });
376 };
377 let mut total = tree.total;
378 if !over_limit(total, limit) {
379 return Ok(Prune {
380 freed: 0,
381 files: 0,
382 remaining: total,
383 });
384 }
385 let mut freed = 0u64;
386 let mut removed = 0usize;
387 for (_, size, path) in &tree.files {
397 if !over_limit(total, limit) {
398 break;
399 }
400 if std::fs::remove_file(path).is_ok() {
401 total = total.saturating_sub(*size);
402 freed += *size;
403 removed += 1;
404 }
405 }
406 strip_empty_dirs(&tree.dirs);
407 Ok(Prune {
408 freed,
409 files: removed,
410 remaining: total,
411 })
412}
413
414const PRESERVED_AT_ROOT: &str = "CACHEDIR.TAG";
423
424struct Tree {
428 total: u64,
429 files: Vec<(u128, u64, PathBuf)>,
430 dirs: Vec<(usize, PathBuf)>,
431}
432
433impl Tree {
434 fn of(dir: &Path) -> Option<Tree> {
437 if dir.symlink_metadata().ok()?.is_dir() {
438 Some(Tree::from_dir(dir))
439 } else {
440 None
441 }
442 }
443
444 fn from_dir(dir: &Path) -> Tree {
445 let mut total = 0u64;
446 let mut files = Vec::new();
447 let mut dirs = Vec::new();
448 let mut stack: Vec<(usize, PathBuf)> = vec![(0, dir.to_path_buf())];
451 while let Some((depth, d)) = stack.pop() {
452 let Ok(rd) = std::fs::read_dir(&d) else {
453 continue;
454 };
455 for entry in rd.flatten() {
456 let Ok(meta) = entry.metadata() else {
457 continue;
458 };
459 let path = entry.path();
460 if meta.is_dir() {
461 dirs.push((depth + 1, path.clone()));
462 stack.push((depth + 1, path));
463 } else if meta.is_file() {
464 let size = meta.len();
465 total += size;
466 if depth == 0 && entry.file_name() == PRESERVED_AT_ROOT {
467 continue;
468 }
469 let mtime = meta
470 .modified()
471 .ok()
472 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
473 .map(|d| d.as_nanos())
474 .unwrap_or(0);
475 files.push((mtime, size, path));
476 }
477 }
478 }
479 files.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)).then(a.2.cmp(&b.2)));
487 Tree { total, files, dirs }
488 }
489}
490
491fn strip_empty_dirs(dirs: &[(usize, PathBuf)]) {
493 let mut by_depth: Vec<&PathBuf> = dirs.iter().map(|(_, d)| d).collect();
494 by_depth.sort_unstable_by_key(|d| std::cmp::Reverse(d.iter().count()));
495 for d in by_depth {
496 let _ = std::fs::remove_dir(d);
497 }
498}
499
500#[cfg(test)]
501mod tests {
502 #[test]
503 fn gate_reasons_follow_the_language_and_stay_recognisable() {
504 let en = gate_in(1, 10, "en").unwrap();
505 let ja = gate_in(1, 10, "日本語").unwrap();
506 assert_eq!(gate_in(1, 10, "fr").unwrap(), en);
507 assert_eq!(en, gate(1, 10).unwrap());
508 assert!(ja.starts_with("run を開始"), "{ja}");
509 assert!(is_gate_reason(&en) && is_gate_reason(&ja));
510 let p = std::path::Path::new("/x");
511 let m_ja = unmeasured_in(p, &"boom", "ja");
512 assert!(m_ja.contains("測定できませんでした") && is_gate_reason(&m_ja));
513 assert!(is_gate_reason(&unmeasured_in(p, &"boom", "en")));
514 assert!(!is_gate_reason("something else"));
515 }
516
517 use super::*;
518 use std::fs;
519
520 #[test]
521 fn the_free_space_predicate_is_the_boundary() {
522 assert!(enough_space(100, 100));
523 assert!(enough_space(101, 100));
524 assert!(!enough_space(99, 100));
525 assert!(enough_space(0, 0));
527 }
528
529 #[test]
530 fn the_gate_text_conveys_both_numbers_and_opens_with_room() {
531 assert_eq!(
532 gate(9, 10).expect("closed"),
533 "not enough free space to start a run: 9 bytes free, 10 required by `[disk] min_free_bytes`"
534 );
535 assert_eq!(gate(10, 10), None, "exactly at the floor is open");
536 assert_eq!(gate(10_000, 0), None, "a zero floor is an opt-out");
537 }
538
539 #[test]
540 fn over_limit_uses_strict_greater_than() {
541 assert!(over_limit(11, 10));
542 assert!(!over_limit(10, 10));
543 assert!(!over_limit(9, 10));
544 }
545
546 #[test]
547 fn df_row_parses_1024_blocks_into_bytes() {
548 let row = "/dev/sda1 976762584 808522388 168240196 83% /home";
549 assert_eq!(parse_df_available(row), Some(168_240_196 * 1024));
550 assert_eq!(parse_df_available("garbage"), None);
551 assert_eq!(parse_df_available("a b c x"), None);
552 }
553
554 #[test]
555 fn a_powershell_number_is_one_unsigned_integer() {
556 assert_eq!(parse_u64(" 82072211456\r\n"), Some(82_072_211_456));
557 assert_eq!(parse_u64("nah"), None);
558 }
559
560 #[test]
562 fn the_volume_root_is_a_drive_not_the_path_it_came_from() {
563 assert_eq!(
566 volume_root(Path::new(
567 r"\\?\C:\Users\yukimemi\src\github.com\yukimemi\magi"
568 )),
569 Some(r"C:\".to_owned())
570 );
571 assert_eq!(
572 volume_root(Path::new(r"C:\Users\yukimemi")),
573 Some(r"C:\".to_owned())
574 );
575 assert_eq!(volume_root(Path::new(r"D:\")), Some(r"D:\".to_owned()));
576 assert_eq!(
578 volume_root(Path::new("C:/Users/yukimemi/src")),
579 Some(r"C:\".to_owned())
580 );
581 assert_eq!(volume_root(Path::new(r"\\server\share\dir")), None);
584 assert_eq!(volume_root(Path::new(r"\\?\UNC\server\share")), None);
585 assert_eq!(volume_root(Path::new("/home/yukimemi")), None);
586 }
587
588 #[test]
589 fn the_cache_dir_is_read_back_out_of_a_rendered_command() {
590 let cmd = r"CARGO_TARGET_DIR=C:\Users\me\Temp\magi-target cargo make check";
591 assert_eq!(
592 extract_cargo_target_dir(cmd),
593 Some(PathBuf::from(r"C:\Users\me\Temp\magi-target"))
594 );
595 assert_eq!(
597 extract_cargo_target_dir(r"CARGO_TARGET_DIR='/tmp/a b' cargo test"),
598 Some(PathBuf::from("/tmp/a b"))
599 );
600 assert_eq!(
601 extract_cargo_target_dir(r#"CARGO_TARGET_DIR="/tmp/qq" cargo test"#),
602 Some(PathBuf::from("/tmp/qq"))
603 );
604 assert_eq!(extract_cargo_target_dir("cargo make check"), None);
605 assert_eq!(extract_cargo_target_dir("CARGO_TARGET_DIR="), None);
606 let two = "CARGO_TARGET_DIR=/first and CARGO_TARGET_DIR=/second cargo x";
609 assert_eq!(extract_cargo_target_dir(two), Some(PathBuf::from("/first")));
610 }
611
612 #[test]
613 fn dir_size_is_zero_for_missing_and_counts_files_without_following_links() {
614 let t = tempfile::TempDir::new().expect("temp");
615 assert_eq!(dir_size(&t.path().join("nope")), 0);
616 fs::write(t.path().join("a"), b"12345").expect("write");
617 fs::create_dir(t.path().join("sub")).expect("dir");
618 fs::write(t.path().join("sub").join("b"), b"678").expect("write");
619 assert_eq!(dir_size(t.path()), 8);
620 #[cfg(unix)]
621 {
622 std::os::unix::fs::symlink(t.path().join("sub"), t.path().join("link"))
623 .expect("symlink");
624 assert_eq!(dir_size(t.path()), 8, "a link is counted as a link");
625 }
626 }
627
628 #[test]
629 fn prune_deletes_oldest_first_until_the_cap_is_met() {
630 let t = tempfile::TempDir::new().expect("temp");
631 let old = t.path().join("old");
632 fs::write(&old, b"yyyy").expect("write");
633 std::thread::sleep(std::time::Duration::from_millis(1_200));
636 fs::write(t.path().join("new"), b"xxxxx").expect("write");
637
638 let keep = prune_dir(t.path(), 9).expect("prune");
640 assert_eq!(
641 keep,
642 Prune {
643 freed: 0,
644 files: 0,
645 remaining: 9
646 }
647 );
648
649 let pruned = prune_dir(t.path(), 6).expect("prune");
651 assert!(pruned.freed > 0);
652 assert_eq!(pruned.files, 1);
653 assert_eq!(pruned.remaining, 5);
654 assert!(!old.exists(), "the older file is the one shed");
655 assert!(t.path().join("new").exists());
656 }
657
658 #[test]
659 fn plan_prune_selects_what_prune_dir_would_delete_without_deleting_it() {
660 let t = tempfile::TempDir::new().expect("temp");
661 let old = t.path().join("old");
662 fs::write(&old, b"yyyy").expect("write");
663 std::thread::sleep(std::time::Duration::from_millis(1_200));
664 fs::write(t.path().join("new"), b"xxxxx").expect("write");
665
666 let plan = plan_prune(t.path(), 6);
667 assert_eq!(plan.files, vec![(old.clone(), 4)]);
668 assert_eq!(plan.freed, 4);
669 assert_eq!(plan.remaining, 5);
670 assert!(old.exists(), "a plan never deletes anything");
671 assert!(t.path().join("new").exists());
672
673 let pruned = prune_dir(t.path(), 6).expect("prune");
675 assert_eq!(pruned.freed, plan.freed);
676 assert_eq!(pruned.remaining, plan.remaining);
677 assert!(!old.exists());
678 }
679
680 #[test]
681 fn a_root_cachedir_tag_survives_even_as_the_oldest_file() {
682 let t = tempfile::TempDir::new().expect("temp");
685 let tag = t.path().join("CACHEDIR.TAG");
686 fs::write(&tag, b"Signature: x").expect("write");
687 let f = fs::File::options().write(true).open(&tag).expect("open");
688 f.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1))
689 .expect("mtime");
690 drop(f);
691 fs::write(t.path().join("a"), b"aaaa").expect("write");
692 fs::create_dir(t.path().join("sub")).expect("mkdir");
693 let nested = t.path().join("sub").join("CACHEDIR.TAG");
695 fs::write(&nested, b"nn").expect("write");
696
697 let limit = 12; let plan = plan_prune(t.path(), limit);
699 assert!(plan.files.iter().all(|(p, _)| p != &tag));
700 assert!(plan.remaining <= limit);
701
702 let pruned = prune_dir(t.path(), limit).expect("prune");
703 assert!(tag.exists(), "the root tag is never pruned");
704 assert!(pruned.remaining <= limit);
705 assert_eq!(pruned.freed, plan.freed);
706 assert_eq!(pruned.remaining, plan.remaining);
707 assert!(!nested.exists());
708 }
709
710 #[test]
711 fn plan_prune_is_empty_under_the_cap_and_for_a_missing_dir() {
712 let t = tempfile::TempDir::new().expect("temp");
713 fs::write(t.path().join("a"), b"12345").expect("write");
714 let plan = plan_prune(t.path(), 100);
715 assert_eq!(
716 plan,
717 PrunePlan {
718 files: Vec::new(),
719 freed: 0,
720 remaining: 5,
721 }
722 );
723
724 assert_eq!(
725 plan_prune(&t.path().join("absent"), 0),
726 PrunePlan::default()
727 );
728 }
729
730 #[cfg(windows)]
737 #[test]
738 fn prune_keeps_reaching_past_an_undeletable_file_to_still_reach_the_cap() {
739 use std::os::windows::fs::OpenOptionsExt as _;
740
741 let t = tempfile::TempDir::new().expect("temp");
742 let old = t.path().join("old");
743 fs::write(&old, b"yyyy").expect("write");
744 std::thread::sleep(std::time::Duration::from_millis(1_200));
745 let mid = t.path().join("mid");
746 fs::write(&mid, b"zzzz").expect("write");
747 std::thread::sleep(std::time::Duration::from_millis(1_200));
748 let new = t.path().join("new");
749 fs::write(&new, b"xxxxx").expect("write");
750
751 let lock = std::fs::OpenOptions::new()
755 .read(true)
756 .share_mode(0)
757 .open(&old)
758 .expect("lock the old file exclusively");
759
760 let pruned = prune_dir(t.path(), 8).expect("prune");
761 drop(lock);
762
763 assert!(old.exists(), "the locked file could not be deleted");
764 assert!(!mid.exists(), "the next-oldest file was tried and removed");
765 assert!(
766 !new.exists(),
767 "pruning kept reaching for newer files until the cap was actually met, \
768 not just until a fixed selection ran out"
769 );
770 assert!(
771 pruned.remaining <= 8,
772 "the achieved total must reach the cap: {pruned:?}"
773 );
774 }
775
776 #[test]
777 fn prune_leaves_a_missing_dir_alone() {
778 let t = tempfile::TempDir::new().expect("temp");
779 let out = prune_dir(&t.path().join("absent"), 1).expect("prune");
780 assert_eq!(out, Prune::default());
781 }
782
783 #[test]
784 fn prune_sweeps_directories_the_files_leave_empty() {
785 let t = tempfile::TempDir::new().expect("temp");
786 let deep = t.path().join("a").join("b").join("c");
787 fs::create_dir_all(&deep).expect("dirs");
788 fs::write(deep.join("f"), b"1234").expect("write");
789 let out = prune_dir(t.path(), 0).expect("prune");
790 assert_eq!(out.files, 1);
791 assert_eq!(out.remaining, 0);
792 assert!(!t.path().join("a").exists(), "empty chain swept");
793 }
794}
795
796#[cfg(test)]
797mod pressure_tests {
798 #[test]
799 fn pressure_reclaims_below_cap_without_underflow() {
800 assert_eq!(super::pressure_limit(100, 200, 80, 30), 50);
801 assert_eq!(super::pressure_limit(100, 200, 80, 100), 100);
802 assert_eq!(super::pressure_limit(100, 200, 800, 0), 0);
803 assert_eq!(super::pressure_limit(100, 50, 0, 0), 50);
804 }
805}