mbx 0.3.0

A build cache for Rust projects
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
//! End-to-end coverage for `mbx build`.
//!
//! Each test drives the real binary over a throwaway project with no
//! dependencies, so nothing here needs the network.

use std::path::Path;
use std::process::Command;

fn write_project(directory: &Path) {
    write_named_project(directory, "fixture");
}

/// Write the fixture under `name`.
///
/// The name reaches the lockfile, and the lockfile is what the build identity
/// is keyed on, so two differently named fixtures are two different identities
/// -- which is what it takes to tell one checkout's artifacts from another's.
fn write_named_project(directory: &Path, name: &str) {
    std::fs::create_dir_all(directory.join("src")).unwrap();
    std::fs::write(
        directory.join("Cargo.toml"),
        format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
    )
    .unwrap();
    std::fs::write(
        directory.join("src/lib.rs"),
        "pub fn double(value: u32) -> u32 {\n    value * 2\n}\n",
    )
    .unwrap();
    // Manifest identity follows the lockfile, and cargo would otherwise write
    // it during the first build -- changing the identity between two runs that
    // are supposed to share a manifest.
    let status = Command::new(cargo())
        .current_dir(directory)
        .args(["generate-lockfile", "--offline"])
        .status()
        .expect("cargo should run");
    assert!(status.success(), "the fixture should resolve offline");
}

/// Write a workspace whose dependency builds before a member that fails.
fn write_partially_failing_project(directory: &Path) {
    std::fs::create_dir_all(directory.join("good/src")).unwrap();
    std::fs::create_dir_all(directory.join("bad/src")).unwrap();
    std::fs::write(
        directory.join("Cargo.toml"),
        "[workspace]\nmembers = [\"good\", \"bad\"]\nresolver = \"2\"\n",
    )
    .unwrap();
    std::fs::write(
        directory.join("good/Cargo.toml"),
        "[package]\nname = \"good\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
    )
    .unwrap();
    std::fs::write(directory.join("good/src/lib.rs"), "pub fn good() {}\n").unwrap();
    std::fs::write(
        directory.join("bad/Cargo.toml"),
        "[package]\nname = \"bad\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\ngood = { path = \"../good\" }\n",
    )
    .unwrap();
    std::fs::write(
        directory.join("bad/src/lib.rs"),
        "use good as _;\ncompile_error!(\"expected failure\");\n",
    )
    .unwrap();
    let status = Command::new(cargo())
        .current_dir(directory)
        .args(["generate-lockfile", "--offline"])
        .status()
        .expect("cargo should run");
    assert!(status.success(), "the fixture should resolve offline");
}

fn cargo() -> std::ffi::OsString {
    std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())
}

/// Build `project` against `store`, returning the run's statistics.
fn build(project: &Path, store: &Path, report: &Path) -> serde_json::Value {
    build_with(project, store, report, &[]).0
}

/// Build as `build` does, with `settings` added to the environment.
///
/// Returns what the build said on stderr alongside its statistics, because some
/// of what mbx reports -- a sweep, for one -- is only ever said there.
fn build_with(
    project: &Path,
    store: &Path,
    report: &Path,
    settings: &[(&str, &str)],
) -> (serde_json::Value, String) {
    let mut command = Command::new(env!("CARGO_BIN_EXE_mbx"));
    command
        .current_dir(project)
        .args(["build", "build", "--offline"])
        .env("MBX_CACHE_DIR", store)
        .env("MBX_STATS_REPORT", report)
        // Cargo's own environment for this test would otherwise redirect the
        // fixture's output into this crate's target directory.
        .env_remove("CARGO_TARGET_DIR")
        // All three decide whether cargo compiles incrementally, so a test that
        // says nothing about it must not inherit an answer from the machine it
        // runs on. CARGO_INCREMENTAL is the one that bites: an enabled build
        // defers to it, and `Swatinem/rust-cache` sets it to 0 for the whole
        // job, so leaving it would make this suite pass locally and fail in CI.
        .env_remove("MBX_INCREMENTAL")
        .env_remove("CARGO_INCREMENTAL")
        .env_remove("CI")
        // Same reason: a test asserting the default cross-checkout behaviour
        // must not read an answer out of the developer's environment.
        .env_remove("MBX_SHARE_OUT_DIR");
    for (name, value) in settings {
        command.env(name, value);
    }
    let output = command.output().expect("mbx should run");
    assert!(
        output.status.success(),
        "build failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let stats = std::fs::read(report).expect("a statistics report should be written");
    (
        serde_json::from_slice(&stats).expect("the report should be JSON"),
        String::from_utf8_lossy(&output.stderr).into_owned(),
    )
}

/// Run `mbx` against `store` and return its stdout.
fn mbx(store: &Path, arguments: &[&str]) -> String {
    let output = Command::new(env!("CARGO_BIN_EXE_mbx"))
        .args(arguments)
        .env("MBX_CACHE_DIR", store)
        .output()
        .expect("mbx should run");
    assert!(
        output.status.success(),
        "{arguments:?} failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    String::from_utf8_lossy(&output.stdout).into_owned()
}

/// The bytes `mbx gc` weighs against its budget.
fn store_bytes(store: &Path) -> u64 {
    tree_bytes(&store.join("actions/cas")) + tree_bytes(&store.join("actions/action-results"))
}

/// Total size of every file under `directory`.
fn tree_bytes(directory: &Path) -> u64 {
    let mut total = 0;
    let mut pending = vec![directory.to_path_buf()];
    while let Some(next) = pending.pop() {
        let Ok(listing) = std::fs::read_dir(&next) else {
            continue;
        };
        for entry in listing.flatten() {
            let metadata = entry.metadata().expect("the entry should be readable");
            if metadata.is_dir() {
                pending.push(entry.path());
            } else if metadata.is_file() {
                total += metadata.len();
            }
        }
    }
    total
}

fn count(stats: &serde_json::Value, field: &str) -> u64 {
    stats[field]
        .as_u64()
        .unwrap_or_else(|| panic!("{field} should be a number"))
}

#[test]
fn incremental_is_opt_in_and_reaches_cargo() {
    let store = tempfile::tempdir().unwrap();
    let project = tempfile::tempdir().unwrap();
    let reports = tempfile::tempdir().unwrap();
    write_project(project.path());

    build(
        project.path(),
        store.path(),
        &reports.path().join("default.json"),
    );
    assert_eq!(
        incremental_sessions(project.path()),
        0,
        "the default build should still force CARGO_INCREMENTAL=0"
    );

    let (stats, _) = build_with(
        project.path(),
        store.path(),
        &reports.path().join("incremental.json"),
        &[("MBX_INCREMENTAL", "1")],
    );
    assert!(
        incremental_sessions(project.path()) > 0,
        "cargo should have compiled the member incrementally: {stats}"
    );
}

/// How many incremental sessions rustc left behind. Cargo creates the directory
/// either way, so its contents are the only evidence that anything used it.
fn incremental_sessions(project: &Path) -> usize {
    match std::fs::read_dir(project.join("target/debug/incremental")) {
        Ok(entries) => entries.count(),
        Err(_) => 0,
    }
}

#[test]
fn restores_a_wiped_target_directory_from_the_store() {
    let store = tempfile::tempdir().unwrap();
    let project = tempfile::tempdir().unwrap();
    let reports = tempfile::tempdir().unwrap();
    write_project(project.path());

    let cold = build(
        project.path(),
        store.path(),
        &reports.path().join("cold.json"),
    );
    assert_eq!(count(&cold, "hits"), 0, "a cold build cannot hit");
    assert!(
        count(&cold, "stored_bytes") > 0,
        "a cold build should publish its outputs: {cold}"
    );
    // A cold target directory leaves nothing to derive an action key from, so
    // these compilations run without the cache ever being consulted. Reported
    // apart from misses: counting them as zero of both says the cache was asked
    // and found nothing, which is not what happened.
    assert_eq!(
        count(&cold, "misses"),
        0,
        "a cold build looks nothing up, so it cannot miss: {cold}"
    );
    assert!(
        count(&cold, "unconsulted") > 0,
        "a cold build should report the compilations it had no key for: {cold}"
    );

    // Load-bearing, not cleanup: the wipe is what forces the warm build to
    // restore from the store. Left in place, cargo would find the cold build's
    // outputs and the test would pass without exercising the cache at all.
    std::fs::remove_dir_all(project.path().join("target")).unwrap();

    let warm = build(
        project.path(),
        store.path(),
        &reports.path().join("warm.json"),
    );
    assert!(
        count(&warm, "hits") > 0,
        "the rebuilt target directory should be restored: {warm}"
    );
    // A hit that never lands on disk is still a broken restore, so check the
    // artifact itself rather than trusting the counter.
    assert!(
        project.path().join("target/debug/libfixture.rlib").exists(),
        "the restored artifact should be materialized: {warm}"
    );
    assert_eq!(
        count(&warm, "compiler_invocations_avoided"),
        count(&warm, "hits")
    );
}

#[test]
fn a_second_checkout_starts_warm() {
    let store = tempfile::tempdir().unwrap();
    let first = tempfile::tempdir().unwrap();
    let second = tempfile::tempdir().unwrap();
    let reports = tempfile::tempdir().unwrap();
    write_project(first.path());
    write_project(second.path());

    build(
        first.path(),
        store.path(),
        &reports.path().join("first.json"),
    );
    let warm = build(
        second.path(),
        store.path(),
        &reports.path().join("second.json"),
    );

    assert!(
        count(&warm, "hits") > 0,
        "a checkout at another path should reuse the first build: {warm}"
    );
}

/// How a fixture's library uses its build script's output.
#[derive(Clone, Copy)]
enum Generated {
    /// A cfg only, so the compilation never reads `OUT_DIR`.
    Cfg,
    /// Includes the generated file. `OUT_DIR` becomes an input, but only rustc
    /// records the path, so `--remap-path-prefix` can take it back out.
    Include,
    /// Keeps `OUT_DIR` in a string constant. That lands in the artifact itself,
    /// where no remapping reaches it.
    Text,
}

/// Write a fixture whose build script generates code, used as `generated` says.
fn write_generated_project(directory: &Path, generated: Generated) {
    std::fs::create_dir_all(directory.join("src")).unwrap();
    std::fs::write(
        directory.join("Cargo.toml"),
        "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n         [lints.rust]\nunexpected_cfgs = { level = \"allow\" }\n",
    )
    .unwrap();
    std::fs::write(
        directory.join("build.rs"),
        "use std::{env, fs, path::PathBuf};\n         fn main() {\n         \u{20}   let out = PathBuf::from(env::var(\"OUT_DIR\").unwrap());\n         \u{20}   fs::write(out.join(\"generated.rs\"), \"pub const VALUE: u32 = 7;\\n\").unwrap();\n         \u{20}   println!(\"cargo:rustc-cfg=generated\");\n         }\n",
    )
    .unwrap();
    let lib = match generated {
        Generated::Cfg => "#[cfg(generated)]\npub fn value() -> u32 { 7 }\n".to_string(),
        Generated::Include => {
            "include!(concat!(env!(\"OUT_DIR\"), \"/generated.rs\"));\npub fn value() -> u32 { VALUE }\n"
                .to_string()
        }
        Generated::Text => {
            "include!(concat!(env!(\"OUT_DIR\"), \"/generated.rs\"));\n\
             pub const WHERE: &str = env!(\"OUT_DIR\");\n\
             pub fn value() -> u32 { VALUE }\n"
                .to_string()
        }
    };
    std::fs::write(directory.join("src/lib.rs"), lib).unwrap();
    let status = Command::new(cargo())
        .current_dir(directory)
        .args(["generate-lockfile", "--offline"])
        .status()
        .expect("cargo should run");
    assert!(status.success());
}

/// A build script alone does not stop two checkouts sharing a cache. Consuming
/// `OUT_DIR` does, because its value is an absolute path the compilation reads
/// and could bake into the artifact.
#[test]
fn out_dir_decides_whether_two_checkouts_share() {
    for (generated, expect_hits) in [(Generated::Cfg, true), (Generated::Include, false)] {
        assert_eq!(
            two_checkouts_share(generated, &[]),
            expect_hits,
            "the default should not share a compilation that reads OUT_DIR"
        );
    }
}

/// With sharing on, the compilation is remapped so rustc records the
/// placeholder instead of the real `OUT_DIR`, and the include-only shape crosses
/// checkouts. The shape that keeps the value in a string still does not: the
/// remapping cannot reach into the artifact, and mbx reads the outputs rather
/// than assuming it can.
///
/// The pair is the test. Either half alone would pass for the wrong reason.
#[test]
fn sharing_out_dir_crosses_checkouts_only_where_the_artifact_allows_it() {
    let sharing = [("MBX_SHARE_OUT_DIR", "1")];
    for (generated, expect_hits) in [(Generated::Include, true), (Generated::Text, false)] {
        assert_eq!(
            two_checkouts_share(generated, &sharing),
            expect_hits,
            "sharing changed the wrong shape"
        );
    }
}

/// Build the same fixture in two checkouts, reporting whether the second one
/// reused anything from the first.
fn two_checkouts_share(generated: Generated, settings: &[(&str, &str)]) -> bool {
    let store = tempfile::tempdir().unwrap();
    let first = tempfile::tempdir().unwrap();
    let second = tempfile::tempdir().unwrap();
    let reports = tempfile::tempdir().unwrap();
    write_generated_project(first.path(), generated);
    write_generated_project(second.path(), generated);

    build_with(
        first.path(),
        store.path(),
        &reports.path().join("first.json"),
        settings,
    );
    let (stats, _) = build_with(
        second.path(),
        store.path(),
        &reports.path().join("second.json"),
        settings,
    );
    count(&stats, "hits") > 0
}

#[test]
fn an_empty_store_reports_nothing() {
    let store = tempfile::tempdir().unwrap();
    let output = Command::new(env!("CARGO_BIN_EXE_mbx"))
        .args(["cache", "stats"])
        .env("MBX_CACHE_DIR", store.path())
        .output()
        .expect("mbx should run");

    assert!(output.status.success());
    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("objects: 0"), "unexpected output: {stdout}");
}

#[test]
fn a_build_records_the_checkout_it_ran_in() {
    let store = tempfile::tempdir().unwrap();
    let project = tempfile::tempdir().unwrap();
    let reports = tempfile::tempdir().unwrap();
    write_project(project.path());

    build(
        project.path(),
        store.path(),
        &reports.path().join("cold.json"),
    );

    // The record is what later tells the collector this checkout exists, so a
    // build that leaves none has silently opted out of being protected.
    let records = store.path().join("actions/checkouts/v1");
    assert!(
        tree_bytes(&records) > 0,
        "the build should record its checkout under {}",
        records.display()
    );
}

#[test]
fn a_failed_build_records_the_compilations_it_completed() {
    let store = tempfile::tempdir().unwrap();
    let project = tempfile::tempdir().unwrap();
    write_partially_failing_project(project.path());

    let output = Command::new(env!("CARGO_BIN_EXE_mbx"))
        .current_dir(project.path())
        .args(["build", "build", "--workspace", "--offline"])
        .env("MBX_CACHE_DIR", store.path())
        .env("MBX_GC_AUTO", "0")
        .env_remove("CARGO_TARGET_DIR")
        .env_remove("CARGO_INCREMENTAL")
        .output()
        .expect("mbx should run");

    assert!(!output.status.success(), "the bad member should fail");
    assert!(
        tree_bytes(&store.path().join("actions/task-manifests/v1")) > 0,
        "the successful dependency should still be recorded as reachable"
    );
}

#[test]
fn a_build_sweeps_the_store_to_its_budget() {
    let store = tempfile::tempdir().unwrap();
    let project = tempfile::tempdir().unwrap();
    let reports = tempfile::tempdir().unwrap();
    write_project(project.path());

    let (_, stderr) = build_with(
        project.path(),
        store.path(),
        &reports.path().join("cold.json"),
        // A one-byte budget swept every build: nothing this build stored can
        // stay, so the sweep is unambiguous.
        &[("MBX_GC_MAX_SIZE", "1"), ("MBX_GC_INTERVAL", "0")],
    );

    assert!(
        stderr.contains("gc: evicted"),
        "the sweep should say what it evicted: {stderr}"
    );
    let stats = mbx(store.path(), &["cache", "stats"]);
    assert!(
        stats.contains("objects: 0"),
        "the store should be swept empty: {stats}"
    );
}

#[test]
fn automatic_sweeps_can_be_turned_off() {
    let store = tempfile::tempdir().unwrap();
    let project = tempfile::tempdir().unwrap();
    let reports = tempfile::tempdir().unwrap();
    write_project(project.path());

    let (_, stderr) = build_with(
        project.path(),
        store.path(),
        &reports.path().join("cold.json"),
        &[
            ("MBX_GC_MAX_SIZE", "1"),
            ("MBX_GC_INTERVAL", "0"),
            ("MBX_GC_AUTO", "0"),
        ],
    );

    assert!(!stderr.contains("gc:"), "no sweep should run: {stderr}");
    let stats = mbx(store.path(), &["cache", "stats"]);
    assert!(
        !stats.contains("objects: 0"),
        "the store should be left alone: {stats}"
    );
}

/// Two checkouts, one deleted, a budget that fits only one of them.
///
/// Plain recency would keep the deleted checkout's newer artifacts and evict
/// the surviving one's, so the survivor coming back warm is the whole claim.
#[test]
fn deleting_a_checkout_releases_what_only_it_used() {
    let store = tempfile::tempdir().unwrap();
    let reports = tempfile::tempdir().unwrap();
    let surviving = tempfile::tempdir().unwrap();
    let deleted = tempfile::tempdir().unwrap();
    write_named_project(surviving.path(), "surviving");
    write_named_project(deleted.path(), "deleted");

    build(
        surviving.path(),
        store.path(),
        &reports.path().join("surviving.json"),
    );
    // Budget the store as it stood with one checkout in it, plus slack. The
    // slack matters: an action result is only dropped once its objects are
    // gone, which happens after eviction has already decided how deep to go, so
    // a budget set to the exact byte forces eviction one object further than
    // the arithmetic suggests. At a real budget that rounding is noise; at this
    // scale it is the whole margin.
    let budget = store_bytes(store.path()) + 4096;
    build(
        deleted.path(),
        store.path(),
        &reports.path().join("deleted.json"),
    );
    std::fs::remove_dir_all(deleted.path()).unwrap();

    mbx(store.path(), &["gc", "--max-size", &budget.to_string()]);

    // Load-bearing, not cleanup: the wipe is what forces the next build to go
    // to the store, which is the only way to see what survived the sweep.
    std::fs::remove_dir_all(surviving.path().join("target")).unwrap();
    let warm = build(
        surviving.path(),
        store.path(),
        &reports.path().join("warm.json"),
    );

    assert!(
        count(&warm, "hits") > 0,
        "the surviving checkout should still be warm: {warm}"
    );
}

/// Managed target directories rest on a symlink standing in for `target`, and
/// Windows only lets a privileged or developer-mode process create one, so mbx
/// leaves the target directory where cargo put it there. These cover the
/// platforms where the feature is available.
#[cfg(unix)]
mod target_views {
    use super::*;

    fn managed(project: &Path) -> std::path::PathBuf {
        std::fs::read_link(project.join("target")).expect("target should be a link")
    }

    #[test]
    fn a_managed_target_directory_keeps_the_workspace_clean() {
        let store = tempfile::tempdir().unwrap();
        let project = tempfile::tempdir().unwrap();
        let reports = tempfile::tempdir().unwrap();
        write_project(project.path());

        build_with(
            project.path(),
            store.path(),
            &reports.path().join("cold.json"),
            &[("MBX_TARGET_VIEWS", "1")],
        );

        let directory = managed(project.path());
        assert!(
            directory.starts_with(store.path().join("targets")),
            "outputs should land under the managed root, not the workspace: {}",
            directory.display()
        );
        // The link is the point: a relocation that breaks the paths people type
        // would not be worth the disk it reclaims.
        assert!(
            project.path().join("target/debug/libfixture.rlib").exists(),
            "the workspace should still reach its own build outputs"
        );
    }

    #[test]
    fn a_managed_target_directory_still_hits_the_cache() {
        let store = tempfile::tempdir().unwrap();
        let first = tempfile::tempdir().unwrap();
        let second = tempfile::tempdir().unwrap();
        let reports = tempfile::tempdir().unwrap();
        write_project(first.path());
        write_project(second.path());

        build(
            first.path(),
            store.path(),
            &reports.path().join("first.json"),
        );
        let (warm, _) = build_with(
            second.path(),
            store.path(),
            &reports.path().join("second.json"),
            &[("MBX_TARGET_VIEWS", "1")],
        );

        // The shim maps the target directory out of its keys before anything
        // else, so moving it must not cost a single hit.
        assert!(
            count(&warm, "hits") > 0,
            "a relocated target directory should still reuse the first build: {warm}"
        );
    }

    #[test]
    fn deleting_a_checkout_frees_its_managed_target_directory() {
        let store = tempfile::tempdir().unwrap();
        let project = tempfile::tempdir().unwrap();
        let reports = tempfile::tempdir().unwrap();
        write_project(project.path());

        build_with(
            project.path(),
            store.path(),
            &reports.path().join("cold.json"),
            &[("MBX_TARGET_VIEWS", "1")],
        );
        let directory = managed(project.path());
        assert!(tree_bytes(&directory) > 0);

        // Outputs used to live inside the checkout and die with it. Now that
        // they outlive it, collecting them is mbx's job.
        std::fs::remove_dir_all(project.path()).unwrap();
        let output = mbx(store.path(), &["gc", "--max-size", "20GiB"]);

        assert!(
            !directory.exists(),
            "the target directory of a checkout that is gone should be freed"
        );
        assert!(
            output.contains("freed 1 target directories"),
            "gc should say what it freed: {output}"
        );
    }

    #[test]
    fn a_store_sweep_failure_still_frees_managed_target_directories() {
        let store = tempfile::tempdir().unwrap();
        let gone = tempfile::tempdir().unwrap();
        let current = tempfile::tempdir().unwrap();
        let reports = tempfile::tempdir().unwrap();
        write_named_project(gone.path(), "gone");
        write_named_project(current.path(), "current");

        build_with(
            gone.path(),
            store.path(),
            &reports.path().join("gone.json"),
            &[("MBX_TARGET_VIEWS", "1")],
        );
        let directory = managed(gone.path());
        std::fs::remove_dir_all(gone.path()).unwrap();

        // Make the due store sweep fail after it writes its throttle stamp.
        // Target collection is independent and must not be skipped with it.
        let cas = store.path().join("actions/cas/v1");
        std::fs::remove_dir_all(&cas).unwrap();
        std::fs::write(&cas, b"not a directory").unwrap();
        let (_, stderr) = build_with(
            current.path(),
            store.path(),
            &reports.path().join("current.json"),
            &[("MBX_TARGET_VIEWS", "1"), ("MBX_GC_INTERVAL", "0")],
        );

        assert!(stderr.contains("the store was not swept"), "{stderr}");
        assert!(
            !directory.exists(),
            "target collection should still run when store collection fails"
        );
    }

    #[test]
    fn explicit_gc_still_frees_targets_when_store_collection_fails() {
        let store = tempfile::tempdir().unwrap();
        let gone = tempfile::tempdir().unwrap();
        let reports = tempfile::tempdir().unwrap();
        write_project(gone.path());

        build_with(
            gone.path(),
            store.path(),
            &reports.path().join("gone.json"),
            &[("MBX_TARGET_VIEWS", "1")],
        );
        let directory = managed(gone.path());
        std::fs::remove_dir_all(gone.path()).unwrap();
        let cas = store.path().join("actions/cas/v1");
        std::fs::remove_dir_all(&cas).unwrap();
        std::fs::write(&cas, b"not a directory").unwrap();

        let output = Command::new(env!("CARGO_BIN_EXE_mbx"))
            .args(["gc", "--max-size", "20GiB"])
            .env("MBX_CACHE_DIR", store.path())
            .output()
            .expect("mbx should run");

        assert!(
            !output.status.success(),
            "the broken store should be reported"
        );
        assert!(
            String::from_utf8_lossy(&output.stdout).contains("freed 1 target directories"),
            "successful target collection should still be reported"
        );
        assert!(
            !directory.exists(),
            "explicit gc should collect targets independently of the store"
        );
    }

    #[test]
    fn a_real_target_directory_is_never_displaced() {
        let store = tempfile::tempdir().unwrap();
        let project = tempfile::tempdir().unwrap();
        let reports = tempfile::tempdir().unwrap();
        write_project(project.path());
        // A build that ran before the feature was turned on.
        build(
            project.path(),
            store.path(),
            &reports.path().join("cold.json"),
        );
        assert!(project.path().join("target").is_dir());

        build_with(
            project.path(),
            store.path(),
            &reports.path().join("warm.json"),
            &[("MBX_TARGET_VIEWS", "1")],
        );

        assert!(
            std::fs::read_link(project.path().join("target")).is_err(),
            "existing build outputs are not ours to move"
        );
        assert!(project.path().join("target/debug/libfixture.rlib").exists());
    }
}