Skip to main content

magi/
disk.rs

1//! Disk accounting: how much magi's own directories occupy, how much space is
2//! left on the volume they live on, and how the shared build cache is pruned.
3//!
4//! The whole module grew out of one incident: a machine with 951.8 GB of disk
5//! ran a handful of competitions and ended up with 6.7 GB free and a pile of
6//! multi-gigabyte `target/` directories. Every function here exists to keep
7//! that from being a discovery, and every number is substituted at a pure
8//! boundary so the policy can be tested without asking the OS anything.
9
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context as _, Result, bail};
13
14use crate::proc::Quiet as _;
15
16/// Are `free` bytes above the floor for starting a run?
17///
18/// Pure on purpose: the threshold logic is asserted against injected numbers,
19/// and the only place the machine is actually asked anything is [`free_bytes`].
20pub fn enough_space(free: u64, min_free: u64) -> bool {
21    free >= min_free
22}
23
24/// Why a run must not start, given measured free bytes and the floor —
25/// `None` means the gate is open. Pure, so the policy is asserted directly.
26///
27/// A gate that cannot measure also closes (see [`crate::daemon::disk_gate`]):
28/// starting a run on a disk that may already be full is the incident this
29/// whole module exists to prevent.
30pub 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
41/// Is `size` past `limit`? One comparison, shared by the janitor and the
42/// health view, so both answer "is the cache over its cap" identically.
43pub fn over_limit(size: u64, limit: u64) -> bool {
44    size > limit
45}
46
47/// The path a rendered command sets `CARGO_TARGET_DIR=` to, if any.
48///
49/// magi never computes the cache path itself. The operator's `magi.toml` is
50/// the only place that knows it, and by the time a [`crate::config::Config`]
51/// exists that template has been rendered — so the concrete path is read back
52/// out of the verify commands (`CARGO_TARGET_DIR={{ vars.cache }}/magi-target
53/// cargo …` becomes `C:\…\Temp\magi-target`). This is what lets the janitor
54/// prune exactly the directory the gate and the seats build into. `None` when
55/// no command sets the variable: there is then no cache to aggregate or prune,
56/// and agents build wherever the repository's own defaults put them.
57///
58/// The value may be quoted with `'` or `"`; both are understood, as is no
59/// quoting (up to the next whitespace).
60pub 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
78/// Free bytes on the volume containing `path`.
79///
80/// There is no portable way to ask for this, so each platform runs its own
81/// tiny command, deliberately not a new dependency. The parsing halves are
82/// pure and asserted against fixture text; only the subprocess is live.
83pub fn free_bytes(path: &Path) -> Result<u64> {
84    free_bytes_by_os(path)
85}
86
87/// Free bytes on the volume containing `path`.
88#[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/// Free bytes on the volume containing `path`.
110#[cfg(windows)]
111fn free_bytes_by_os(path: &Path) -> Result<u64> {
112    // `fsutil volume diskfree` needs an elevated shell; the .NET DriveInfo in
113    // the Windows PowerShell that ships with the OS does not.
114    //
115    // DriveInfo is handed the **volume root**, never the path itself: its
116    // constructor accepts a drive letter or a root directory and throws on
117    // anything else, including every verbatim path. The queue stores repo
118    // paths as `\\?\C:\...` (that is what `std::path::absolute` yields for a
119    // canonicalised root), so passing the path through closed the disk gate
120    // for every task with `the disk gate refuses to let a run start blind` -
121    // nine tasks were `held` for a disk that had 164 GiB free.
122    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        // Without this the operator watches a console window blink open for
131        // every measurement - and the health view measures on every tick, so
132        // merely leaving the deck open in a browser flashed one every few
133        // seconds. `Quiet` exists for exactly this and the probe skipped it.
134        .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
147/// One `df -k -P` data row: `Filesystem 1024-blocks Used Available …`.
148///
149/// The value is 1024-byte blocks, so the parse returns bytes.
150pub fn parse_df_available(line: &str) -> Option<u64> {
151    let mut fields = line.split_whitespace();
152    fields.next()?; // filesystem
153    fields.next()?; // 1024-blocks
154    fields.next()?; // used
155    let blocks: u64 = fields.next()?.parse().ok()?;
156    Some(blocks.saturating_mul(1024))
157}
158
159/// The volume root of an absolute Windows path, as DriveInfo wants it:
160/// `C:\`, never `C:\Users\...` and never a verbatim `\\?\C:\...`.
161///
162/// Pure and platform-independent so the verbatim form - which is what the
163/// queue stores and what closed the disk gate on every task - is asserted
164/// without a Windows runner. `None` when there is no drive letter to name: a
165/// UNC share has no DriveInfo of its own, and a caller must say it cannot
166/// measure rather than invent a volume.
167pub fn volume_root(path: &Path) -> Option<String> {
168    let text = path.to_str()?;
169    // Verbatim (`\\?\C:\x`) and verbatim-UNC (`\\?\UNC\server\share`) prefixes.
170    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
182/// A bare unsigned integer line, which is all PowerShell prints for a long.
183pub fn parse_u64(text: &str) -> Option<u64> {
184    text.trim().parse().ok()
185}
186
187/// Total bytes under `path`, without following symlinks.
188///
189/// A symlinked directory counts as the link itself, not its contents: mutable
190/// worktrees are real directories, and following an accidental link into a
191/// clone of the repository would count the same bytes twice.
192pub 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            // `DirEntry::metadata` reports the entry itself, so a symlink is
210            // never traversed.
211            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/// What a prune removed, for the report.
225#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
226pub struct Prune {
227    /// Bytes actually freed.
228    pub freed: u64,
229    /// Files deleted.
230    pub files: usize,
231    /// Bytes still under the directory afterwards.
232    pub remaining: u64,
233}
234
235/// What [`plan_prune`] would remove, without removing it.
236///
237/// Oldest-first, the same order [`prune_dir`] deletes in — a caller previewing
238/// the plan (`magi cache prune --dry-run`) must see exactly what an immediate
239/// `prune_dir` call would do, not an approximation of it.
240#[derive(Debug, Clone, Default, PartialEq, Eq)]
241pub struct PrunePlan {
242    /// Files this plan would delete, oldest-first, paired with their size.
243    pub files: Vec<(PathBuf, u64)>,
244    /// Bytes this plan would free, assuming every listed file is removable.
245    pub freed: u64,
246    /// Bytes that would remain under the directory once the plan applies
247    /// cleanly.
248    pub remaining: u64,
249}
250
251/// The selection [`prune_dir`] would act on *if every one of these deletions
252/// succeeds*, computed without touching disk.
253///
254/// Read-only on purpose: a preview must never take the cache's lease (see
255/// `cache::maintenance_prune`'s doc) — it does not write anything, so it
256/// cannot race a build the way a real prune would, and a caller only wanting
257/// to show an operator a plan should not have to wait out contention to do
258/// it. This is necessarily an idealized selection, not a prediction of every
259/// file [`prune_dir`] will end up touching: a build finishing between the
260/// preview and a real prune can change what gets deleted, and so can a single
261/// locked file on the real pass, which - unlike this preview - has to keep
262/// reaching past its own plan for another (newer) file when an older one it
263/// counted on turns out to be unremovable. The CLI surface that shows this
264/// preview says so.
265#[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
295/// Delete files under `dir` oldest-first until its size is at or below `limit`.
296///
297/// The comparison is [`over_limit`], so a directory exactly at the cap is left
298/// alone. Oldest-first keeps the newest generation of artifacts — the one the
299/// next run reuses — and sheds the generations that only compile history. A
300/// deleted file costs the next build a rebuild of that one unit; deleting the
301/// whole directory would cost it everything, which is precisely the work
302/// [`prune_dir`] is keeping for it.
303///
304/// Empty directories left behind are swept depth-first, so cargo's deep
305/// `fingerprint`/`deps` trees do not outlive the files that made them.
306///
307/// Nothing is deleted when the directory is missing.
308pub 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    // Deliberately walks every file in `tree`, not a fixed plan computed up
327    // front: `total` only drops on a successful removal, so a file that is
328    // being read elsewhere (a concurrent build, a snapshot) and fails to
329    // delete on Windows costs this pass nothing but that one file - it is
330    // skipped, and the loop keeps reaching for the next-oldest file until the
331    // real, achieved total is at or below `limit` or there is nothing left to
332    // try. A version of this that instead deleted only a pre-computed
333    // selection would stop short of the cap on the first locked file, every
334    // pass, on exactly the machines where locked files are common.
335    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
353/// Files and directories under one root, walked up-front.
354struct Tree {
355    total: u64,
356    files: Vec<(u128, u64, PathBuf)>,
357    dirs: Vec<(usize, PathBuf)>,
358}
359
360impl Tree {
361    /// Walk `dir`, collecting files (mtime-nanoseconds, size, path) and
362    /// directories (depth, path). `None` when the directory does not exist.
363    fn of(dir: &Path) -> Option<Tree> {
364        if dir.symlink_metadata().ok()?.is_dir() {
365            Some(Tree::from_dir(dir))
366        } else {
367            None
368        }
369    }
370
371    fn from_dir(dir: &Path) -> Tree {
372        let mut total = 0u64;
373        let mut files = Vec::new();
374        let mut dirs = Vec::new();
375        // Depth-first so directories are recorded before their contents; the
376        // dir list is then sorted by descending depth for the sweep.
377        let mut stack: Vec<(usize, PathBuf)> = vec![(0, dir.to_path_buf())];
378        while let Some((depth, d)) = stack.pop() {
379            let Ok(rd) = std::fs::read_dir(&d) else {
380                continue;
381            };
382            for entry in rd.flatten() {
383                let Ok(meta) = entry.metadata() else {
384                    continue;
385                };
386                let path = entry.path();
387                if meta.is_dir() {
388                    dirs.push((depth + 1, path.clone()));
389                    stack.push((depth + 1, path));
390                } else if meta.is_file() {
391                    let size = meta.len();
392                    total += size;
393                    let mtime = meta
394                        .modified()
395                        .ok()
396                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
397                        .map(|d| d.as_nanos())
398                        .unwrap_or(0);
399                    files.push((mtime, size, path));
400                }
401            }
402        }
403        // Oldest first, and on a tie the larger file: a whole generation of
404        // cargo artifacts is written within one filesystem timestamp tick, so
405        // mtime alone leaves the order to `read_dir` and the sort's
406        // instability - the same cache pruned twice would shed different
407        // files, and a test over two same-tick files passed on one platform
408        // and failed on another. Larger-first also reaches the cap in fewer
409        // deletions, which is fewer rebuilt units for the next run.
410        files.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)).then(a.2.cmp(&b.2)));
411        Tree { total, files, dirs }
412    }
413}
414
415/// Remove empty directories, deepest first, never the root itself.
416fn strip_empty_dirs(dirs: &[(usize, PathBuf)]) {
417    let mut by_depth: Vec<&PathBuf> = dirs.iter().map(|(_, d)| d).collect();
418    by_depth.sort_unstable_by_key(|d| std::cmp::Reverse(d.iter().count()));
419    for d in by_depth {
420        let _ = std::fs::remove_dir(d);
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use std::fs;
428
429    #[test]
430    fn the_free_space_predicate_is_the_boundary() {
431        assert!(enough_space(100, 100));
432        assert!(enough_space(101, 100));
433        assert!(!enough_space(99, 100));
434        // A zero floor disables the gate: the operator opted out.
435        assert!(enough_space(0, 0));
436    }
437
438    #[test]
439    fn the_gate_text_conveys_both_numbers_and_opens_with_room() {
440        assert_eq!(
441            gate(9, 10).expect("closed"),
442            "not enough free space to start a run: 9 bytes free, 10 required by `[disk] min_free_bytes`"
443        );
444        assert_eq!(gate(10, 10), None, "exactly at the floor is open");
445        assert_eq!(gate(10_000, 0), None, "a zero floor is an opt-out");
446    }
447
448    #[test]
449    fn over_limit_uses_strict_greater_than() {
450        assert!(over_limit(11, 10));
451        assert!(!over_limit(10, 10));
452        assert!(!over_limit(9, 10));
453    }
454
455    #[test]
456    fn df_row_parses_1024_blocks_into_bytes() {
457        let row = "/dev/sda1 976762584 808522388 168240196 83% /home";
458        assert_eq!(parse_df_available(row), Some(168_240_196 * 1024));
459        assert_eq!(parse_df_available("garbage"), None);
460        assert_eq!(parse_df_available("a b c x"), None);
461    }
462
463    #[test]
464    fn a_powershell_number_is_one_unsigned_integer() {
465        assert_eq!(parse_u64("     82072211456\r\n"), Some(82_072_211_456));
466        assert_eq!(parse_u64("nah"), None);
467    }
468
469    /// DriveInfo takes a volume, and the queue hands out verbatim paths.
470    #[test]
471    fn the_volume_root_is_a_drive_not_the_path_it_came_from() {
472        // The form that closed the gate on every queued task: the queue
473        // records the repo as `\\?\C:\...`.
474        assert_eq!(
475            volume_root(Path::new(
476                r"\\?\C:\Users\yukimemi\src\github.com\yukimemi\magi"
477            )),
478            Some(r"C:\".to_owned())
479        );
480        assert_eq!(
481            volume_root(Path::new(r"C:\Users\yukimemi")),
482            Some(r"C:\".to_owned())
483        );
484        assert_eq!(volume_root(Path::new(r"D:\")), Some(r"D:\".to_owned()));
485        // Forward slashes reach magi from configs written by hand.
486        assert_eq!(
487            volume_root(Path::new("C:/Users/yukimemi/src")),
488            Some(r"C:\".to_owned())
489        );
490        // No drive to name: a share has no DriveInfo, and a POSIX path has no
491        // volume at all. The caller has to report that it cannot measure.
492        assert_eq!(volume_root(Path::new(r"\\server\share\dir")), None);
493        assert_eq!(volume_root(Path::new(r"\\?\UNC\server\share")), None);
494        assert_eq!(volume_root(Path::new("/home/yukimemi")), None);
495    }
496
497    #[test]
498    fn the_cache_dir_is_read_back_out_of_a_rendered_command() {
499        let cmd = r"CARGO_TARGET_DIR=C:\Users\me\Temp\magi-target cargo make check";
500        assert_eq!(
501            extract_cargo_target_dir(cmd),
502            Some(PathBuf::from(r"C:\Users\me\Temp\magi-target"))
503        );
504        // Quoted forms survive spaces; a config with none stays None.
505        assert_eq!(
506            extract_cargo_target_dir(r"CARGO_TARGET_DIR='/tmp/a b' cargo test"),
507            Some(PathBuf::from("/tmp/a b"))
508        );
509        assert_eq!(
510            extract_cargo_target_dir(r#"CARGO_TARGET_DIR="/tmp/qq" cargo test"#),
511            Some(PathBuf::from("/tmp/qq"))
512        );
513        assert_eq!(extract_cargo_target_dir("cargo make check"), None);
514        assert_eq!(extract_cargo_target_dir("CARGO_TARGET_DIR="), None);
515        // Second occurrence is irrelevant: the first is what the build used
516        // (a command's environment applies once).
517        let two = "CARGO_TARGET_DIR=/first and CARGO_TARGET_DIR=/second cargo x";
518        assert_eq!(extract_cargo_target_dir(two), Some(PathBuf::from("/first")));
519    }
520
521    #[test]
522    fn dir_size_is_zero_for_missing_and_counts_files_without_following_links() {
523        let t = tempfile::TempDir::new().expect("temp");
524        assert_eq!(dir_size(&t.path().join("nope")), 0);
525        fs::write(t.path().join("a"), b"12345").expect("write");
526        fs::create_dir(t.path().join("sub")).expect("dir");
527        fs::write(t.path().join("sub").join("b"), b"678").expect("write");
528        assert_eq!(dir_size(t.path()), 8);
529        #[cfg(unix)]
530        {
531            std::os::unix::fs::symlink(t.path().join("sub"), t.path().join("link"))
532                .expect("symlink");
533            assert_eq!(dir_size(t.path()), 8, "a link is counted as a link");
534        }
535    }
536
537    #[test]
538    fn prune_deletes_oldest_first_until_the_cap_is_met() {
539        let t = tempfile::TempDir::new().expect("temp");
540        let old = t.path().join("old");
541        fs::write(&old, b"yyyy").expect("write");
542        // Give the older file a measurably older mtime; a second is past the
543        // granularity of the filesystems magi runs on.
544        std::thread::sleep(std::time::Duration::from_millis(1_200));
545        fs::write(t.path().join("new"), b"xxxxx").expect("write");
546
547        // Cap above the total: nothing moves.
548        let keep = prune_dir(t.path(), 9).expect("prune");
549        assert_eq!(
550            keep,
551            Prune {
552                freed: 0,
553                files: 0,
554                remaining: 9
555            }
556        );
557
558        // Cap below: the oldest file goes, the new one stays.
559        let pruned = prune_dir(t.path(), 6).expect("prune");
560        assert!(pruned.freed > 0);
561        assert_eq!(pruned.files, 1);
562        assert_eq!(pruned.remaining, 5);
563        assert!(!old.exists(), "the older file is the one shed");
564        assert!(t.path().join("new").exists());
565    }
566
567    #[test]
568    fn plan_prune_selects_what_prune_dir_would_delete_without_deleting_it() {
569        let t = tempfile::TempDir::new().expect("temp");
570        let old = t.path().join("old");
571        fs::write(&old, b"yyyy").expect("write");
572        std::thread::sleep(std::time::Duration::from_millis(1_200));
573        fs::write(t.path().join("new"), b"xxxxx").expect("write");
574
575        let plan = plan_prune(t.path(), 6);
576        assert_eq!(plan.files, vec![(old.clone(), 4)]);
577        assert_eq!(plan.freed, 4);
578        assert_eq!(plan.remaining, 5);
579        assert!(old.exists(), "a plan never deletes anything");
580        assert!(t.path().join("new").exists());
581
582        // Applying `prune_dir` afterwards removes exactly what the plan named.
583        let pruned = prune_dir(t.path(), 6).expect("prune");
584        assert_eq!(pruned.freed, plan.freed);
585        assert_eq!(pruned.remaining, plan.remaining);
586        assert!(!old.exists());
587    }
588
589    #[test]
590    fn plan_prune_is_empty_under_the_cap_and_for_a_missing_dir() {
591        let t = tempfile::TempDir::new().expect("temp");
592        fs::write(t.path().join("a"), b"12345").expect("write");
593        let plan = plan_prune(t.path(), 100);
594        assert_eq!(
595            plan,
596            PrunePlan {
597                files: Vec::new(),
598                freed: 0,
599                remaining: 5,
600            }
601        );
602
603        assert_eq!(
604            plan_prune(&t.path().join("absent"), 0),
605            PrunePlan::default()
606        );
607    }
608
609    /// A file `prune_dir` cannot delete - locked by a concurrent reader on
610    /// Windows, the exact scenario the function's own doc calls out - must
611    /// not make the pass stop short of the cap. The achieved total only
612    /// drops on a successful removal, so the loop has to keep reaching for
613    /// newer files until *that* total clears `limit`, not stop once a
614    /// precomputed selection runs out.
615    #[cfg(windows)]
616    #[test]
617    fn prune_keeps_reaching_past_an_undeletable_file_to_still_reach_the_cap() {
618        use std::os::windows::fs::OpenOptionsExt as _;
619
620        let t = tempfile::TempDir::new().expect("temp");
621        let old = t.path().join("old");
622        fs::write(&old, b"yyyy").expect("write");
623        std::thread::sleep(std::time::Duration::from_millis(1_200));
624        let mid = t.path().join("mid");
625        fs::write(&mid, b"zzzz").expect("write");
626        std::thread::sleep(std::time::Duration::from_millis(1_200));
627        let new = t.path().join("new");
628        fs::write(&new, b"xxxxx").expect("write");
629
630        // A share mode of 0 denies every other handle, including a delete -
631        // standing in for a file a concurrent build still has open, which is
632        // exactly the case `prune_dir`'s own doc calls out.
633        let lock = std::fs::OpenOptions::new()
634            .read(true)
635            .share_mode(0)
636            .open(&old)
637            .expect("lock the old file exclusively");
638
639        let pruned = prune_dir(t.path(), 8).expect("prune");
640        drop(lock);
641
642        assert!(old.exists(), "the locked file could not be deleted");
643        assert!(!mid.exists(), "the next-oldest file was tried and removed");
644        assert!(
645            !new.exists(),
646            "pruning kept reaching for newer files until the cap was actually met, \
647             not just until a fixed selection ran out"
648        );
649        assert!(
650            pruned.remaining <= 8,
651            "the achieved total must reach the cap: {pruned:?}"
652        );
653    }
654
655    #[test]
656    fn prune_leaves_a_missing_dir_alone() {
657        let t = tempfile::TempDir::new().expect("temp");
658        let out = prune_dir(&t.path().join("absent"), 1).expect("prune");
659        assert_eq!(out, Prune::default());
660    }
661
662    #[test]
663    fn prune_sweeps_directories_the_files_leave_empty() {
664        let t = tempfile::TempDir::new().expect("temp");
665        let deep = t.path().join("a").join("b").join("c");
666        fs::create_dir_all(&deep).expect("dirs");
667        fs::write(deep.join("f"), b"1234").expect("write");
668        let out = prune_dir(t.path(), 0).expect("prune");
669        assert_eq!(out.files, 1);
670        assert_eq!(out.remaining, 0);
671        assert!(!t.path().join("a").exists(), "empty chain swept");
672    }
673}