Skip to main content

cortexkit_test_support/
sibling_cache.rs

1//! Shared executable cache for real-sibling integration tests.
2
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6
7fn git(repo: &Path, args: &[&str]) -> Vec<u8> {
8    let output = Command::new("git")
9        .current_dir(repo)
10        .args(args)
11        .output()
12        .expect("run git for sibling identity");
13    assert!(
14        output.status.success(),
15        "git {args:?} failed in {}",
16        repo.display()
17    );
18    output.stdout
19}
20
21/// Return a cache key derived from the checkout's HEAD and source bytes, not
22/// its directory location. Staged changes, unstaged changes, and untracked
23/// non-ignored files contribute, so each local edit invalidates a cached build.
24pub fn sibling_revision(repo: &Path) -> String {
25    let head = String::from_utf8(git(repo, &["rev-parse", "HEAD"])).unwrap();
26    let mut contents = git(repo, &["diff", "HEAD", "--binary", "--no-ext-diff"]);
27    for name in git(repo, &["ls-files", "--others", "--exclude-standard", "-z"])
28        .split(|byte| *byte == 0)
29        .filter(|name| !name.is_empty())
30    {
31        let name = std::str::from_utf8(name).expect("UTF-8 sibling file name");
32        let bytes = std::fs::read(repo.join(name)).expect("read untracked sibling file");
33        contents.extend_from_slice(&(name.len() as u64).to_le_bytes());
34        contents.extend_from_slice(name.as_bytes());
35        contents.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
36        contents.extend(bytes);
37    }
38    if contents.is_empty() {
39        return head.trim().to_owned();
40    }
41    format!("{}-dirty-{}", head.trim(), hash_contents(&contents))
42}
43
44fn hash_contents(contents: &[u8]) -> String {
45    let mut child = Command::new("git")
46        .args(["hash-object", "--stdin"])
47        .stdin(Stdio::piped())
48        .stdout(Stdio::piped())
49        .spawn()
50        .expect("hash sibling changes");
51    child.stdin.take().unwrap().write_all(contents).unwrap();
52    let output = child.wait_with_output().unwrap();
53    assert!(output.status.success());
54    String::from_utf8(output.stdout).unwrap().trim().to_owned()
55}
56
57/// Return a user-owned directory outside the source checkouts for compiled
58/// sibling builds. Downloading or unpacking pinned source trees is the caller's
59/// responsibility; this cache stores build outputs, not those source trees.
60pub fn sibling_cache_root() -> PathBuf {
61    PathBuf::from(
62        std::env::var_os("HOME")
63            .or_else(|| std::env::var_os("USERPROFILE"))
64            .expect("user home for sibling build cache"),
65    )
66    .join(".cache/cortexkit-tests/sibling-target")
67}
68
69/// Resolve an immutable executable, building only on a cache miss. `revision`
70/// may be an explicit pin rather than the live checkout's HEAD. The builder
71/// receives a staging Cargo target directory and must produce `debug/<bin>`.
72/// A sibling-wide OS lock covers publication and pruning, including across
73/// processes; dropping the file releases it even when a builder panics.
74pub fn cached_sibling_binary(
75    sibling: &str,
76    revision: &str,
77    bin: &str,
78    builder: impl FnOnce(&Path),
79) -> PathBuf {
80    cached_sibling_binary_with_target(sibling, revision, bin, builder).executable
81}
82
83/// Paths to the cached Cargo target directory and the executable used by tests.
84/// A `ck-*` artifact is copied to a separate `ckdev-*` publication before running,
85/// so its execution path is not the path where Cargo wrote the build output.
86pub struct CachedSiblingBinary {
87    pub executable: PathBuf,
88    pub target_dir: PathBuf,
89}
90
91/// Resolve both paths so a caller can track or restrict where Cargo writes build
92/// files separately from the executable path used to start test processes.
93pub fn cached_sibling_binary_with_target(
94    sibling: &str,
95    revision: &str,
96    bin: &str,
97    builder: impl FnOnce(&Path),
98) -> CachedSiblingBinary {
99    resolve_with_target(&sibling_cache_root(), sibling, revision, bin, builder)
100}
101
102#[cfg(test)]
103fn resolve(
104    root: &Path,
105    sibling: &str,
106    revision: &str,
107    bin: &str,
108    builder: impl FnOnce(&Path),
109) -> PathBuf {
110    resolve_with_target(root, sibling, revision, bin, builder).executable
111}
112
113fn resolve_with_target(
114    root: &Path,
115    sibling: &str,
116    revision: &str,
117    bin: &str,
118    builder: impl FnOnce(&Path),
119) -> CachedSiblingBinary {
120    for component in [sibling, revision, bin] {
121        assert!(
122            !component.is_empty()
123                && component
124                    .bytes()
125                    .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'),
126            "invalid sibling cache component"
127        );
128    }
129    let root = root.join(sibling);
130    std::fs::create_dir_all(&root).unwrap();
131    let lock = std::fs::OpenOptions::new()
132        .create(true)
133        .truncate(false)
134        .read(true)
135        .write(true)
136        .open(root.join("build.lock"))
137        .unwrap();
138    lock.lock().expect("lock shared sibling build");
139    let destination = root.join(revision).join(bin);
140    let executable = destination
141        .join("debug")
142        .join(format!("{bin}{}", std::env::consts::EXE_SUFFIX));
143    if !executable.is_file() {
144        eprintln!("sibling cache: miss, building {sibling}/{revision}/{bin}");
145        let staging = root.join("building");
146        if staging.exists() {
147            std::fs::remove_dir_all(&staging).unwrap();
148        }
149        std::fs::create_dir_all(&staging).unwrap();
150        builder(&staging);
151        let built = staging
152            .join("debug")
153            .join(format!("{bin}{}", std::env::consts::EXE_SUFFIX));
154        assert!(
155            built.is_file(),
156            "builder did not produce {}",
157            built.display()
158        );
159        super::sign_test_binary(&built, None);
160        std::fs::create_dir_all(destination.parent().unwrap()).unwrap();
161        std::fs::rename(&staging, &destination).expect("publish sibling executable atomically");
162        super::warm_exec_fenced(&executable);
163    } else {
164        eprintln!("sibling cache: hit {}", executable.display());
165    }
166    // Refresh recency on reuse too, so pruning keeps the actively used pins.
167    std::fs::write(destination.parent().unwrap().join("last-used"), []).unwrap();
168    let mut revisions: Vec<_> = std::fs::read_dir(&root)
169        .unwrap()
170        .filter_map(Result::ok)
171        .filter(|entry| {
172            entry.file_type().is_ok_and(|kind| kind.is_dir()) && entry.file_name() != "building"
173        })
174        .map(|entry| {
175            (
176                std::fs::metadata(entry.path().join("last-used"))
177                    .and_then(|m| m.modified())
178                    .ok(),
179                entry.path(),
180            )
181        })
182        .collect();
183    revisions.sort_by_key(|a| std::cmp::Reverse(a.0));
184    for (_, old) in revisions
185        .into_iter()
186        .filter(|(_, path)| path != destination.parent().unwrap())
187        .skip(3)
188    {
189        std::fs::remove_dir_all(old).unwrap();
190    }
191    // Stage while the sibling lock is still held, so another revision's prune
192    // cannot remove this build before its execution copy has been published.
193    CachedSiblingBinary {
194        target_dir: destination,
195        executable: super::stage_test_binary(&executable),
196    }
197}
198
199/// Return a cache key combining an archived source revision with caller-supplied
200/// dependency names and full commit SHAs. Changing any dependency pin produces
201/// a new key; fetching or unpacking those source revisions is up to the caller.
202pub fn pinned_sibling_build_revision(revision: &str, dependencies: &[(&str, &str)]) -> String {
203    let mut key = revision.to_owned();
204    for (name, sha) in dependencies {
205        assert_eq!(sha.len(), 40, "dependency pin must be a full commit SHA");
206        assert!(sha.bytes().all(|b| b.is_ascii_hexdigit()));
207        key.push_str(&format!("-{name}-{sha}"));
208    }
209    format!("{revision}-pins-{}", hash_contents(key.as_bytes()))
210}
211
212/// Include the source revisions of caller-named adjacent path-dependency checkouts
213/// in the build cache key. Missing checkouts and `repo` itself are skipped.
214/// Names must be plain directory names; order is significant in the hash.
215pub fn sibling_build_revision(
216    repo: &Path,
217    revision: &str,
218    dependency_checkouts: &[&str],
219) -> String {
220    let mut key = revision.to_owned();
221    for name in dependency_checkouts {
222        crate::fence::plain_name(name);
223        let dependency = repo.parent().unwrap().join(name);
224        if dependency.join(".git").exists()
225            && dependency.canonicalize().ok() != repo.canonicalize().ok()
226        {
227            key.push('-');
228            key.push_str(&sibling_revision(&dependency));
229        }
230    }
231    format!("{revision}-deps-{}", hash_contents(key.as_bytes()))
232}
233
234/// Build a sibling using a cache key derived from its current source revision
235/// and caller-selected adjacent dependency checkouts. The same dependency names
236/// are checked again after compilation to reject sources changed during the build.
237pub fn build_sibling_binary(
238    repo: &Path,
239    sibling: &str,
240    bin: &str,
241    dependency_checkouts: &[&str],
242) -> PathBuf {
243    let revision = sibling_build_revision(repo, &sibling_revision(repo), dependency_checkouts);
244    cached_sibling_binary(sibling, &revision, bin, |target| {
245        let status = super::sibling_cargo_build(repo, target, bin)
246            .status()
247            .expect("build sibling binary");
248        assert!(
249            status.success(),
250            "building {bin} failed: {}",
251            crate::describe_exit_status(status)
252        );
253        assert_eq!(
254            sibling_build_revision(repo, &sibling_revision(repo), dependency_checkouts),
255            revision,
256            "sibling changed during build; retry with the new revision"
257        );
258    })
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use std::sync::{
265        atomic::{AtomicUsize, Ordering},
266        Arc, Barrier,
267    };
268
269    fn fake_build(target: &Path) {
270        std::fs::create_dir_all(target.join("debug")).unwrap();
271        std::fs::write(
272            target.join(format!("debug/probe{}", std::env::consts::EXE_SUFFIX)),
273            b"fake executable",
274        )
275        .unwrap();
276    }
277
278    #[test]
279    fn cached_binary_keeps_build_target_separate_from_ckdev_execution() {
280        let scratch = crate::ScratchDir::new("sibling-cache-build-and-exec");
281        let cache = scratch.path().join("cache");
282        let built_name = format!("ck-probe{}", std::env::consts::EXE_SUFFIX);
283        let first = resolve_with_target(&cache, "example", "revision", "ck-probe", |target| {
284            std::fs::create_dir_all(target.join("debug")).unwrap();
285            std::fs::copy(
286                std::env::current_exe().unwrap(),
287                target.join("debug").join(&built_name),
288            )
289            .unwrap();
290        });
291        let published_target = cache.join("example/revision/ck-probe");
292        assert_eq!(first.target_dir, published_target);
293        assert!(first.target_dir.join("debug").join(built_name).is_file());
294        assert_eq!(
295            first.executable.file_name().unwrap(),
296            format!("ckdev-probe{}", std::env::consts::EXE_SUFFIX).as_str()
297        );
298        assert!(!first.executable.starts_with(&cache));
299        let reused = resolve_with_target(&cache, "example", "revision", "ck-probe", |_| {
300            panic!("cache hit must not build")
301        });
302        assert_eq!(reused.target_dir, first.target_dir);
303        assert_eq!(reused.executable, first.executable);
304    }
305
306    fn git_ok(repo: &Path, args: &[&str]) {
307        assert!(Command::new("git")
308            .current_dir(repo)
309            .args(args)
310            .env("GIT_AUTHOR_NAME", "Test")
311            .env("GIT_AUTHOR_EMAIL", "test@example.org")
312            .env("GIT_COMMITTER_NAME", "Test")
313            .env("GIT_COMMITTER_EMAIL", "test@example.org")
314            .status()
315            .unwrap()
316            .success());
317    }
318
319    #[test]
320    fn pinned_identity_includes_every_dependency() {
321        let pin = "a".repeat(40);
322        let next = "b".repeat(40);
323        let baseline =
324            pinned_sibling_build_revision(&pin, &[("library-a", &pin), ("library-b", &pin)]);
325        for dependencies in [
326            [("library-a", next.as_str()), ("library-b", pin.as_str())],
327            [("library-a", pin.as_str()), ("library-b", next.as_str())],
328        ] {
329            assert_ne!(baseline, pinned_sibling_build_revision(&pin, &dependencies));
330        }
331        assert_ne!(
332            baseline,
333            pinned_sibling_build_revision(&next, &[("library-a", &pin), ("library-b", &pin)])
334        );
335        assert_eq!(
336            baseline,
337            pinned_sibling_build_revision(&pin, &[("library-a", &pin), ("library-b", &pin)])
338        );
339    }
340
341    #[test]
342    fn live_build_identity_includes_only_caller_selected_dependencies() {
343        let scratch = crate::ScratchDir::new("sibling-dependency-selection");
344        let repo = scratch.join("application");
345        let selected = scratch.join("library-a");
346        let excluded = scratch.join("library-b");
347        for checkout in [&repo, &selected, &excluded] {
348            std::fs::create_dir(checkout).unwrap();
349            git_ok(checkout, &["init"]);
350            std::fs::write(checkout.join("source"), "initial").unwrap();
351            git_ok(checkout, &["add", "source"]);
352            git_ok(checkout, &["commit", "-m", "initial"]);
353        }
354        let revision = sibling_revision(&repo);
355        let empty = sibling_build_revision(&repo, &revision, &[]);
356        let baseline = sibling_build_revision(&repo, &revision, &["library-a"]);
357        assert_ne!(baseline, empty);
358        assert_eq!(
359            baseline,
360            sibling_build_revision(&repo, &revision, &["library-a", "missing", "application"])
361        );
362        std::fs::write(excluded.join("source"), "excluded edit").unwrap();
363        assert_eq!(
364            baseline,
365            sibling_build_revision(&repo, &revision, &["library-a"])
366        );
367        assert_ne!(
368            baseline,
369            sibling_build_revision(&repo, &revision, &["library-a", "library-b"])
370        );
371        std::fs::write(selected.join("source"), "selected edit").unwrap();
372        let edited = sibling_build_revision(&repo, &revision, &["library-a"]);
373        assert_ne!(baseline, edited);
374        assert_eq!(empty, sibling_build_revision(&repo, &revision, &[]));
375        std::fs::write(selected.join("untracked"), "new dependency source").unwrap();
376        assert_ne!(
377            edited,
378            sibling_build_revision(&repo, &revision, &["library-a"])
379        );
380    }
381
382    #[test]
383    fn same_revision_from_different_checkout_roots_reuses_path_and_changes_get_new_paths() {
384        let scratch = crate::ScratchDir::new("sibling-cache-identity");
385        let first = scratch.path().join("first");
386        let second = scratch.path().join("second");
387        std::fs::create_dir_all(&first).unwrap();
388        git_ok(&first, &["init"]);
389        std::fs::write(first.join("source"), "one").unwrap();
390        git_ok(&first, &["add", "source"]);
391        git_ok(&first, &["commit", "-m", "first"]);
392        git_ok(
393            scratch.path(),
394            &["clone", first.to_str().unwrap(), second.to_str().unwrap()],
395        );
396        let cache = scratch.path().join("cache");
397        let a = resolve(
398            &cache,
399            "example",
400            &sibling_revision(&first),
401            "probe",
402            fake_build,
403        );
404        let b = resolve(
405            &cache,
406            "example",
407            &sibling_revision(&second),
408            "probe",
409            |_| panic!("cache hit must not build"),
410        );
411        assert_eq!(a, b);
412        std::fs::write(second.join("source"), "two").unwrap();
413        let dirty = resolve(
414            &cache,
415            "example",
416            &sibling_revision(&second),
417            "probe",
418            fake_build,
419        );
420        assert_ne!(a, dirty);
421        std::fs::write(second.join("source"), "three").unwrap();
422        assert_ne!(
423            dirty,
424            resolve(
425                &cache,
426                "example",
427                &sibling_revision(&second),
428                "probe",
429                fake_build
430            )
431        );
432        git_ok(&second, &["add", "source"]);
433        git_ok(&second, &["commit", "-m", "second"]);
434        assert_ne!(
435            a,
436            resolve(
437                &cache,
438                "example",
439                &sibling_revision(&second),
440                "probe",
441                fake_build
442            )
443        );
444        let clean_revision = sibling_revision(&second);
445        std::fs::write(second.join("untracked"), "new source").unwrap();
446        assert_ne!(clean_revision, sibling_revision(&second));
447        let dirty_revision = sibling_revision(&second);
448        std::fs::write(second.join("untracked"), "edited source").unwrap();
449        assert_ne!(dirty_revision, sibling_revision(&second));
450    }
451
452    #[test]
453    fn concurrent_resolution_builds_once() {
454        let scratch = crate::ScratchDir::new("sibling-cache-concurrent");
455        let root = scratch.path().to_owned();
456        let count = Arc::new(AtomicUsize::new(0));
457        let barrier = Arc::new(Barrier::new(8));
458        let threads: Vec<_> = (0..8)
459            .map(|_| {
460                let root = root.clone();
461                let count = count.clone();
462                let barrier = barrier.clone();
463                std::thread::spawn(move || {
464                    barrier.wait();
465                    resolve(&root, "example", "revision", "probe", |target| {
466                        count.fetch_add(1, Ordering::SeqCst);
467                        std::thread::sleep(std::time::Duration::from_millis(30));
468                        fake_build(target);
469                    })
470                })
471            })
472            .collect();
473        let paths: Vec<_> = threads
474            .into_iter()
475            .map(|thread| thread.join().unwrap())
476            .collect();
477        assert_eq!(count.load(Ordering::SeqCst), 1);
478        assert!(paths.iter().all(|path| path == &paths[0]));
479    }
480
481    #[test]
482    fn cache_prunes_to_four_revisions() {
483        let scratch = crate::ScratchDir::new("sibling-cache-prune");
484        for revision in 0..6 {
485            resolve(
486                scratch.path(),
487                "example",
488                &format!("rev-{revision}"),
489                "probe",
490                fake_build,
491            );
492        }
493        let count = std::fs::read_dir(scratch.path().join("example"))
494            .unwrap()
495            .filter_map(Result::ok)
496            .filter(|entry| entry.file_type().unwrap().is_dir())
497            .count();
498        assert_eq!(count, 4);
499    }
500}