1use std::path::{Path, PathBuf};
11
12use anyhow::{Context as _, Result, bail};
13
14pub fn enough_space(free: u64, min_free: u64) -> bool {
19 free >= min_free
20}
21
22pub fn gate(free: u64, min_free: u64) -> Option<String> {
29 if enough_space(free, min_free) {
30 None
31 } else {
32 Some(format!(
33 "not enough free space to start a run: {free} bytes free, \
34 {min_free} required by `[disk] min_free_bytes`"
35 ))
36 }
37}
38
39pub fn over_limit(size: u64, limit: u64) -> bool {
42 size > limit
43}
44
45pub fn extract_cargo_target_dir(command: &str) -> Option<PathBuf> {
59 const KEY: &str = "CARGO_TARGET_DIR=";
60 let rest = command.split_once(KEY)?.1.trim_start();
61 let value = if let Some(s) = rest.strip_prefix('\'') {
62 s.split('\'').next().unwrap_or("")
63 } else if let Some(s) = rest.strip_prefix('"') {
64 s.split('"').next().unwrap_or("")
65 } else {
66 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
67 &rest[..end]
68 };
69 if value.is_empty() {
70 None
71 } else {
72 Some(PathBuf::from(value))
73 }
74}
75
76pub fn free_bytes(path: &Path) -> Result<u64> {
82 free_bytes_by_os(path)
83}
84
85#[cfg(unix)]
87fn free_bytes_by_os(path: &Path) -> Result<u64> {
88 let out = std::process::Command::new("df")
89 .args(["-k", "-P"])
90 .arg(path)
91 .output()
92 .with_context(|| format!("run `df` for {}", path.display()))?;
93 if !out.status.success() {
94 bail!(
95 "`df` failed: {}",
96 String::from_utf8_lossy(&out.stderr).trim()
97 );
98 }
99 let text = String::from_utf8_lossy(&out.stdout);
100 text.lines()
101 .skip(1)
102 .find_map(parse_df_available)
103 .with_context(|| format!("parse `df` output for {}", path.display()))
104}
105
106#[cfg(windows)]
108fn free_bytes_by_os(path: &Path) -> Result<u64> {
109 let abs = std::path::absolute(path)
114 .with_context(|| format!("absolute path for {}", path.display()))?;
115 let quoted = abs.to_string_lossy().replace('\'', "''");
116 let script = format!("[System.IO.DriveInfo]::new('{quoted}').AvailableFreeSpace");
117 let out = std::process::Command::new("powershell")
118 .args(["-NoProfile", "-NonInteractive", "-Command", &script])
119 .output()
120 .with_context(|| format!("run PowerShell for {}", abs.display()))?;
121 if !out.status.success() {
122 bail!(
123 "PowerShell failed: {}",
124 String::from_utf8_lossy(&out.stderr).trim()
125 );
126 }
127 parse_u64(&String::from_utf8_lossy(&out.stdout))
128 .with_context(|| format!("parse PowerShell bytes for {}", abs.display()))
129}
130
131pub fn parse_df_available(line: &str) -> Option<u64> {
135 let mut fields = line.split_whitespace();
136 fields.next()?; fields.next()?; fields.next()?; let blocks: u64 = fields.next()?.parse().ok()?;
140 Some(blocks.saturating_mul(1024))
141}
142
143pub fn parse_u64(text: &str) -> Option<u64> {
145 text.trim().parse().ok()
146}
147
148pub fn dir_size(path: &Path) -> u64 {
154 let Ok(meta) = std::fs::symlink_metadata(path) else {
155 return 0;
156 };
157 if meta.is_file() {
158 return meta.len();
159 }
160 if !meta.is_dir() {
161 return 0;
162 }
163 let mut total = 0u64;
164 let mut stack = vec![path.to_path_buf()];
165 while let Some(dir) = stack.pop() {
166 let Ok(rd) = std::fs::read_dir(&dir) else {
167 continue;
168 };
169 for entry in rd.flatten() {
170 let Ok(meta) = entry.metadata() else {
173 continue;
174 };
175 if meta.is_dir() {
176 stack.push(entry.path());
177 } else if meta.is_file() {
178 total += meta.len();
179 }
180 }
181 }
182 total
183}
184
185#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
187pub struct Prune {
188 pub freed: u64,
190 pub files: usize,
192 pub remaining: u64,
194}
195
196pub fn prune_dir(dir: &Path, limit: u64) -> Result<Prune> {
210 let Some(tree) = Tree::of(dir) else {
211 return Ok(Prune {
212 freed: 0,
213 files: 0,
214 remaining: 0,
215 });
216 };
217 let mut total = tree.total;
218 if !over_limit(total, limit) {
219 return Ok(Prune {
220 freed: 0,
221 files: 0,
222 remaining: total,
223 });
224 }
225 let mut freed = 0u64;
226 let mut removed = 0usize;
227 for (_, size, path) in tree.files {
228 if !over_limit(total, limit) {
229 break;
230 }
231 if std::fs::remove_file(&path).is_ok() {
234 total = total.saturating_sub(size);
235 freed += size;
236 removed += 1;
237 }
238 }
239 strip_empty_dirs(&tree.dirs);
240 Ok(Prune {
241 freed,
242 files: removed,
243 remaining: total,
244 })
245}
246
247struct Tree {
249 total: u64,
250 files: Vec<(u128, u64, PathBuf)>,
251 dirs: Vec<(usize, PathBuf)>,
252}
253
254impl Tree {
255 fn of(dir: &Path) -> Option<Tree> {
258 if dir.symlink_metadata().ok()?.is_dir() {
259 Some(Tree::from_dir(dir))
260 } else {
261 None
262 }
263 }
264
265 fn from_dir(dir: &Path) -> Tree {
266 let mut total = 0u64;
267 let mut files = Vec::new();
268 let mut dirs = Vec::new();
269 let mut stack: Vec<(usize, PathBuf)> = vec![(0, dir.to_path_buf())];
272 while let Some((depth, d)) = stack.pop() {
273 let Ok(rd) = std::fs::read_dir(&d) else {
274 continue;
275 };
276 for entry in rd.flatten() {
277 let Ok(meta) = entry.metadata() else {
278 continue;
279 };
280 let path = entry.path();
281 if meta.is_dir() {
282 dirs.push((depth + 1, path.clone()));
283 stack.push((depth + 1, path));
284 } else if meta.is_file() {
285 let size = meta.len();
286 total += size;
287 let mtime = meta
288 .modified()
289 .ok()
290 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
291 .map(|d| d.as_nanos())
292 .unwrap_or(0);
293 files.push((mtime, size, path));
294 }
295 }
296 }
297 files.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)).then(a.2.cmp(&b.2)));
305 Tree { total, files, dirs }
306 }
307}
308
309fn strip_empty_dirs(dirs: &[(usize, PathBuf)]) {
311 let mut by_depth: Vec<&PathBuf> = dirs.iter().map(|(_, d)| d).collect();
312 by_depth.sort_unstable_by_key(|d| std::cmp::Reverse(d.iter().count()));
313 for d in by_depth {
314 let _ = std::fs::remove_dir(d);
315 }
316}
317
318#[cfg(test)]
319mod tests {
320 use super::*;
321 use std::fs;
322
323 #[test]
324 fn the_free_space_predicate_is_the_boundary() {
325 assert!(enough_space(100, 100));
326 assert!(enough_space(101, 100));
327 assert!(!enough_space(99, 100));
328 assert!(enough_space(0, 0));
330 }
331
332 #[test]
333 fn the_gate_text_conveys_both_numbers_and_opens_with_room() {
334 assert_eq!(
335 gate(9, 10).expect("closed"),
336 "not enough free space to start a run: 9 bytes free, 10 required by `[disk] min_free_bytes`"
337 );
338 assert_eq!(gate(10, 10), None, "exactly at the floor is open");
339 assert_eq!(gate(10_000, 0), None, "a zero floor is an opt-out");
340 }
341
342 #[test]
343 fn over_limit_uses_strict_greater_than() {
344 assert!(over_limit(11, 10));
345 assert!(!over_limit(10, 10));
346 assert!(!over_limit(9, 10));
347 }
348
349 #[test]
350 fn df_row_parses_1024_blocks_into_bytes() {
351 let row = "/dev/sda1 976762584 808522388 168240196 83% /home";
352 assert_eq!(parse_df_available(row), Some(168_240_196 * 1024));
353 assert_eq!(parse_df_available("garbage"), None);
354 assert_eq!(parse_df_available("a b c x"), None);
355 }
356
357 #[test]
358 fn a_powershell_number_is_one_unsigned_integer() {
359 assert_eq!(parse_u64(" 82072211456\r\n"), Some(82_072_211_456));
360 assert_eq!(parse_u64("nah"), None);
361 }
362
363 #[test]
364 fn the_cache_dir_is_read_back_out_of_a_rendered_command() {
365 let cmd = r"CARGO_TARGET_DIR=C:\Users\me\Temp\magi-target cargo make check";
366 assert_eq!(
367 extract_cargo_target_dir(cmd),
368 Some(PathBuf::from(r"C:\Users\me\Temp\magi-target"))
369 );
370 assert_eq!(
372 extract_cargo_target_dir(r"CARGO_TARGET_DIR='/tmp/a b' cargo test"),
373 Some(PathBuf::from("/tmp/a b"))
374 );
375 assert_eq!(
376 extract_cargo_target_dir(r#"CARGO_TARGET_DIR="/tmp/qq" cargo test"#),
377 Some(PathBuf::from("/tmp/qq"))
378 );
379 assert_eq!(extract_cargo_target_dir("cargo make check"), None);
380 assert_eq!(extract_cargo_target_dir("CARGO_TARGET_DIR="), None);
381 let two = "CARGO_TARGET_DIR=/first and CARGO_TARGET_DIR=/second cargo x";
384 assert_eq!(extract_cargo_target_dir(two), Some(PathBuf::from("/first")));
385 }
386
387 #[test]
388 fn dir_size_is_zero_for_missing_and_counts_files_without_following_links() {
389 let t = tempfile::TempDir::new().expect("temp");
390 assert_eq!(dir_size(&t.path().join("nope")), 0);
391 fs::write(t.path().join("a"), b"12345").expect("write");
392 fs::create_dir(t.path().join("sub")).expect("dir");
393 fs::write(t.path().join("sub").join("b"), b"678").expect("write");
394 assert_eq!(dir_size(t.path()), 8);
395 #[cfg(unix)]
396 {
397 std::os::unix::fs::symlink(t.path().join("sub"), t.path().join("link"))
398 .expect("symlink");
399 assert_eq!(dir_size(t.path()), 8, "a link is counted as a link");
400 }
401 }
402
403 #[test]
404 fn prune_deletes_oldest_first_until_the_cap_is_met() {
405 let t = tempfile::TempDir::new().expect("temp");
406 let old = t.path().join("old");
407 fs::write(&old, b"yyyy").expect("write");
408 std::thread::sleep(std::time::Duration::from_millis(1_200));
411 fs::write(t.path().join("new"), b"xxxxx").expect("write");
412
413 let keep = prune_dir(t.path(), 9).expect("prune");
415 assert_eq!(
416 keep,
417 Prune {
418 freed: 0,
419 files: 0,
420 remaining: 9
421 }
422 );
423
424 let pruned = prune_dir(t.path(), 6).expect("prune");
426 assert!(pruned.freed > 0);
427 assert_eq!(pruned.files, 1);
428 assert_eq!(pruned.remaining, 5);
429 assert!(!old.exists(), "the older file is the one shed");
430 assert!(t.path().join("new").exists());
431 }
432
433 #[test]
434 fn prune_leaves_a_missing_dir_alone() {
435 let t = tempfile::TempDir::new().expect("temp");
436 let out = prune_dir(&t.path().join("absent"), 1).expect("prune");
437 assert_eq!(out, Prune::default());
438 }
439
440 #[test]
441 fn prune_sweeps_directories_the_files_leave_empty() {
442 let t = tempfile::TempDir::new().expect("temp");
443 let deep = t.path().join("a").join("b").join("c");
444 fs::create_dir_all(&deep).expect("dirs");
445 fs::write(deep.join("f"), b"1234").expect("write");
446 let out = prune_dir(t.path(), 0).expect("prune");
447 assert_eq!(out.files, 1);
448 assert_eq!(out.remaining, 0);
449 assert!(!t.path().join("a").exists(), "empty chain swept");
450 }
451}