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 a resource hold after emergency writer termination.
39pub const RUNNING_PRESSURE: &str = "disk pressure while a run was writing: ";
40/// Prefix of a failed free-space measurement.
41pub const UNMEASURED_EN: &str = "could not measure free space on ";
42/// See [`UNMEASURED_EN`].
43pub const UNMEASURED_JA: &str = "空き容量を測定できませんでした: ";
44
45/// [`gate`], with the reason in `language` (`en`, or Japanese; anything else
46/// is English).
47pub fn gate_in(free: u64, min_free: u64, language: &str) -> Option<String> {
48    if enough_space(free, min_free) {
49        None
50    } else if crate::lang::is_japanese(language) {
51        Some(format!(
52            "{SHORT_JA} 空き {free} バイト、`[disk] min_free_bytes` の要求は {min_free} バイト"
53        ))
54    } else {
55        Some(format!(
56            "{SHORT_EN} {free} bytes free, \
57             {min_free} required by `[disk] min_free_bytes`"
58        ))
59    }
60}
61
62/// The reason for a measurement that failed, in `language`.
63pub fn unmeasured_in(
64    path: &std::path::Path,
65    err: &dyn std::fmt::Display,
66    language: &str,
67) -> String {
68    if crate::lang::is_japanese(language) {
69        format!(
70            "{UNMEASURED_JA}{} ({err}); ディスクゲートは測定できないまま run を開始させません",
71            path.display()
72        )
73    } else {
74        format!(
75            "{UNMEASURED_EN}{} ({err}); the disk gate refuses \
76             to let a run start blind",
77            path.display()
78        )
79    }
80}
81
82/// Is `reason` one of this module's disk-gate reasons, in either language?
83/// What triage reads back to decide a hold is disk pressure.
84pub fn is_gate_reason(reason: &str) -> bool {
85    [
86        SHORT_EN,
87        SHORT_JA,
88        UNMEASURED_EN,
89        UNMEASURED_JA,
90        RUNNING_PRESSURE,
91    ]
92    .iter()
93    .any(|p| reason.starts_with(p))
94}
95
96/// Is `size` past `limit`? One comparison, shared by the janitor and the
97/// health view, so both answer "is the cache over its cap" identically.
98pub fn over_limit(size: u64, limit: u64) -> bool {
99    size > limit
100}
101
102/// A cache's target size under its cap and the volume's free-space floor.
103/// Pressure can reclaim idle cache files below the ordinary size cap.
104pub fn pressure_limit(size: u64, cap: u64, min_free: u64, free: u64) -> u64 {
105    cap.min(size.saturating_sub(min_free.saturating_sub(free)))
106}
107
108/// The path a rendered command sets `CARGO_TARGET_DIR=` to, if any.
109///
110/// magi never computes the cache path itself. The operator's `magi.toml` is
111/// the only place that knows it, and by the time a [`crate::config::Config`]
112/// exists that template has been rendered — so the concrete path is read back
113/// out of the verify commands (`CARGO_TARGET_DIR={{ vars.cache }}/magi-target
114/// cargo …` becomes `C:\…\Temp\magi-target`). This is what lets the janitor
115/// prune exactly the directory the gate and the seats build into. `None` when
116/// no command sets the variable: there is then no cache to aggregate or prune,
117/// and agents build wherever the repository's own defaults put them.
118///
119/// The value may be quoted with `'` or `"`; both are understood, as is no
120/// quoting (up to the next whitespace).
121pub fn extract_cargo_target_dir(command: &str) -> Option<PathBuf> {
122    const KEY: &str = "CARGO_TARGET_DIR=";
123    let rest = command.split_once(KEY)?.1.trim_start();
124    let value = if let Some(s) = rest.strip_prefix('\'') {
125        s.split('\'').next().unwrap_or("")
126    } else if let Some(s) = rest.strip_prefix('"') {
127        s.split('"').next().unwrap_or("")
128    } else {
129        let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
130        &rest[..end]
131    };
132    if value.is_empty() {
133        None
134    } else {
135        Some(PathBuf::from(value))
136    }
137}
138
139/// Free bytes on the volume containing `path`.
140///
141/// There is no portable way to ask for this, so each platform runs its own
142/// tiny command, deliberately not a new dependency. The parsing halves are
143/// pure and asserted against fixture text; only the subprocess is live.
144pub fn free_bytes(path: &Path) -> Result<u64> {
145    free_bytes_by_os(path)
146}
147
148/// Free bytes on the volume containing `path`.
149#[cfg(unix)]
150fn free_bytes_by_os(path: &Path) -> Result<u64> {
151    let out = std::process::Command::new("df")
152        .args(["-k", "-P"])
153        .arg(path)
154        .quiet()
155        .output()
156        .with_context(|| format!("run `df` for {}", path.display()))?;
157    if !out.status.success() {
158        bail!(
159            "`df` failed: {}",
160            String::from_utf8_lossy(&out.stderr).trim()
161        );
162    }
163    let text = String::from_utf8_lossy(&out.stdout);
164    text.lines()
165        .skip(1)
166        .find_map(parse_df_available)
167        .with_context(|| format!("parse `df` output for {}", path.display()))
168}
169
170/// Free bytes on the volume containing `path`.
171#[cfg(windows)]
172fn free_bytes_by_os(path: &Path) -> Result<u64> {
173    // `fsutil volume diskfree` needs an elevated shell; the .NET DriveInfo in
174    // the Windows PowerShell that ships with the OS does not.
175    //
176    // DriveInfo is handed the **volume root**, never the path itself: its
177    // constructor accepts a drive letter or a root directory and throws on
178    // anything else, including every verbatim path. The queue stores repo
179    // paths as `\\?\C:\...` (that is what `std::path::absolute` yields for a
180    // canonicalised root), so passing the path through closed the disk gate
181    // for every task with `the disk gate refuses to let a run start blind` -
182    // nine tasks were `held` for a disk that had 164 GiB free.
183    let abs = std::path::absolute(path)
184        .with_context(|| format!("absolute path for {}", path.display()))?;
185    let root = volume_root(&abs)
186        .with_context(|| format!("no volume root in {} to measure", abs.display()))?;
187    let quoted = root.replace('\'', "''");
188    let script = format!("[System.IO.DriveInfo]::new('{quoted}').AvailableFreeSpace");
189    let out = std::process::Command::new("powershell")
190        .args(["-NoProfile", "-NonInteractive", "-Command", &script])
191        // Without this the operator watches a console window blink open for
192        // every measurement - and the health view measures on every tick, so
193        // merely leaving the deck open in a browser flashed one every few
194        // seconds. `Quiet` exists for exactly this and the probe skipped it.
195        .quiet()
196        .output()
197        .with_context(|| format!("run PowerShell for {}", abs.display()))?;
198    if !out.status.success() {
199        bail!(
200            "PowerShell failed: {}",
201            String::from_utf8_lossy(&out.stderr).trim()
202        );
203    }
204    parse_u64(&String::from_utf8_lossy(&out.stdout))
205        .with_context(|| format!("parse PowerShell bytes for {}", abs.display()))
206}
207
208/// One `df -k -P` data row: `Filesystem 1024-blocks Used Available …`.
209///
210/// The value is 1024-byte blocks, so the parse returns bytes.
211pub fn parse_df_available(line: &str) -> Option<u64> {
212    let mut fields = line.split_whitespace();
213    fields.next()?; // filesystem
214    fields.next()?; // 1024-blocks
215    fields.next()?; // used
216    let blocks: u64 = fields.next()?.parse().ok()?;
217    Some(blocks.saturating_mul(1024))
218}
219
220/// The volume root of an absolute Windows path, as DriveInfo wants it:
221/// `C:\`, never `C:\Users\...` and never a verbatim `\\?\C:\...`.
222///
223/// Pure and platform-independent so the verbatim form - which is what the
224/// queue stores and what closed the disk gate on every task - is asserted
225/// without a Windows runner. `None` when there is no drive letter to name: a
226/// UNC share has no DriveInfo of its own, and a caller must say it cannot
227/// measure rather than invent a volume.
228pub fn volume_root(path: &Path) -> Option<String> {
229    let text = path.to_str()?;
230    // Verbatim (`\\?\C:\x`) and verbatim-UNC (`\\?\UNC\server\share`) prefixes.
231    let bare = text
232        .strip_prefix(r"\\?\")
233        .or_else(|| text.strip_prefix("//?/"))
234        .unwrap_or(text);
235    let mut chars = bare.chars();
236    let letter = chars.next()?;
237    if !letter.is_ascii_alphabetic() || chars.next()? != ':' {
238        return None;
239    }
240    Some(format!(r"{letter}:\"))
241}
242
243/// A bare unsigned integer line, which is all PowerShell prints for a long.
244pub fn parse_u64(text: &str) -> Option<u64> {
245    text.trim().parse().ok()
246}
247
248/// Total bytes under `path`, without following symlinks.
249///
250/// A symlinked directory counts as the link itself, not its contents: mutable
251/// worktrees are real directories, and following an accidental link into a
252/// clone of the repository would count the same bytes twice.
253pub fn dir_size(path: &Path) -> u64 {
254    let Ok(meta) = std::fs::symlink_metadata(path) else {
255        return 0;
256    };
257    if meta.is_file() {
258        return meta.len();
259    }
260    if !meta.is_dir() {
261        return 0;
262    }
263    let mut total = 0u64;
264    let mut stack = vec![path.to_path_buf()];
265    while let Some(dir) = stack.pop() {
266        let Ok(rd) = std::fs::read_dir(&dir) else {
267            continue;
268        };
269        for entry in rd.flatten() {
270            // `DirEntry::metadata` reports the entry itself, so a symlink is
271            // never traversed.
272            let Ok(meta) = entry.metadata() else {
273                continue;
274            };
275            if meta.is_dir() {
276                stack.push(entry.path());
277            } else if meta.is_file() {
278                total += meta.len();
279            }
280        }
281    }
282    total
283}
284
285/// What a prune removed, for the report.
286#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
287pub struct Prune {
288    /// Bytes actually freed.
289    pub freed: u64,
290    /// Files deleted.
291    pub files: usize,
292    /// Bytes still under the directory afterwards.
293    pub remaining: u64,
294}
295
296/// What [`plan_prune`] would remove, without removing it.
297///
298/// Oldest-first, the same order [`prune_dir`] deletes in — a caller previewing
299/// the plan (`magi cache prune --dry-run`) must see exactly what an immediate
300/// `prune_dir` call would do, not an approximation of it.
301#[derive(Debug, Clone, Default, PartialEq, Eq)]
302pub struct PrunePlan {
303    /// Files this plan would delete, oldest-first, paired with their size.
304    pub files: Vec<(PathBuf, u64)>,
305    /// Bytes this plan would free, assuming every listed file is removable.
306    pub freed: u64,
307    /// Bytes that would remain under the directory once the plan applies
308    /// cleanly.
309    pub remaining: u64,
310}
311
312/// The selection [`prune_dir`] would act on *if every one of these deletions
313/// succeeds*, computed without touching disk.
314///
315/// Read-only on purpose: a preview must never take the cache's lease (see
316/// `cache::maintenance_prune`'s doc) — it does not write anything, so it
317/// cannot race a build the way a real prune would, and a caller only wanting
318/// to show an operator a plan should not have to wait out contention to do
319/// it. This is necessarily an idealized selection, not a prediction of every
320/// file [`prune_dir`] will end up touching: a build finishing between the
321/// preview and a real prune can change what gets deleted, and so can a single
322/// locked file on the real pass, which - unlike this preview - has to keep
323/// reaching past its own plan for another (newer) file when an older one it
324/// counted on turns out to be unremovable. The CLI surface that shows this
325/// preview says so.
326#[must_use]
327pub fn plan_prune(dir: &Path, limit: u64) -> PrunePlan {
328    let Some(tree) = Tree::of(dir) else {
329        return PrunePlan::default();
330    };
331    let mut total = tree.total;
332    if !over_limit(total, limit) {
333        return PrunePlan {
334            files: Vec::new(),
335            freed: 0,
336            remaining: total,
337        };
338    }
339    let mut freed = 0u64;
340    let mut files = Vec::new();
341    for (_, size, path) in &tree.files {
342        if !over_limit(total, limit) {
343            break;
344        }
345        total = total.saturating_sub(*size);
346        freed += *size;
347        files.push((path.clone(), *size));
348    }
349    PrunePlan {
350        files,
351        freed,
352        remaining: total,
353    }
354}
355
356/// Delete files under `dir` oldest-first until its size is at or below `limit`.
357///
358/// The comparison is [`over_limit`], so a directory exactly at the cap is left
359/// alone. Oldest-first keeps the newest generation of artifacts — the one the
360/// next run reuses — and sheds the generations that only compile history. A
361/// deleted file costs the next build a rebuild of that one unit; deleting the
362/// whole directory would cost it everything, which is precisely the work
363/// [`prune_dir`] is keeping for it.
364///
365/// Empty directories left behind are swept depth-first, so cargo's deep
366/// `fingerprint`/`deps` trees do not outlive the files that made them.
367///
368/// Nothing is deleted when the directory is missing.
369pub fn prune_dir(dir: &Path, limit: u64) -> Result<Prune> {
370    let Some(tree) = Tree::of(dir) else {
371        return Ok(Prune {
372            freed: 0,
373            files: 0,
374            remaining: 0,
375        });
376    };
377    let mut total = tree.total;
378    if !over_limit(total, limit) {
379        return Ok(Prune {
380            freed: 0,
381            files: 0,
382            remaining: total,
383        });
384    }
385    let mut freed = 0u64;
386    let mut removed = 0usize;
387    // Deliberately walks every file in `tree`, not a fixed plan computed up
388    // front: `total` only drops on a successful removal, so a file that is
389    // being read elsewhere (a concurrent build, a snapshot) and fails to
390    // delete on Windows costs this pass nothing but that one file - it is
391    // skipped, and the loop keeps reaching for the next-oldest file until the
392    // real, achieved total is at or below `limit` or there is nothing left to
393    // try. A version of this that instead deleted only a pre-computed
394    // selection would stop short of the cap on the first locked file, every
395    // pass, on exactly the machines where locked files are common.
396    for (_, size, path) in &tree.files {
397        if !over_limit(total, limit) {
398            break;
399        }
400        if std::fs::remove_file(path).is_ok() {
401            total = total.saturating_sub(*size);
402            freed += *size;
403            removed += 1;
404        }
405    }
406    strip_empty_dirs(&tree.dirs);
407    Ok(Prune {
408        freed,
409        files: removed,
410        remaining: total,
411    })
412}
413
414/// The one file a prune never selects, when it sits directly in the pruned root.
415///
416/// cargo writes `CACHEDIR.TAG` once, when it creates a target directory, so in
417/// a long-lived cache it is the oldest file and an oldest-first prune reaches it
418/// first. Without it `cargo clean -p` refuses ("missing or invalid
419/// `CACHEDIR.TAG` file"). The tag belongs to the cargo that made it, not to the
420/// prune. It still counts toward the directory's size; a same-named file in a
421/// subdirectory is an ordinary candidate.
422const PRESERVED_AT_ROOT: &str = "CACHEDIR.TAG";
423
424/// Files and directories under one root, walked up-front.
425///
426/// `files` are the deletion candidates; `total` also counts what is preserved.
427struct Tree {
428    total: u64,
429    files: Vec<(u128, u64, PathBuf)>,
430    dirs: Vec<(usize, PathBuf)>,
431}
432
433impl Tree {
434    /// Walk `dir`, collecting files (mtime-nanoseconds, size, path) and
435    /// directories (depth, path). `None` when the directory does not exist.
436    fn of(dir: &Path) -> Option<Tree> {
437        if dir.symlink_metadata().ok()?.is_dir() {
438            Some(Tree::from_dir(dir))
439        } else {
440            None
441        }
442    }
443
444    fn from_dir(dir: &Path) -> Tree {
445        let mut total = 0u64;
446        let mut files = Vec::new();
447        let mut dirs = Vec::new();
448        // Depth-first so directories are recorded before their contents; the
449        // dir list is then sorted by descending depth for the sweep.
450        let mut stack: Vec<(usize, PathBuf)> = vec![(0, dir.to_path_buf())];
451        while let Some((depth, d)) = stack.pop() {
452            let Ok(rd) = std::fs::read_dir(&d) else {
453                continue;
454            };
455            for entry in rd.flatten() {
456                let Ok(meta) = entry.metadata() else {
457                    continue;
458                };
459                let path = entry.path();
460                if meta.is_dir() {
461                    dirs.push((depth + 1, path.clone()));
462                    stack.push((depth + 1, path));
463                } else if meta.is_file() {
464                    let size = meta.len();
465                    total += size;
466                    if depth == 0 && entry.file_name() == PRESERVED_AT_ROOT {
467                        continue;
468                    }
469                    let mtime = meta
470                        .modified()
471                        .ok()
472                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
473                        .map(|d| d.as_nanos())
474                        .unwrap_or(0);
475                    files.push((mtime, size, path));
476                }
477            }
478        }
479        // Oldest first, and on a tie the larger file: a whole generation of
480        // cargo artifacts is written within one filesystem timestamp tick, so
481        // mtime alone leaves the order to `read_dir` and the sort's
482        // instability - the same cache pruned twice would shed different
483        // files, and a test over two same-tick files passed on one platform
484        // and failed on another. Larger-first also reaches the cap in fewer
485        // deletions, which is fewer rebuilt units for the next run.
486        files.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)).then(a.2.cmp(&b.2)));
487        Tree { total, files, dirs }
488    }
489}
490
491/// Remove empty directories, deepest first, never the root itself.
492fn strip_empty_dirs(dirs: &[(usize, PathBuf)]) {
493    let mut by_depth: Vec<&PathBuf> = dirs.iter().map(|(_, d)| d).collect();
494    by_depth.sort_unstable_by_key(|d| std::cmp::Reverse(d.iter().count()));
495    for d in by_depth {
496        let _ = std::fs::remove_dir(d);
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    #[test]
503    fn gate_reasons_follow_the_language_and_stay_recognisable() {
504        let en = gate_in(1, 10, "en").unwrap();
505        let ja = gate_in(1, 10, "日本語").unwrap();
506        assert_eq!(gate_in(1, 10, "fr").unwrap(), en);
507        assert_eq!(en, gate(1, 10).unwrap());
508        assert!(ja.starts_with("run を開始"), "{ja}");
509        assert!(is_gate_reason(&en) && is_gate_reason(&ja));
510        let p = std::path::Path::new("/x");
511        let m_ja = unmeasured_in(p, &"boom", "ja");
512        assert!(m_ja.contains("測定できませんでした") && is_gate_reason(&m_ja));
513        assert!(is_gate_reason(&unmeasured_in(p, &"boom", "en")));
514        assert!(!is_gate_reason("something else"));
515    }
516
517    use super::*;
518    use std::fs;
519
520    #[test]
521    fn the_free_space_predicate_is_the_boundary() {
522        assert!(enough_space(100, 100));
523        assert!(enough_space(101, 100));
524        assert!(!enough_space(99, 100));
525        // A zero floor disables the gate: the operator opted out.
526        assert!(enough_space(0, 0));
527    }
528
529    #[test]
530    fn the_gate_text_conveys_both_numbers_and_opens_with_room() {
531        assert_eq!(
532            gate(9, 10).expect("closed"),
533            "not enough free space to start a run: 9 bytes free, 10 required by `[disk] min_free_bytes`"
534        );
535        assert_eq!(gate(10, 10), None, "exactly at the floor is open");
536        assert_eq!(gate(10_000, 0), None, "a zero floor is an opt-out");
537    }
538
539    #[test]
540    fn over_limit_uses_strict_greater_than() {
541        assert!(over_limit(11, 10));
542        assert!(!over_limit(10, 10));
543        assert!(!over_limit(9, 10));
544    }
545
546    #[test]
547    fn df_row_parses_1024_blocks_into_bytes() {
548        let row = "/dev/sda1 976762584 808522388 168240196 83% /home";
549        assert_eq!(parse_df_available(row), Some(168_240_196 * 1024));
550        assert_eq!(parse_df_available("garbage"), None);
551        assert_eq!(parse_df_available("a b c x"), None);
552    }
553
554    #[test]
555    fn a_powershell_number_is_one_unsigned_integer() {
556        assert_eq!(parse_u64("     82072211456\r\n"), Some(82_072_211_456));
557        assert_eq!(parse_u64("nah"), None);
558    }
559
560    /// DriveInfo takes a volume, and the queue hands out verbatim paths.
561    #[test]
562    fn the_volume_root_is_a_drive_not_the_path_it_came_from() {
563        // The form that closed the gate on every queued task: the queue
564        // records the repo as `\\?\C:\...`.
565        assert_eq!(
566            volume_root(Path::new(
567                r"\\?\C:\Users\yukimemi\src\github.com\yukimemi\magi"
568            )),
569            Some(r"C:\".to_owned())
570        );
571        assert_eq!(
572            volume_root(Path::new(r"C:\Users\yukimemi")),
573            Some(r"C:\".to_owned())
574        );
575        assert_eq!(volume_root(Path::new(r"D:\")), Some(r"D:\".to_owned()));
576        // Forward slashes reach magi from configs written by hand.
577        assert_eq!(
578            volume_root(Path::new("C:/Users/yukimemi/src")),
579            Some(r"C:\".to_owned())
580        );
581        // No drive to name: a share has no DriveInfo, and a POSIX path has no
582        // volume at all. The caller has to report that it cannot measure.
583        assert_eq!(volume_root(Path::new(r"\\server\share\dir")), None);
584        assert_eq!(volume_root(Path::new(r"\\?\UNC\server\share")), None);
585        assert_eq!(volume_root(Path::new("/home/yukimemi")), None);
586    }
587
588    #[test]
589    fn the_cache_dir_is_read_back_out_of_a_rendered_command() {
590        let cmd = r"CARGO_TARGET_DIR=C:\Users\me\Temp\magi-target cargo make check";
591        assert_eq!(
592            extract_cargo_target_dir(cmd),
593            Some(PathBuf::from(r"C:\Users\me\Temp\magi-target"))
594        );
595        // Quoted forms survive spaces; a config with none stays None.
596        assert_eq!(
597            extract_cargo_target_dir(r"CARGO_TARGET_DIR='/tmp/a b' cargo test"),
598            Some(PathBuf::from("/tmp/a b"))
599        );
600        assert_eq!(
601            extract_cargo_target_dir(r#"CARGO_TARGET_DIR="/tmp/qq" cargo test"#),
602            Some(PathBuf::from("/tmp/qq"))
603        );
604        assert_eq!(extract_cargo_target_dir("cargo make check"), None);
605        assert_eq!(extract_cargo_target_dir("CARGO_TARGET_DIR="), None);
606        // Second occurrence is irrelevant: the first is what the build used
607        // (a command's environment applies once).
608        let two = "CARGO_TARGET_DIR=/first and CARGO_TARGET_DIR=/second cargo x";
609        assert_eq!(extract_cargo_target_dir(two), Some(PathBuf::from("/first")));
610    }
611
612    #[test]
613    fn dir_size_is_zero_for_missing_and_counts_files_without_following_links() {
614        let t = tempfile::TempDir::new().expect("temp");
615        assert_eq!(dir_size(&t.path().join("nope")), 0);
616        fs::write(t.path().join("a"), b"12345").expect("write");
617        fs::create_dir(t.path().join("sub")).expect("dir");
618        fs::write(t.path().join("sub").join("b"), b"678").expect("write");
619        assert_eq!(dir_size(t.path()), 8);
620        #[cfg(unix)]
621        {
622            std::os::unix::fs::symlink(t.path().join("sub"), t.path().join("link"))
623                .expect("symlink");
624            assert_eq!(dir_size(t.path()), 8, "a link is counted as a link");
625        }
626    }
627
628    #[test]
629    fn prune_deletes_oldest_first_until_the_cap_is_met() {
630        let t = tempfile::TempDir::new().expect("temp");
631        let old = t.path().join("old");
632        fs::write(&old, b"yyyy").expect("write");
633        // Give the older file a measurably older mtime; a second is past the
634        // granularity of the filesystems magi runs on.
635        std::thread::sleep(std::time::Duration::from_millis(1_200));
636        fs::write(t.path().join("new"), b"xxxxx").expect("write");
637
638        // Cap above the total: nothing moves.
639        let keep = prune_dir(t.path(), 9).expect("prune");
640        assert_eq!(
641            keep,
642            Prune {
643                freed: 0,
644                files: 0,
645                remaining: 9
646            }
647        );
648
649        // Cap below: the oldest file goes, the new one stays.
650        let pruned = prune_dir(t.path(), 6).expect("prune");
651        assert!(pruned.freed > 0);
652        assert_eq!(pruned.files, 1);
653        assert_eq!(pruned.remaining, 5);
654        assert!(!old.exists(), "the older file is the one shed");
655        assert!(t.path().join("new").exists());
656    }
657
658    #[test]
659    fn plan_prune_selects_what_prune_dir_would_delete_without_deleting_it() {
660        let t = tempfile::TempDir::new().expect("temp");
661        let old = t.path().join("old");
662        fs::write(&old, b"yyyy").expect("write");
663        std::thread::sleep(std::time::Duration::from_millis(1_200));
664        fs::write(t.path().join("new"), b"xxxxx").expect("write");
665
666        let plan = plan_prune(t.path(), 6);
667        assert_eq!(plan.files, vec![(old.clone(), 4)]);
668        assert_eq!(plan.freed, 4);
669        assert_eq!(plan.remaining, 5);
670        assert!(old.exists(), "a plan never deletes anything");
671        assert!(t.path().join("new").exists());
672
673        // Applying `prune_dir` afterwards removes exactly what the plan named.
674        let pruned = prune_dir(t.path(), 6).expect("prune");
675        assert_eq!(pruned.freed, plan.freed);
676        assert_eq!(pruned.remaining, plan.remaining);
677        assert!(!old.exists());
678    }
679
680    #[test]
681    fn a_root_cachedir_tag_survives_even_as_the_oldest_file() {
682        // cargo writes the tag once, so in a long-lived cache it is the oldest
683        // file and used to be the first thing an over-cap prune deleted.
684        let t = tempfile::TempDir::new().expect("temp");
685        let tag = t.path().join("CACHEDIR.TAG");
686        fs::write(&tag, b"Signature: x").expect("write");
687        let f = fs::File::options().write(true).open(&tag).expect("open");
688        f.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1))
689            .expect("mtime");
690        drop(f);
691        fs::write(t.path().join("a"), b"aaaa").expect("write");
692        fs::create_dir(t.path().join("sub")).expect("mkdir");
693        // A same-named file below the root is an ordinary candidate.
694        let nested = t.path().join("sub").join("CACHEDIR.TAG");
695        fs::write(&nested, b"nn").expect("write");
696
697        let limit = 12; // exactly the tag: every other file must go
698        let plan = plan_prune(t.path(), limit);
699        assert!(plan.files.iter().all(|(p, _)| p != &tag));
700        assert!(plan.remaining <= limit);
701
702        let pruned = prune_dir(t.path(), limit).expect("prune");
703        assert!(tag.exists(), "the root tag is never pruned");
704        assert!(pruned.remaining <= limit);
705        assert_eq!(pruned.freed, plan.freed);
706        assert_eq!(pruned.remaining, plan.remaining);
707        assert!(!nested.exists());
708    }
709
710    #[test]
711    fn plan_prune_is_empty_under_the_cap_and_for_a_missing_dir() {
712        let t = tempfile::TempDir::new().expect("temp");
713        fs::write(t.path().join("a"), b"12345").expect("write");
714        let plan = plan_prune(t.path(), 100);
715        assert_eq!(
716            plan,
717            PrunePlan {
718                files: Vec::new(),
719                freed: 0,
720                remaining: 5,
721            }
722        );
723
724        assert_eq!(
725            plan_prune(&t.path().join("absent"), 0),
726            PrunePlan::default()
727        );
728    }
729
730    /// A file `prune_dir` cannot delete - locked by a concurrent reader on
731    /// Windows, the exact scenario the function's own doc calls out - must
732    /// not make the pass stop short of the cap. The achieved total only
733    /// drops on a successful removal, so the loop has to keep reaching for
734    /// newer files until *that* total clears `limit`, not stop once a
735    /// precomputed selection runs out.
736    #[cfg(windows)]
737    #[test]
738    fn prune_keeps_reaching_past_an_undeletable_file_to_still_reach_the_cap() {
739        use std::os::windows::fs::OpenOptionsExt as _;
740
741        let t = tempfile::TempDir::new().expect("temp");
742        let old = t.path().join("old");
743        fs::write(&old, b"yyyy").expect("write");
744        std::thread::sleep(std::time::Duration::from_millis(1_200));
745        let mid = t.path().join("mid");
746        fs::write(&mid, b"zzzz").expect("write");
747        std::thread::sleep(std::time::Duration::from_millis(1_200));
748        let new = t.path().join("new");
749        fs::write(&new, b"xxxxx").expect("write");
750
751        // A share mode of 0 denies every other handle, including a delete -
752        // standing in for a file a concurrent build still has open, which is
753        // exactly the case `prune_dir`'s own doc calls out.
754        let lock = std::fs::OpenOptions::new()
755            .read(true)
756            .share_mode(0)
757            .open(&old)
758            .expect("lock the old file exclusively");
759
760        let pruned = prune_dir(t.path(), 8).expect("prune");
761        drop(lock);
762
763        assert!(old.exists(), "the locked file could not be deleted");
764        assert!(!mid.exists(), "the next-oldest file was tried and removed");
765        assert!(
766            !new.exists(),
767            "pruning kept reaching for newer files until the cap was actually met, \
768             not just until a fixed selection ran out"
769        );
770        assert!(
771            pruned.remaining <= 8,
772            "the achieved total must reach the cap: {pruned:?}"
773        );
774    }
775
776    #[test]
777    fn prune_leaves_a_missing_dir_alone() {
778        let t = tempfile::TempDir::new().expect("temp");
779        let out = prune_dir(&t.path().join("absent"), 1).expect("prune");
780        assert_eq!(out, Prune::default());
781    }
782
783    #[test]
784    fn prune_sweeps_directories_the_files_leave_empty() {
785        let t = tempfile::TempDir::new().expect("temp");
786        let deep = t.path().join("a").join("b").join("c");
787        fs::create_dir_all(&deep).expect("dirs");
788        fs::write(deep.join("f"), b"1234").expect("write");
789        let out = prune_dir(t.path(), 0).expect("prune");
790        assert_eq!(out.files, 1);
791        assert_eq!(out.remaining, 0);
792        assert!(!t.path().join("a").exists(), "empty chain swept");
793    }
794}
795
796#[cfg(test)]
797mod pressure_tests {
798    #[test]
799    fn pressure_reclaims_below_cap_without_underflow() {
800        assert_eq!(super::pressure_limit(100, 200, 80, 30), 50);
801        assert_eq!(super::pressure_limit(100, 200, 80, 100), 100);
802        assert_eq!(super::pressure_limit(100, 200, 800, 0), 0);
803        assert_eq!(super::pressure_limit(100, 50, 0, 0), 50);
804    }
805}