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/// The one file a prune never selects, when it sits directly in the pruned root.
354///
355/// cargo writes `CACHEDIR.TAG` once, when it creates a target directory, so in
356/// a long-lived cache it is the oldest file and an oldest-first prune reaches it
357/// first. Without it `cargo clean -p` refuses ("missing or invalid
358/// `CACHEDIR.TAG` file"). The tag belongs to the cargo that made it, not to the
359/// prune. It still counts toward the directory's size; a same-named file in a
360/// subdirectory is an ordinary candidate.
361const PRESERVED_AT_ROOT: &str = "CACHEDIR.TAG";
362
363/// Files and directories under one root, walked up-front.
364///
365/// `files` are the deletion candidates; `total` also counts what is preserved.
366struct Tree {
367    total: u64,
368    files: Vec<(u128, u64, PathBuf)>,
369    dirs: Vec<(usize, PathBuf)>,
370}
371
372impl Tree {
373    /// Walk `dir`, collecting files (mtime-nanoseconds, size, path) and
374    /// directories (depth, path). `None` when the directory does not exist.
375    fn of(dir: &Path) -> Option<Tree> {
376        if dir.symlink_metadata().ok()?.is_dir() {
377            Some(Tree::from_dir(dir))
378        } else {
379            None
380        }
381    }
382
383    fn from_dir(dir: &Path) -> Tree {
384        let mut total = 0u64;
385        let mut files = Vec::new();
386        let mut dirs = Vec::new();
387        // Depth-first so directories are recorded before their contents; the
388        // dir list is then sorted by descending depth for the sweep.
389        let mut stack: Vec<(usize, PathBuf)> = vec![(0, dir.to_path_buf())];
390        while let Some((depth, d)) = stack.pop() {
391            let Ok(rd) = std::fs::read_dir(&d) else {
392                continue;
393            };
394            for entry in rd.flatten() {
395                let Ok(meta) = entry.metadata() else {
396                    continue;
397                };
398                let path = entry.path();
399                if meta.is_dir() {
400                    dirs.push((depth + 1, path.clone()));
401                    stack.push((depth + 1, path));
402                } else if meta.is_file() {
403                    let size = meta.len();
404                    total += size;
405                    if depth == 0 && entry.file_name() == PRESERVED_AT_ROOT {
406                        continue;
407                    }
408                    let mtime = meta
409                        .modified()
410                        .ok()
411                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
412                        .map(|d| d.as_nanos())
413                        .unwrap_or(0);
414                    files.push((mtime, size, path));
415                }
416            }
417        }
418        // Oldest first, and on a tie the larger file: a whole generation of
419        // cargo artifacts is written within one filesystem timestamp tick, so
420        // mtime alone leaves the order to `read_dir` and the sort's
421        // instability - the same cache pruned twice would shed different
422        // files, and a test over two same-tick files passed on one platform
423        // and failed on another. Larger-first also reaches the cap in fewer
424        // deletions, which is fewer rebuilt units for the next run.
425        files.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)).then(a.2.cmp(&b.2)));
426        Tree { total, files, dirs }
427    }
428}
429
430/// Remove empty directories, deepest first, never the root itself.
431fn strip_empty_dirs(dirs: &[(usize, PathBuf)]) {
432    let mut by_depth: Vec<&PathBuf> = dirs.iter().map(|(_, d)| d).collect();
433    by_depth.sort_unstable_by_key(|d| std::cmp::Reverse(d.iter().count()));
434    for d in by_depth {
435        let _ = std::fs::remove_dir(d);
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use std::fs;
443
444    #[test]
445    fn the_free_space_predicate_is_the_boundary() {
446        assert!(enough_space(100, 100));
447        assert!(enough_space(101, 100));
448        assert!(!enough_space(99, 100));
449        // A zero floor disables the gate: the operator opted out.
450        assert!(enough_space(0, 0));
451    }
452
453    #[test]
454    fn the_gate_text_conveys_both_numbers_and_opens_with_room() {
455        assert_eq!(
456            gate(9, 10).expect("closed"),
457            "not enough free space to start a run: 9 bytes free, 10 required by `[disk] min_free_bytes`"
458        );
459        assert_eq!(gate(10, 10), None, "exactly at the floor is open");
460        assert_eq!(gate(10_000, 0), None, "a zero floor is an opt-out");
461    }
462
463    #[test]
464    fn over_limit_uses_strict_greater_than() {
465        assert!(over_limit(11, 10));
466        assert!(!over_limit(10, 10));
467        assert!(!over_limit(9, 10));
468    }
469
470    #[test]
471    fn df_row_parses_1024_blocks_into_bytes() {
472        let row = "/dev/sda1 976762584 808522388 168240196 83% /home";
473        assert_eq!(parse_df_available(row), Some(168_240_196 * 1024));
474        assert_eq!(parse_df_available("garbage"), None);
475        assert_eq!(parse_df_available("a b c x"), None);
476    }
477
478    #[test]
479    fn a_powershell_number_is_one_unsigned_integer() {
480        assert_eq!(parse_u64("     82072211456\r\n"), Some(82_072_211_456));
481        assert_eq!(parse_u64("nah"), None);
482    }
483
484    /// DriveInfo takes a volume, and the queue hands out verbatim paths.
485    #[test]
486    fn the_volume_root_is_a_drive_not_the_path_it_came_from() {
487        // The form that closed the gate on every queued task: the queue
488        // records the repo as `\\?\C:\...`.
489        assert_eq!(
490            volume_root(Path::new(
491                r"\\?\C:\Users\yukimemi\src\github.com\yukimemi\magi"
492            )),
493            Some(r"C:\".to_owned())
494        );
495        assert_eq!(
496            volume_root(Path::new(r"C:\Users\yukimemi")),
497            Some(r"C:\".to_owned())
498        );
499        assert_eq!(volume_root(Path::new(r"D:\")), Some(r"D:\".to_owned()));
500        // Forward slashes reach magi from configs written by hand.
501        assert_eq!(
502            volume_root(Path::new("C:/Users/yukimemi/src")),
503            Some(r"C:\".to_owned())
504        );
505        // No drive to name: a share has no DriveInfo, and a POSIX path has no
506        // volume at all. The caller has to report that it cannot measure.
507        assert_eq!(volume_root(Path::new(r"\\server\share\dir")), None);
508        assert_eq!(volume_root(Path::new(r"\\?\UNC\server\share")), None);
509        assert_eq!(volume_root(Path::new("/home/yukimemi")), None);
510    }
511
512    #[test]
513    fn the_cache_dir_is_read_back_out_of_a_rendered_command() {
514        let cmd = r"CARGO_TARGET_DIR=C:\Users\me\Temp\magi-target cargo make check";
515        assert_eq!(
516            extract_cargo_target_dir(cmd),
517            Some(PathBuf::from(r"C:\Users\me\Temp\magi-target"))
518        );
519        // Quoted forms survive spaces; a config with none stays None.
520        assert_eq!(
521            extract_cargo_target_dir(r"CARGO_TARGET_DIR='/tmp/a b' cargo test"),
522            Some(PathBuf::from("/tmp/a b"))
523        );
524        assert_eq!(
525            extract_cargo_target_dir(r#"CARGO_TARGET_DIR="/tmp/qq" cargo test"#),
526            Some(PathBuf::from("/tmp/qq"))
527        );
528        assert_eq!(extract_cargo_target_dir("cargo make check"), None);
529        assert_eq!(extract_cargo_target_dir("CARGO_TARGET_DIR="), None);
530        // Second occurrence is irrelevant: the first is what the build used
531        // (a command's environment applies once).
532        let two = "CARGO_TARGET_DIR=/first and CARGO_TARGET_DIR=/second cargo x";
533        assert_eq!(extract_cargo_target_dir(two), Some(PathBuf::from("/first")));
534    }
535
536    #[test]
537    fn dir_size_is_zero_for_missing_and_counts_files_without_following_links() {
538        let t = tempfile::TempDir::new().expect("temp");
539        assert_eq!(dir_size(&t.path().join("nope")), 0);
540        fs::write(t.path().join("a"), b"12345").expect("write");
541        fs::create_dir(t.path().join("sub")).expect("dir");
542        fs::write(t.path().join("sub").join("b"), b"678").expect("write");
543        assert_eq!(dir_size(t.path()), 8);
544        #[cfg(unix)]
545        {
546            std::os::unix::fs::symlink(t.path().join("sub"), t.path().join("link"))
547                .expect("symlink");
548            assert_eq!(dir_size(t.path()), 8, "a link is counted as a link");
549        }
550    }
551
552    #[test]
553    fn prune_deletes_oldest_first_until_the_cap_is_met() {
554        let t = tempfile::TempDir::new().expect("temp");
555        let old = t.path().join("old");
556        fs::write(&old, b"yyyy").expect("write");
557        // Give the older file a measurably older mtime; a second is past the
558        // granularity of the filesystems magi runs on.
559        std::thread::sleep(std::time::Duration::from_millis(1_200));
560        fs::write(t.path().join("new"), b"xxxxx").expect("write");
561
562        // Cap above the total: nothing moves.
563        let keep = prune_dir(t.path(), 9).expect("prune");
564        assert_eq!(
565            keep,
566            Prune {
567                freed: 0,
568                files: 0,
569                remaining: 9
570            }
571        );
572
573        // Cap below: the oldest file goes, the new one stays.
574        let pruned = prune_dir(t.path(), 6).expect("prune");
575        assert!(pruned.freed > 0);
576        assert_eq!(pruned.files, 1);
577        assert_eq!(pruned.remaining, 5);
578        assert!(!old.exists(), "the older file is the one shed");
579        assert!(t.path().join("new").exists());
580    }
581
582    #[test]
583    fn plan_prune_selects_what_prune_dir_would_delete_without_deleting_it() {
584        let t = tempfile::TempDir::new().expect("temp");
585        let old = t.path().join("old");
586        fs::write(&old, b"yyyy").expect("write");
587        std::thread::sleep(std::time::Duration::from_millis(1_200));
588        fs::write(t.path().join("new"), b"xxxxx").expect("write");
589
590        let plan = plan_prune(t.path(), 6);
591        assert_eq!(plan.files, vec![(old.clone(), 4)]);
592        assert_eq!(plan.freed, 4);
593        assert_eq!(plan.remaining, 5);
594        assert!(old.exists(), "a plan never deletes anything");
595        assert!(t.path().join("new").exists());
596
597        // Applying `prune_dir` afterwards removes exactly what the plan named.
598        let pruned = prune_dir(t.path(), 6).expect("prune");
599        assert_eq!(pruned.freed, plan.freed);
600        assert_eq!(pruned.remaining, plan.remaining);
601        assert!(!old.exists());
602    }
603
604    #[test]
605    fn a_root_cachedir_tag_survives_even_as_the_oldest_file() {
606        // cargo writes the tag once, so in a long-lived cache it is the oldest
607        // file and used to be the first thing an over-cap prune deleted.
608        let t = tempfile::TempDir::new().expect("temp");
609        let tag = t.path().join("CACHEDIR.TAG");
610        fs::write(&tag, b"Signature: x").expect("write");
611        let f = fs::File::options().write(true).open(&tag).expect("open");
612        f.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1))
613            .expect("mtime");
614        drop(f);
615        fs::write(t.path().join("a"), b"aaaa").expect("write");
616        fs::create_dir(t.path().join("sub")).expect("mkdir");
617        // A same-named file below the root is an ordinary candidate.
618        let nested = t.path().join("sub").join("CACHEDIR.TAG");
619        fs::write(&nested, b"nn").expect("write");
620
621        let limit = 12; // exactly the tag: every other file must go
622        let plan = plan_prune(t.path(), limit);
623        assert!(plan.files.iter().all(|(p, _)| p != &tag));
624        assert!(plan.remaining <= limit);
625
626        let pruned = prune_dir(t.path(), limit).expect("prune");
627        assert!(tag.exists(), "the root tag is never pruned");
628        assert!(pruned.remaining <= limit);
629        assert_eq!(pruned.freed, plan.freed);
630        assert_eq!(pruned.remaining, plan.remaining);
631        assert!(!nested.exists());
632    }
633
634    #[test]
635    fn plan_prune_is_empty_under_the_cap_and_for_a_missing_dir() {
636        let t = tempfile::TempDir::new().expect("temp");
637        fs::write(t.path().join("a"), b"12345").expect("write");
638        let plan = plan_prune(t.path(), 100);
639        assert_eq!(
640            plan,
641            PrunePlan {
642                files: Vec::new(),
643                freed: 0,
644                remaining: 5,
645            }
646        );
647
648        assert_eq!(
649            plan_prune(&t.path().join("absent"), 0),
650            PrunePlan::default()
651        );
652    }
653
654    /// A file `prune_dir` cannot delete - locked by a concurrent reader on
655    /// Windows, the exact scenario the function's own doc calls out - must
656    /// not make the pass stop short of the cap. The achieved total only
657    /// drops on a successful removal, so the loop has to keep reaching for
658    /// newer files until *that* total clears `limit`, not stop once a
659    /// precomputed selection runs out.
660    #[cfg(windows)]
661    #[test]
662    fn prune_keeps_reaching_past_an_undeletable_file_to_still_reach_the_cap() {
663        use std::os::windows::fs::OpenOptionsExt as _;
664
665        let t = tempfile::TempDir::new().expect("temp");
666        let old = t.path().join("old");
667        fs::write(&old, b"yyyy").expect("write");
668        std::thread::sleep(std::time::Duration::from_millis(1_200));
669        let mid = t.path().join("mid");
670        fs::write(&mid, b"zzzz").expect("write");
671        std::thread::sleep(std::time::Duration::from_millis(1_200));
672        let new = t.path().join("new");
673        fs::write(&new, b"xxxxx").expect("write");
674
675        // A share mode of 0 denies every other handle, including a delete -
676        // standing in for a file a concurrent build still has open, which is
677        // exactly the case `prune_dir`'s own doc calls out.
678        let lock = std::fs::OpenOptions::new()
679            .read(true)
680            .share_mode(0)
681            .open(&old)
682            .expect("lock the old file exclusively");
683
684        let pruned = prune_dir(t.path(), 8).expect("prune");
685        drop(lock);
686
687        assert!(old.exists(), "the locked file could not be deleted");
688        assert!(!mid.exists(), "the next-oldest file was tried and removed");
689        assert!(
690            !new.exists(),
691            "pruning kept reaching for newer files until the cap was actually met, \
692             not just until a fixed selection ran out"
693        );
694        assert!(
695            pruned.remaining <= 8,
696            "the achieved total must reach the cap: {pruned:?}"
697        );
698    }
699
700    #[test]
701    fn prune_leaves_a_missing_dir_alone() {
702        let t = tempfile::TempDir::new().expect("temp");
703        let out = prune_dir(&t.path().join("absent"), 1).expect("prune");
704        assert_eq!(out, Prune::default());
705    }
706
707    #[test]
708    fn prune_sweeps_directories_the_files_leave_empty() {
709        let t = tempfile::TempDir::new().expect("temp");
710        let deep = t.path().join("a").join("b").join("c");
711        fs::create_dir_all(&deep).expect("dirs");
712        fs::write(deep.join("f"), b"1234").expect("write");
713        let out = prune_dir(t.path(), 0).expect("prune");
714        assert_eq!(out.files, 1);
715        assert_eq!(out.remaining, 0);
716        assert!(!t.path().join("a").exists(), "empty chain swept");
717    }
718}