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    gate_in(free, min_free, "en")
32}
33
34/// Prefix of [`gate_in`]'s reason, per language. [`is_gate_reason`] matches
35/// both, so translating the text cannot stop triage's auto-requeue.
36const SHORT_EN: &str = "not enough free space to start a run:";
37const SHORT_JA: &str = "run を開始するための空き容量が不足しています:";
38/// Prefix of the reason for a failed measurement, per language.
39pub const UNMEASURED_EN: &str = "could not measure free space on ";
40/// See [`UNMEASURED_EN`].
41pub const UNMEASURED_JA: &str = "空き容量を測定できませんでした: ";
42
43/// [`gate`], with the reason in `language` (`en`, or Japanese; anything else
44/// is English).
45pub 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
60/// The reason for a measurement that failed, in `language`.
61pub 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
80/// Is `reason` one of this module's disk-gate reasons, in either language?
81/// What triage reads back to decide a hold is disk pressure.
82pub 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
88/// Is `size` past `limit`? One comparison, shared by the janitor and the
89/// health view, so both answer "is the cache over its cap" identically.
90pub fn over_limit(size: u64, limit: u64) -> bool {
91    size > limit
92}
93
94/// The path a rendered command sets `CARGO_TARGET_DIR=` to, if any.
95///
96/// magi never computes the cache path itself. The operator's `magi.toml` is
97/// the only place that knows it, and by the time a [`crate::config::Config`]
98/// exists that template has been rendered — so the concrete path is read back
99/// out of the verify commands (`CARGO_TARGET_DIR={{ vars.cache }}/magi-target
100/// cargo …` becomes `C:\…\Temp\magi-target`). This is what lets the janitor
101/// prune exactly the directory the gate and the seats build into. `None` when
102/// no command sets the variable: there is then no cache to aggregate or prune,
103/// and agents build wherever the repository's own defaults put them.
104///
105/// The value may be quoted with `'` or `"`; both are understood, as is no
106/// quoting (up to the next whitespace).
107pub 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
125/// Free bytes on the volume containing `path`.
126///
127/// There is no portable way to ask for this, so each platform runs its own
128/// tiny command, deliberately not a new dependency. The parsing halves are
129/// pure and asserted against fixture text; only the subprocess is live.
130pub fn free_bytes(path: &Path) -> Result<u64> {
131    free_bytes_by_os(path)
132}
133
134/// Free bytes on the volume containing `path`.
135#[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/// Free bytes on the volume containing `path`.
157#[cfg(windows)]
158fn free_bytes_by_os(path: &Path) -> Result<u64> {
159    // `fsutil volume diskfree` needs an elevated shell; the .NET DriveInfo in
160    // the Windows PowerShell that ships with the OS does not.
161    //
162    // DriveInfo is handed the **volume root**, never the path itself: its
163    // constructor accepts a drive letter or a root directory and throws on
164    // anything else, including every verbatim path. The queue stores repo
165    // paths as `\\?\C:\...` (that is what `std::path::absolute` yields for a
166    // canonicalised root), so passing the path through closed the disk gate
167    // for every task with `the disk gate refuses to let a run start blind` -
168    // nine tasks were `held` for a disk that had 164 GiB free.
169    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        // Without this the operator watches a console window blink open for
178        // every measurement - and the health view measures on every tick, so
179        // merely leaving the deck open in a browser flashed one every few
180        // seconds. `Quiet` exists for exactly this and the probe skipped it.
181        .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
194/// One `df -k -P` data row: `Filesystem 1024-blocks Used Available …`.
195///
196/// The value is 1024-byte blocks, so the parse returns bytes.
197pub fn parse_df_available(line: &str) -> Option<u64> {
198    let mut fields = line.split_whitespace();
199    fields.next()?; // filesystem
200    fields.next()?; // 1024-blocks
201    fields.next()?; // used
202    let blocks: u64 = fields.next()?.parse().ok()?;
203    Some(blocks.saturating_mul(1024))
204}
205
206/// The volume root of an absolute Windows path, as DriveInfo wants it:
207/// `C:\`, never `C:\Users\...` and never a verbatim `\\?\C:\...`.
208///
209/// Pure and platform-independent so the verbatim form - which is what the
210/// queue stores and what closed the disk gate on every task - is asserted
211/// without a Windows runner. `None` when there is no drive letter to name: a
212/// UNC share has no DriveInfo of its own, and a caller must say it cannot
213/// measure rather than invent a volume.
214pub fn volume_root(path: &Path) -> Option<String> {
215    let text = path.to_str()?;
216    // Verbatim (`\\?\C:\x`) and verbatim-UNC (`\\?\UNC\server\share`) prefixes.
217    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
229/// A bare unsigned integer line, which is all PowerShell prints for a long.
230pub fn parse_u64(text: &str) -> Option<u64> {
231    text.trim().parse().ok()
232}
233
234/// Total bytes under `path`, without following symlinks.
235///
236/// A symlinked directory counts as the link itself, not its contents: mutable
237/// worktrees are real directories, and following an accidental link into a
238/// clone of the repository would count the same bytes twice.
239pub 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            // `DirEntry::metadata` reports the entry itself, so a symlink is
257            // never traversed.
258            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/// What a prune removed, for the report.
272#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
273pub struct Prune {
274    /// Bytes actually freed.
275    pub freed: u64,
276    /// Files deleted.
277    pub files: usize,
278    /// Bytes still under the directory afterwards.
279    pub remaining: u64,
280}
281
282/// What [`plan_prune`] would remove, without removing it.
283///
284/// Oldest-first, the same order [`prune_dir`] deletes in — a caller previewing
285/// the plan (`magi cache prune --dry-run`) must see exactly what an immediate
286/// `prune_dir` call would do, not an approximation of it.
287#[derive(Debug, Clone, Default, PartialEq, Eq)]
288pub struct PrunePlan {
289    /// Files this plan would delete, oldest-first, paired with their size.
290    pub files: Vec<(PathBuf, u64)>,
291    /// Bytes this plan would free, assuming every listed file is removable.
292    pub freed: u64,
293    /// Bytes that would remain under the directory once the plan applies
294    /// cleanly.
295    pub remaining: u64,
296}
297
298/// The selection [`prune_dir`] would act on *if every one of these deletions
299/// succeeds*, computed without touching disk.
300///
301/// Read-only on purpose: a preview must never take the cache's lease (see
302/// `cache::maintenance_prune`'s doc) — it does not write anything, so it
303/// cannot race a build the way a real prune would, and a caller only wanting
304/// to show an operator a plan should not have to wait out contention to do
305/// it. This is necessarily an idealized selection, not a prediction of every
306/// file [`prune_dir`] will end up touching: a build finishing between the
307/// preview and a real prune can change what gets deleted, and so can a single
308/// locked file on the real pass, which - unlike this preview - has to keep
309/// reaching past its own plan for another (newer) file when an older one it
310/// counted on turns out to be unremovable. The CLI surface that shows this
311/// preview says so.
312#[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
342/// Delete files under `dir` oldest-first until its size is at or below `limit`.
343///
344/// The comparison is [`over_limit`], so a directory exactly at the cap is left
345/// alone. Oldest-first keeps the newest generation of artifacts — the one the
346/// next run reuses — and sheds the generations that only compile history. A
347/// deleted file costs the next build a rebuild of that one unit; deleting the
348/// whole directory would cost it everything, which is precisely the work
349/// [`prune_dir`] is keeping for it.
350///
351/// Empty directories left behind are swept depth-first, so cargo's deep
352/// `fingerprint`/`deps` trees do not outlive the files that made them.
353///
354/// Nothing is deleted when the directory is missing.
355pub 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    // Deliberately walks every file in `tree`, not a fixed plan computed up
374    // front: `total` only drops on a successful removal, so a file that is
375    // being read elsewhere (a concurrent build, a snapshot) and fails to
376    // delete on Windows costs this pass nothing but that one file - it is
377    // skipped, and the loop keeps reaching for the next-oldest file until the
378    // real, achieved total is at or below `limit` or there is nothing left to
379    // try. A version of this that instead deleted only a pre-computed
380    // selection would stop short of the cap on the first locked file, every
381    // pass, on exactly the machines where locked files are common.
382    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
400/// The one file a prune never selects, when it sits directly in the pruned root.
401///
402/// cargo writes `CACHEDIR.TAG` once, when it creates a target directory, so in
403/// a long-lived cache it is the oldest file and an oldest-first prune reaches it
404/// first. Without it `cargo clean -p` refuses ("missing or invalid
405/// `CACHEDIR.TAG` file"). The tag belongs to the cargo that made it, not to the
406/// prune. It still counts toward the directory's size; a same-named file in a
407/// subdirectory is an ordinary candidate.
408const PRESERVED_AT_ROOT: &str = "CACHEDIR.TAG";
409
410/// Files and directories under one root, walked up-front.
411///
412/// `files` are the deletion candidates; `total` also counts what is preserved.
413struct Tree {
414    total: u64,
415    files: Vec<(u128, u64, PathBuf)>,
416    dirs: Vec<(usize, PathBuf)>,
417}
418
419impl Tree {
420    /// Walk `dir`, collecting files (mtime-nanoseconds, size, path) and
421    /// directories (depth, path). `None` when the directory does not exist.
422    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        // Depth-first so directories are recorded before their contents; the
435        // dir list is then sorted by descending depth for the sweep.
436        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        // Oldest first, and on a tie the larger file: a whole generation of
466        // cargo artifacts is written within one filesystem timestamp tick, so
467        // mtime alone leaves the order to `read_dir` and the sort's
468        // instability - the same cache pruned twice would shed different
469        // files, and a test over two same-tick files passed on one platform
470        // and failed on another. Larger-first also reaches the cap in fewer
471        // deletions, which is fewer rebuilt units for the next run.
472        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
477/// Remove empty directories, deepest first, never the root itself.
478fn 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        // A zero floor disables the gate: the operator opted out.
512        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    /// DriveInfo takes a volume, and the queue hands out verbatim paths.
547    #[test]
548    fn the_volume_root_is_a_drive_not_the_path_it_came_from() {
549        // The form that closed the gate on every queued task: the queue
550        // records the repo as `\\?\C:\...`.
551        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        // Forward slashes reach magi from configs written by hand.
563        assert_eq!(
564            volume_root(Path::new("C:/Users/yukimemi/src")),
565            Some(r"C:\".to_owned())
566        );
567        // No drive to name: a share has no DriveInfo, and a POSIX path has no
568        // volume at all. The caller has to report that it cannot measure.
569        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        // Quoted forms survive spaces; a config with none stays None.
582        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        // Second occurrence is irrelevant: the first is what the build used
593        // (a command's environment applies once).
594        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        // Give the older file a measurably older mtime; a second is past the
620        // granularity of the filesystems magi runs on.
621        std::thread::sleep(std::time::Duration::from_millis(1_200));
622        fs::write(t.path().join("new"), b"xxxxx").expect("write");
623
624        // Cap above the total: nothing moves.
625        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        // Cap below: the oldest file goes, the new one stays.
636        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        // Applying `prune_dir` afterwards removes exactly what the plan named.
660        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        // cargo writes the tag once, so in a long-lived cache it is the oldest
669        // file and used to be the first thing an over-cap prune deleted.
670        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        // A same-named file below the root is an ordinary candidate.
680        let nested = t.path().join("sub").join("CACHEDIR.TAG");
681        fs::write(&nested, b"nn").expect("write");
682
683        let limit = 12; // exactly the tag: every other file must go
684        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    /// A file `prune_dir` cannot delete - locked by a concurrent reader on
717    /// Windows, the exact scenario the function's own doc calls out - must
718    /// not make the pass stop short of the cap. The achieved total only
719    /// drops on a successful removal, so the loop has to keep reaching for
720    /// newer files until *that* total clears `limit`, not stop once a
721    /// precomputed selection runs out.
722    #[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        // A share mode of 0 denies every other handle, including a delete -
738        // standing in for a file a concurrent build still has open, which is
739        // exactly the case `prune_dir`'s own doc calls out.
740        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}