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 UNMEASURED_EN: &str = "could not measure free space on ";
40pub const UNMEASURED_JA: &str = "空き容量を測定できませんでした: ";
42
43pub fn gate_in(free: u64, min_free: u64, language: &str) -> Option<String> {
46 if enough_space(free, min_free) {
47 None
48 } else if crate::lang::is_japanese(language) {
49 Some(format!(
50 "{SHORT_JA} 空き {free} バイト、`[disk] min_free_bytes` の要求は {min_free} バイト"
51 ))
52 } else {
53 Some(format!(
54 "{SHORT_EN} {free} bytes free, \
55 {min_free} required by `[disk] min_free_bytes`"
56 ))
57 }
58}
59
60pub fn unmeasured_in(
62 path: &std::path::Path,
63 err: &dyn std::fmt::Display,
64 language: &str,
65) -> String {
66 if crate::lang::is_japanese(language) {
67 format!(
68 "{UNMEASURED_JA}{} ({err}); ディスクゲートは測定できないまま run を開始させません",
69 path.display()
70 )
71 } else {
72 format!(
73 "{UNMEASURED_EN}{} ({err}); the disk gate refuses \
74 to let a run start blind",
75 path.display()
76 )
77 }
78}
79
80pub fn is_gate_reason(reason: &str) -> bool {
83 [SHORT_EN, SHORT_JA, UNMEASURED_EN, UNMEASURED_JA]
84 .iter()
85 .any(|p| reason.starts_with(p))
86}
87
88pub fn over_limit(size: u64, limit: u64) -> bool {
91 size > limit
92}
93
94pub fn extract_cargo_target_dir(command: &str) -> Option<PathBuf> {
108 const KEY: &str = "CARGO_TARGET_DIR=";
109 let rest = command.split_once(KEY)?.1.trim_start();
110 let value = if let Some(s) = rest.strip_prefix('\'') {
111 s.split('\'').next().unwrap_or("")
112 } else if let Some(s) = rest.strip_prefix('"') {
113 s.split('"').next().unwrap_or("")
114 } else {
115 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
116 &rest[..end]
117 };
118 if value.is_empty() {
119 None
120 } else {
121 Some(PathBuf::from(value))
122 }
123}
124
125pub fn free_bytes(path: &Path) -> Result<u64> {
131 free_bytes_by_os(path)
132}
133
134#[cfg(unix)]
136fn free_bytes_by_os(path: &Path) -> Result<u64> {
137 let out = std::process::Command::new("df")
138 .args(["-k", "-P"])
139 .arg(path)
140 .quiet()
141 .output()
142 .with_context(|| format!("run `df` for {}", path.display()))?;
143 if !out.status.success() {
144 bail!(
145 "`df` failed: {}",
146 String::from_utf8_lossy(&out.stderr).trim()
147 );
148 }
149 let text = String::from_utf8_lossy(&out.stdout);
150 text.lines()
151 .skip(1)
152 .find_map(parse_df_available)
153 .with_context(|| format!("parse `df` output for {}", path.display()))
154}
155
156#[cfg(windows)]
158fn free_bytes_by_os(path: &Path) -> Result<u64> {
159 let abs = std::path::absolute(path)
170 .with_context(|| format!("absolute path for {}", path.display()))?;
171 let root = volume_root(&abs)
172 .with_context(|| format!("no volume root in {} to measure", abs.display()))?;
173 let quoted = root.replace('\'', "''");
174 let script = format!("[System.IO.DriveInfo]::new('{quoted}').AvailableFreeSpace");
175 let out = std::process::Command::new("powershell")
176 .args(["-NoProfile", "-NonInteractive", "-Command", &script])
177 .quiet()
182 .output()
183 .with_context(|| format!("run PowerShell for {}", abs.display()))?;
184 if !out.status.success() {
185 bail!(
186 "PowerShell failed: {}",
187 String::from_utf8_lossy(&out.stderr).trim()
188 );
189 }
190 parse_u64(&String::from_utf8_lossy(&out.stdout))
191 .with_context(|| format!("parse PowerShell bytes for {}", abs.display()))
192}
193
194pub fn parse_df_available(line: &str) -> Option<u64> {
198 let mut fields = line.split_whitespace();
199 fields.next()?; fields.next()?; fields.next()?; let blocks: u64 = fields.next()?.parse().ok()?;
203 Some(blocks.saturating_mul(1024))
204}
205
206pub fn volume_root(path: &Path) -> Option<String> {
215 let text = path.to_str()?;
216 let bare = text
218 .strip_prefix(r"\\?\")
219 .or_else(|| text.strip_prefix("//?/"))
220 .unwrap_or(text);
221 let mut chars = bare.chars();
222 let letter = chars.next()?;
223 if !letter.is_ascii_alphabetic() || chars.next()? != ':' {
224 return None;
225 }
226 Some(format!(r"{letter}:\"))
227}
228
229pub fn parse_u64(text: &str) -> Option<u64> {
231 text.trim().parse().ok()
232}
233
234pub fn dir_size(path: &Path) -> u64 {
240 let Ok(meta) = std::fs::symlink_metadata(path) else {
241 return 0;
242 };
243 if meta.is_file() {
244 return meta.len();
245 }
246 if !meta.is_dir() {
247 return 0;
248 }
249 let mut total = 0u64;
250 let mut stack = vec![path.to_path_buf()];
251 while let Some(dir) = stack.pop() {
252 let Ok(rd) = std::fs::read_dir(&dir) else {
253 continue;
254 };
255 for entry in rd.flatten() {
256 let Ok(meta) = entry.metadata() else {
259 continue;
260 };
261 if meta.is_dir() {
262 stack.push(entry.path());
263 } else if meta.is_file() {
264 total += meta.len();
265 }
266 }
267 }
268 total
269}
270
271#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
273pub struct Prune {
274 pub freed: u64,
276 pub files: usize,
278 pub remaining: u64,
280}
281
282#[derive(Debug, Clone, Default, PartialEq, Eq)]
288pub struct PrunePlan {
289 pub files: Vec<(PathBuf, u64)>,
291 pub freed: u64,
293 pub remaining: u64,
296}
297
298#[must_use]
313pub fn plan_prune(dir: &Path, limit: u64) -> PrunePlan {
314 let Some(tree) = Tree::of(dir) else {
315 return PrunePlan::default();
316 };
317 let mut total = tree.total;
318 if !over_limit(total, limit) {
319 return PrunePlan {
320 files: Vec::new(),
321 freed: 0,
322 remaining: total,
323 };
324 }
325 let mut freed = 0u64;
326 let mut files = Vec::new();
327 for (_, size, path) in &tree.files {
328 if !over_limit(total, limit) {
329 break;
330 }
331 total = total.saturating_sub(*size);
332 freed += *size;
333 files.push((path.clone(), *size));
334 }
335 PrunePlan {
336 files,
337 freed,
338 remaining: total,
339 }
340}
341
342pub fn prune_dir(dir: &Path, limit: u64) -> Result<Prune> {
356 let Some(tree) = Tree::of(dir) else {
357 return Ok(Prune {
358 freed: 0,
359 files: 0,
360 remaining: 0,
361 });
362 };
363 let mut total = tree.total;
364 if !over_limit(total, limit) {
365 return Ok(Prune {
366 freed: 0,
367 files: 0,
368 remaining: total,
369 });
370 }
371 let mut freed = 0u64;
372 let mut removed = 0usize;
373 for (_, size, path) in &tree.files {
383 if !over_limit(total, limit) {
384 break;
385 }
386 if std::fs::remove_file(path).is_ok() {
387 total = total.saturating_sub(*size);
388 freed += *size;
389 removed += 1;
390 }
391 }
392 strip_empty_dirs(&tree.dirs);
393 Ok(Prune {
394 freed,
395 files: removed,
396 remaining: total,
397 })
398}
399
400const PRESERVED_AT_ROOT: &str = "CACHEDIR.TAG";
409
410struct Tree {
414 total: u64,
415 files: Vec<(u128, u64, PathBuf)>,
416 dirs: Vec<(usize, PathBuf)>,
417}
418
419impl Tree {
420 fn of(dir: &Path) -> Option<Tree> {
423 if dir.symlink_metadata().ok()?.is_dir() {
424 Some(Tree::from_dir(dir))
425 } else {
426 None
427 }
428 }
429
430 fn from_dir(dir: &Path) -> Tree {
431 let mut total = 0u64;
432 let mut files = Vec::new();
433 let mut dirs = Vec::new();
434 let mut stack: Vec<(usize, PathBuf)> = vec![(0, dir.to_path_buf())];
437 while let Some((depth, d)) = stack.pop() {
438 let Ok(rd) = std::fs::read_dir(&d) else {
439 continue;
440 };
441 for entry in rd.flatten() {
442 let Ok(meta) = entry.metadata() else {
443 continue;
444 };
445 let path = entry.path();
446 if meta.is_dir() {
447 dirs.push((depth + 1, path.clone()));
448 stack.push((depth + 1, path));
449 } else if meta.is_file() {
450 let size = meta.len();
451 total += size;
452 if depth == 0 && entry.file_name() == PRESERVED_AT_ROOT {
453 continue;
454 }
455 let mtime = meta
456 .modified()
457 .ok()
458 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
459 .map(|d| d.as_nanos())
460 .unwrap_or(0);
461 files.push((mtime, size, path));
462 }
463 }
464 }
465 files.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)).then(a.2.cmp(&b.2)));
473 Tree { total, files, dirs }
474 }
475}
476
477fn strip_empty_dirs(dirs: &[(usize, PathBuf)]) {
479 let mut by_depth: Vec<&PathBuf> = dirs.iter().map(|(_, d)| d).collect();
480 by_depth.sort_unstable_by_key(|d| std::cmp::Reverse(d.iter().count()));
481 for d in by_depth {
482 let _ = std::fs::remove_dir(d);
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 #[test]
489 fn gate_reasons_follow_the_language_and_stay_recognisable() {
490 let en = gate_in(1, 10, "en").unwrap();
491 let ja = gate_in(1, 10, "日本語").unwrap();
492 assert_eq!(gate_in(1, 10, "fr").unwrap(), en);
493 assert_eq!(en, gate(1, 10).unwrap());
494 assert!(ja.starts_with("run を開始"), "{ja}");
495 assert!(is_gate_reason(&en) && is_gate_reason(&ja));
496 let p = std::path::Path::new("/x");
497 let m_ja = unmeasured_in(p, &"boom", "ja");
498 assert!(m_ja.contains("測定できませんでした") && is_gate_reason(&m_ja));
499 assert!(is_gate_reason(&unmeasured_in(p, &"boom", "en")));
500 assert!(!is_gate_reason("something else"));
501 }
502
503 use super::*;
504 use std::fs;
505
506 #[test]
507 fn the_free_space_predicate_is_the_boundary() {
508 assert!(enough_space(100, 100));
509 assert!(enough_space(101, 100));
510 assert!(!enough_space(99, 100));
511 assert!(enough_space(0, 0));
513 }
514
515 #[test]
516 fn the_gate_text_conveys_both_numbers_and_opens_with_room() {
517 assert_eq!(
518 gate(9, 10).expect("closed"),
519 "not enough free space to start a run: 9 bytes free, 10 required by `[disk] min_free_bytes`"
520 );
521 assert_eq!(gate(10, 10), None, "exactly at the floor is open");
522 assert_eq!(gate(10_000, 0), None, "a zero floor is an opt-out");
523 }
524
525 #[test]
526 fn over_limit_uses_strict_greater_than() {
527 assert!(over_limit(11, 10));
528 assert!(!over_limit(10, 10));
529 assert!(!over_limit(9, 10));
530 }
531
532 #[test]
533 fn df_row_parses_1024_blocks_into_bytes() {
534 let row = "/dev/sda1 976762584 808522388 168240196 83% /home";
535 assert_eq!(parse_df_available(row), Some(168_240_196 * 1024));
536 assert_eq!(parse_df_available("garbage"), None);
537 assert_eq!(parse_df_available("a b c x"), None);
538 }
539
540 #[test]
541 fn a_powershell_number_is_one_unsigned_integer() {
542 assert_eq!(parse_u64(" 82072211456\r\n"), Some(82_072_211_456));
543 assert_eq!(parse_u64("nah"), None);
544 }
545
546 #[test]
548 fn the_volume_root_is_a_drive_not_the_path_it_came_from() {
549 assert_eq!(
552 volume_root(Path::new(
553 r"\\?\C:\Users\yukimemi\src\github.com\yukimemi\magi"
554 )),
555 Some(r"C:\".to_owned())
556 );
557 assert_eq!(
558 volume_root(Path::new(r"C:\Users\yukimemi")),
559 Some(r"C:\".to_owned())
560 );
561 assert_eq!(volume_root(Path::new(r"D:\")), Some(r"D:\".to_owned()));
562 assert_eq!(
564 volume_root(Path::new("C:/Users/yukimemi/src")),
565 Some(r"C:\".to_owned())
566 );
567 assert_eq!(volume_root(Path::new(r"\\server\share\dir")), None);
570 assert_eq!(volume_root(Path::new(r"\\?\UNC\server\share")), None);
571 assert_eq!(volume_root(Path::new("/home/yukimemi")), None);
572 }
573
574 #[test]
575 fn the_cache_dir_is_read_back_out_of_a_rendered_command() {
576 let cmd = r"CARGO_TARGET_DIR=C:\Users\me\Temp\magi-target cargo make check";
577 assert_eq!(
578 extract_cargo_target_dir(cmd),
579 Some(PathBuf::from(r"C:\Users\me\Temp\magi-target"))
580 );
581 assert_eq!(
583 extract_cargo_target_dir(r"CARGO_TARGET_DIR='/tmp/a b' cargo test"),
584 Some(PathBuf::from("/tmp/a b"))
585 );
586 assert_eq!(
587 extract_cargo_target_dir(r#"CARGO_TARGET_DIR="/tmp/qq" cargo test"#),
588 Some(PathBuf::from("/tmp/qq"))
589 );
590 assert_eq!(extract_cargo_target_dir("cargo make check"), None);
591 assert_eq!(extract_cargo_target_dir("CARGO_TARGET_DIR="), None);
592 let two = "CARGO_TARGET_DIR=/first and CARGO_TARGET_DIR=/second cargo x";
595 assert_eq!(extract_cargo_target_dir(two), Some(PathBuf::from("/first")));
596 }
597
598 #[test]
599 fn dir_size_is_zero_for_missing_and_counts_files_without_following_links() {
600 let t = tempfile::TempDir::new().expect("temp");
601 assert_eq!(dir_size(&t.path().join("nope")), 0);
602 fs::write(t.path().join("a"), b"12345").expect("write");
603 fs::create_dir(t.path().join("sub")).expect("dir");
604 fs::write(t.path().join("sub").join("b"), b"678").expect("write");
605 assert_eq!(dir_size(t.path()), 8);
606 #[cfg(unix)]
607 {
608 std::os::unix::fs::symlink(t.path().join("sub"), t.path().join("link"))
609 .expect("symlink");
610 assert_eq!(dir_size(t.path()), 8, "a link is counted as a link");
611 }
612 }
613
614 #[test]
615 fn prune_deletes_oldest_first_until_the_cap_is_met() {
616 let t = tempfile::TempDir::new().expect("temp");
617 let old = t.path().join("old");
618 fs::write(&old, b"yyyy").expect("write");
619 std::thread::sleep(std::time::Duration::from_millis(1_200));
622 fs::write(t.path().join("new"), b"xxxxx").expect("write");
623
624 let keep = prune_dir(t.path(), 9).expect("prune");
626 assert_eq!(
627 keep,
628 Prune {
629 freed: 0,
630 files: 0,
631 remaining: 9
632 }
633 );
634
635 let pruned = prune_dir(t.path(), 6).expect("prune");
637 assert!(pruned.freed > 0);
638 assert_eq!(pruned.files, 1);
639 assert_eq!(pruned.remaining, 5);
640 assert!(!old.exists(), "the older file is the one shed");
641 assert!(t.path().join("new").exists());
642 }
643
644 #[test]
645 fn plan_prune_selects_what_prune_dir_would_delete_without_deleting_it() {
646 let t = tempfile::TempDir::new().expect("temp");
647 let old = t.path().join("old");
648 fs::write(&old, b"yyyy").expect("write");
649 std::thread::sleep(std::time::Duration::from_millis(1_200));
650 fs::write(t.path().join("new"), b"xxxxx").expect("write");
651
652 let plan = plan_prune(t.path(), 6);
653 assert_eq!(plan.files, vec![(old.clone(), 4)]);
654 assert_eq!(plan.freed, 4);
655 assert_eq!(plan.remaining, 5);
656 assert!(old.exists(), "a plan never deletes anything");
657 assert!(t.path().join("new").exists());
658
659 let pruned = prune_dir(t.path(), 6).expect("prune");
661 assert_eq!(pruned.freed, plan.freed);
662 assert_eq!(pruned.remaining, plan.remaining);
663 assert!(!old.exists());
664 }
665
666 #[test]
667 fn a_root_cachedir_tag_survives_even_as_the_oldest_file() {
668 let t = tempfile::TempDir::new().expect("temp");
671 let tag = t.path().join("CACHEDIR.TAG");
672 fs::write(&tag, b"Signature: x").expect("write");
673 let f = fs::File::options().write(true).open(&tag).expect("open");
674 f.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1))
675 .expect("mtime");
676 drop(f);
677 fs::write(t.path().join("a"), b"aaaa").expect("write");
678 fs::create_dir(t.path().join("sub")).expect("mkdir");
679 let nested = t.path().join("sub").join("CACHEDIR.TAG");
681 fs::write(&nested, b"nn").expect("write");
682
683 let limit = 12; let plan = plan_prune(t.path(), limit);
685 assert!(plan.files.iter().all(|(p, _)| p != &tag));
686 assert!(plan.remaining <= limit);
687
688 let pruned = prune_dir(t.path(), limit).expect("prune");
689 assert!(tag.exists(), "the root tag is never pruned");
690 assert!(pruned.remaining <= limit);
691 assert_eq!(pruned.freed, plan.freed);
692 assert_eq!(pruned.remaining, plan.remaining);
693 assert!(!nested.exists());
694 }
695
696 #[test]
697 fn plan_prune_is_empty_under_the_cap_and_for_a_missing_dir() {
698 let t = tempfile::TempDir::new().expect("temp");
699 fs::write(t.path().join("a"), b"12345").expect("write");
700 let plan = plan_prune(t.path(), 100);
701 assert_eq!(
702 plan,
703 PrunePlan {
704 files: Vec::new(),
705 freed: 0,
706 remaining: 5,
707 }
708 );
709
710 assert_eq!(
711 plan_prune(&t.path().join("absent"), 0),
712 PrunePlan::default()
713 );
714 }
715
716 #[cfg(windows)]
723 #[test]
724 fn prune_keeps_reaching_past_an_undeletable_file_to_still_reach_the_cap() {
725 use std::os::windows::fs::OpenOptionsExt as _;
726
727 let t = tempfile::TempDir::new().expect("temp");
728 let old = t.path().join("old");
729 fs::write(&old, b"yyyy").expect("write");
730 std::thread::sleep(std::time::Duration::from_millis(1_200));
731 let mid = t.path().join("mid");
732 fs::write(&mid, b"zzzz").expect("write");
733 std::thread::sleep(std::time::Duration::from_millis(1_200));
734 let new = t.path().join("new");
735 fs::write(&new, b"xxxxx").expect("write");
736
737 let lock = std::fs::OpenOptions::new()
741 .read(true)
742 .share_mode(0)
743 .open(&old)
744 .expect("lock the old file exclusively");
745
746 let pruned = prune_dir(t.path(), 8).expect("prune");
747 drop(lock);
748
749 assert!(old.exists(), "the locked file could not be deleted");
750 assert!(!mid.exists(), "the next-oldest file was tried and removed");
751 assert!(
752 !new.exists(),
753 "pruning kept reaching for newer files until the cap was actually met, \
754 not just until a fixed selection ran out"
755 );
756 assert!(
757 pruned.remaining <= 8,
758 "the achieved total must reach the cap: {pruned:?}"
759 );
760 }
761
762 #[test]
763 fn prune_leaves_a_missing_dir_alone() {
764 let t = tempfile::TempDir::new().expect("temp");
765 let out = prune_dir(&t.path().join("absent"), 1).expect("prune");
766 assert_eq!(out, Prune::default());
767 }
768
769 #[test]
770 fn prune_sweeps_directories_the_files_leave_empty() {
771 let t = tempfile::TempDir::new().expect("temp");
772 let deep = t.path().join("a").join("b").join("c");
773 fs::create_dir_all(&deep).expect("dirs");
774 fs::write(deep.join("f"), b"1234").expect("write");
775 let out = prune_dir(t.path(), 0).expect("prune");
776 assert_eq!(out.files, 1);
777 assert_eq!(out.remaining, 0);
778 assert!(!t.path().join("a").exists(), "empty chain swept");
779 }
780}