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)
117 .with_context(|| format!("absolute path for {}", path.display()))?;
118 let quoted = abs.to_string_lossy().replace('\'', "''");
119 let script = format!("[System.IO.DriveInfo]::new('{quoted}').AvailableFreeSpace");
120 let out = std::process::Command::new("powershell")
121 .args(["-NoProfile", "-NonInteractive", "-Command", &script])
122 .quiet()
127 .output()
128 .with_context(|| format!("run PowerShell for {}", abs.display()))?;
129 if !out.status.success() {
130 bail!(
131 "PowerShell failed: {}",
132 String::from_utf8_lossy(&out.stderr).trim()
133 );
134 }
135 parse_u64(&String::from_utf8_lossy(&out.stdout))
136 .with_context(|| format!("parse PowerShell bytes for {}", abs.display()))
137}
138
139pub fn parse_df_available(line: &str) -> Option<u64> {
143 let mut fields = line.split_whitespace();
144 fields.next()?; fields.next()?; fields.next()?; let blocks: u64 = fields.next()?.parse().ok()?;
148 Some(blocks.saturating_mul(1024))
149}
150
151pub fn parse_u64(text: &str) -> Option<u64> {
153 text.trim().parse().ok()
154}
155
156pub fn dir_size(path: &Path) -> u64 {
162 let Ok(meta) = std::fs::symlink_metadata(path) else {
163 return 0;
164 };
165 if meta.is_file() {
166 return meta.len();
167 }
168 if !meta.is_dir() {
169 return 0;
170 }
171 let mut total = 0u64;
172 let mut stack = vec![path.to_path_buf()];
173 while let Some(dir) = stack.pop() {
174 let Ok(rd) = std::fs::read_dir(&dir) else {
175 continue;
176 };
177 for entry in rd.flatten() {
178 let Ok(meta) = entry.metadata() else {
181 continue;
182 };
183 if meta.is_dir() {
184 stack.push(entry.path());
185 } else if meta.is_file() {
186 total += meta.len();
187 }
188 }
189 }
190 total
191}
192
193#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
195pub struct Prune {
196 pub freed: u64,
198 pub files: usize,
200 pub remaining: u64,
202}
203
204pub fn prune_dir(dir: &Path, limit: u64) -> Result<Prune> {
218 let Some(tree) = Tree::of(dir) else {
219 return Ok(Prune {
220 freed: 0,
221 files: 0,
222 remaining: 0,
223 });
224 };
225 let mut total = tree.total;
226 if !over_limit(total, limit) {
227 return Ok(Prune {
228 freed: 0,
229 files: 0,
230 remaining: total,
231 });
232 }
233 let mut freed = 0u64;
234 let mut removed = 0usize;
235 for (_, size, path) in tree.files {
236 if !over_limit(total, limit) {
237 break;
238 }
239 if std::fs::remove_file(&path).is_ok() {
242 total = total.saturating_sub(size);
243 freed += size;
244 removed += 1;
245 }
246 }
247 strip_empty_dirs(&tree.dirs);
248 Ok(Prune {
249 freed,
250 files: removed,
251 remaining: total,
252 })
253}
254
255struct Tree {
257 total: u64,
258 files: Vec<(u128, u64, PathBuf)>,
259 dirs: Vec<(usize, PathBuf)>,
260}
261
262impl Tree {
263 fn of(dir: &Path) -> Option<Tree> {
266 if dir.symlink_metadata().ok()?.is_dir() {
267 Some(Tree::from_dir(dir))
268 } else {
269 None
270 }
271 }
272
273 fn from_dir(dir: &Path) -> Tree {
274 let mut total = 0u64;
275 let mut files = Vec::new();
276 let mut dirs = Vec::new();
277 let mut stack: Vec<(usize, PathBuf)> = vec![(0, dir.to_path_buf())];
280 while let Some((depth, d)) = stack.pop() {
281 let Ok(rd) = std::fs::read_dir(&d) else {
282 continue;
283 };
284 for entry in rd.flatten() {
285 let Ok(meta) = entry.metadata() else {
286 continue;
287 };
288 let path = entry.path();
289 if meta.is_dir() {
290 dirs.push((depth + 1, path.clone()));
291 stack.push((depth + 1, path));
292 } else if meta.is_file() {
293 let size = meta.len();
294 total += size;
295 let mtime = meta
296 .modified()
297 .ok()
298 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
299 .map(|d| d.as_nanos())
300 .unwrap_or(0);
301 files.push((mtime, size, path));
302 }
303 }
304 }
305 files.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)).then(a.2.cmp(&b.2)));
313 Tree { total, files, dirs }
314 }
315}
316
317fn strip_empty_dirs(dirs: &[(usize, PathBuf)]) {
319 let mut by_depth: Vec<&PathBuf> = dirs.iter().map(|(_, d)| d).collect();
320 by_depth.sort_unstable_by_key(|d| std::cmp::Reverse(d.iter().count()));
321 for d in by_depth {
322 let _ = std::fs::remove_dir(d);
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use std::fs;
330
331 #[test]
332 fn the_free_space_predicate_is_the_boundary() {
333 assert!(enough_space(100, 100));
334 assert!(enough_space(101, 100));
335 assert!(!enough_space(99, 100));
336 assert!(enough_space(0, 0));
338 }
339
340 #[test]
341 fn the_gate_text_conveys_both_numbers_and_opens_with_room() {
342 assert_eq!(
343 gate(9, 10).expect("closed"),
344 "not enough free space to start a run: 9 bytes free, 10 required by `[disk] min_free_bytes`"
345 );
346 assert_eq!(gate(10, 10), None, "exactly at the floor is open");
347 assert_eq!(gate(10_000, 0), None, "a zero floor is an opt-out");
348 }
349
350 #[test]
351 fn over_limit_uses_strict_greater_than() {
352 assert!(over_limit(11, 10));
353 assert!(!over_limit(10, 10));
354 assert!(!over_limit(9, 10));
355 }
356
357 #[test]
358 fn df_row_parses_1024_blocks_into_bytes() {
359 let row = "/dev/sda1 976762584 808522388 168240196 83% /home";
360 assert_eq!(parse_df_available(row), Some(168_240_196 * 1024));
361 assert_eq!(parse_df_available("garbage"), None);
362 assert_eq!(parse_df_available("a b c x"), None);
363 }
364
365 #[test]
366 fn a_powershell_number_is_one_unsigned_integer() {
367 assert_eq!(parse_u64(" 82072211456\r\n"), Some(82_072_211_456));
368 assert_eq!(parse_u64("nah"), None);
369 }
370
371 #[test]
372 fn the_cache_dir_is_read_back_out_of_a_rendered_command() {
373 let cmd = r"CARGO_TARGET_DIR=C:\Users\me\Temp\magi-target cargo make check";
374 assert_eq!(
375 extract_cargo_target_dir(cmd),
376 Some(PathBuf::from(r"C:\Users\me\Temp\magi-target"))
377 );
378 assert_eq!(
380 extract_cargo_target_dir(r"CARGO_TARGET_DIR='/tmp/a b' cargo test"),
381 Some(PathBuf::from("/tmp/a b"))
382 );
383 assert_eq!(
384 extract_cargo_target_dir(r#"CARGO_TARGET_DIR="/tmp/qq" cargo test"#),
385 Some(PathBuf::from("/tmp/qq"))
386 );
387 assert_eq!(extract_cargo_target_dir("cargo make check"), None);
388 assert_eq!(extract_cargo_target_dir("CARGO_TARGET_DIR="), None);
389 let two = "CARGO_TARGET_DIR=/first and CARGO_TARGET_DIR=/second cargo x";
392 assert_eq!(extract_cargo_target_dir(two), Some(PathBuf::from("/first")));
393 }
394
395 #[test]
396 fn dir_size_is_zero_for_missing_and_counts_files_without_following_links() {
397 let t = tempfile::TempDir::new().expect("temp");
398 assert_eq!(dir_size(&t.path().join("nope")), 0);
399 fs::write(t.path().join("a"), b"12345").expect("write");
400 fs::create_dir(t.path().join("sub")).expect("dir");
401 fs::write(t.path().join("sub").join("b"), b"678").expect("write");
402 assert_eq!(dir_size(t.path()), 8);
403 #[cfg(unix)]
404 {
405 std::os::unix::fs::symlink(t.path().join("sub"), t.path().join("link"))
406 .expect("symlink");
407 assert_eq!(dir_size(t.path()), 8, "a link is counted as a link");
408 }
409 }
410
411 #[test]
412 fn prune_deletes_oldest_first_until_the_cap_is_met() {
413 let t = tempfile::TempDir::new().expect("temp");
414 let old = t.path().join("old");
415 fs::write(&old, b"yyyy").expect("write");
416 std::thread::sleep(std::time::Duration::from_millis(1_200));
419 fs::write(t.path().join("new"), b"xxxxx").expect("write");
420
421 let keep = prune_dir(t.path(), 9).expect("prune");
423 assert_eq!(
424 keep,
425 Prune {
426 freed: 0,
427 files: 0,
428 remaining: 9
429 }
430 );
431
432 let pruned = prune_dir(t.path(), 6).expect("prune");
434 assert!(pruned.freed > 0);
435 assert_eq!(pruned.files, 1);
436 assert_eq!(pruned.remaining, 5);
437 assert!(!old.exists(), "the older file is the one shed");
438 assert!(t.path().join("new").exists());
439 }
440
441 #[test]
442 fn prune_leaves_a_missing_dir_alone() {
443 let t = tempfile::TempDir::new().expect("temp");
444 let out = prune_dir(&t.path().join("absent"), 1).expect("prune");
445 assert_eq!(out, Prune::default());
446 }
447
448 #[test]
449 fn prune_sweeps_directories_the_files_leave_empty() {
450 let t = tempfile::TempDir::new().expect("temp");
451 let deep = t.path().join("a").join("b").join("c");
452 fs::create_dir_all(&deep).expect("dirs");
453 fs::write(deep.join("f"), b"1234").expect("write");
454 let out = prune_dir(t.path(), 0).expect("prune");
455 assert_eq!(out.files, 1);
456 assert_eq!(out.remaining, 0);
457 assert!(!t.path().join("a").exists(), "empty chain swept");
458 }
459}