Skip to main content

haz_cache/
layout.rs

1//! On-disk cache-entry layout per `CACHE-010`, `CACHE-012`, and
2//! `CACHE-013`.
3//!
4//! Pure path-computation helpers. None of these functions touch
5//! the filesystem; they translate a workspace-root path and a
6//! [`CacheKey`] into the canonical paths the cache uses to read
7//! and write entry data.
8//!
9//! Layout tree under `<workspace-root>/.haz/cache/`:
10//!
11//! ```text
12//! .haz/cache/
13//! `-- <shard>/                          (first two hex chars of key)
14//!     `-- <hex-key>/                    (entry directory)
15//!         |-- manifest.json             (CACHE-011)
16//!         |-- stdout                    (CACHE-012)
17//!         |-- stderr                    (CACHE-012)
18//!         `-- outputs/
19//!             `-- <hex-content-hash>    (CACHE-013, one per blob)
20//! ```
21
22use std::path::{Path, PathBuf};
23
24use crate::hex;
25use crate::key::CacheKey;
26
27/// File name of the manifest within an entry directory
28/// (`CACHE-011`).
29pub const MANIFEST_FILE_NAME: &str = "manifest.json";
30
31/// File name of the captured stdout stream within an entry
32/// directory (`CACHE-012`).
33pub const STDOUT_FILE_NAME: &str = "stdout";
34
35/// File name of the captured stderr stream within an entry
36/// directory (`CACHE-012`).
37pub const STDERR_FILE_NAME: &str = "stderr";
38
39/// Subdirectory holding output blobs within an entry directory
40/// (`CACHE-013`).
41pub const OUTPUTS_SUBDIR: &str = "outputs";
42
43/// Compute the cache root directory under `workspace_root`:
44/// `<workspace_root>/.haz/cache`.
45#[must_use]
46pub fn cache_root(workspace_root: &Path) -> PathBuf {
47    workspace_root.join(".haz").join("cache")
48}
49
50/// The shard component of `key` per `CACHE-010`: the first two
51/// lowercase hexadecimal characters of the key.
52#[must_use]
53pub fn shard(key: &CacheKey) -> String {
54    let hex = key.to_hex();
55    hex[..2].to_owned()
56}
57
58/// Shard directory under `cache_root`: `<cache_root>/<shard>`.
59#[must_use]
60pub fn shard_dir(cache_root: &Path, key: &CacheKey) -> PathBuf {
61    cache_root.join(shard(key))
62}
63
64/// Entry directory of `key`: `<cache_root>/<shard>/<hex-key>`.
65#[must_use]
66pub fn entry_dir(cache_root: &Path, key: &CacheKey) -> PathBuf {
67    shard_dir(cache_root, key).join(key.to_hex())
68}
69
70/// Path to the manifest file of `key`'s entry.
71#[must_use]
72pub fn manifest_path(cache_root: &Path, key: &CacheKey) -> PathBuf {
73    entry_dir(cache_root, key).join(MANIFEST_FILE_NAME)
74}
75
76/// Path to the captured stdout file of `key`'s entry.
77#[must_use]
78pub fn stdout_path(cache_root: &Path, key: &CacheKey) -> PathBuf {
79    entry_dir(cache_root, key).join(STDOUT_FILE_NAME)
80}
81
82/// Path to the captured stderr file of `key`'s entry.
83#[must_use]
84pub fn stderr_path(cache_root: &Path, key: &CacheKey) -> PathBuf {
85    entry_dir(cache_root, key).join(STDERR_FILE_NAME)
86}
87
88/// Subdirectory holding output blobs of `key`'s entry.
89#[must_use]
90pub fn outputs_dir(cache_root: &Path, key: &CacheKey) -> PathBuf {
91    entry_dir(cache_root, key).join(OUTPUTS_SUBDIR)
92}
93
94/// Path to a single output blob, keyed by its content hash
95/// (`CACHE-013`).
96#[must_use]
97pub fn output_blob_path(cache_root: &Path, key: &CacheKey, content_hash: &[u8; 32]) -> PathBuf {
98    outputs_dir(cache_root, key).join(hex::encode_32(content_hash))
99}
100
101/// The name of the two-phase-store tmp directory for `key` with
102/// the caller-supplied `random_suffix`, per `CACHE-017`:
103/// `.tmp-<hex-key>-<random>`. The caller chooses the random
104/// suffix; the layout helper only joins it into the canonical
105/// shape so concurrent stores of the same key on the same shard
106/// do not collide.
107#[must_use]
108pub fn tmp_entry_dir(cache_root: &Path, key: &CacheKey, random_suffix: &str) -> PathBuf {
109    let name = format!(".tmp-{}-{}", key.to_hex(), random_suffix);
110    shard_dir(cache_root, key).join(name)
111}
112
113/// Staging directory used by restoration (`CACHE-019`,
114/// `CACHE-020`): blob bytes are written here first, then renamed
115/// onto their workspace-absolute targets so a partial publish is
116/// detectable and contained.
117///
118/// Placed directly under `cache_root` (not on a shard) because it
119/// is a transient publishing scratch space, not an entry in the
120/// content-addressed sense. The naming pattern
121/// `.restore-<hex-key>-<random>` keeps it distinct from
122/// [`tmp_entry_dir`]'s store-time `.tmp-<hex-key>-<random>` so
123/// future invalidation logic can tell the two apart.
124#[must_use]
125pub fn restore_staging_dir(cache_root: &Path, key: &CacheKey, random_suffix: &str) -> PathBuf {
126    let name = format!(".restore-{}-{}", key.to_hex(), random_suffix);
127    cache_root.join(name)
128}
129
130#[cfg(test)]
131mod tests {
132    use std::path::Path;
133
134    use crate::CacheKey;
135    use crate::layout::{
136        cache_root, entry_dir, manifest_path, output_blob_path, outputs_dir, restore_staging_dir,
137        shard, shard_dir, stderr_path, stdout_path, tmp_entry_dir,
138    };
139
140    /// Build a [`CacheKey`] whose hex form has a predictable
141    /// 2-char shard prefix and a recognisable tail.
142    fn key_with_first_byte(first: u8) -> CacheKey {
143        let mut bytes = [0u8; 32];
144        bytes[0] = first;
145        for (i, b) in bytes.iter_mut().enumerate().skip(1) {
146            *b = u8::try_from(i & 0xFF).unwrap();
147        }
148        CacheKey::from_bytes(bytes)
149    }
150
151    #[test]
152    fn cache_010_cache_root_is_workspace_dot_haz_cache() {
153        let root = cache_root(Path::new("/ws"));
154        assert_eq!(root, Path::new("/ws/.haz/cache"));
155    }
156
157    #[test]
158    fn cache_010_shard_is_first_two_hex_chars_of_key() {
159        let key = key_with_first_byte(0xAB);
160        assert_eq!(shard(&key), "ab");
161
162        let key = key_with_first_byte(0x00);
163        assert_eq!(shard(&key), "00");
164
165        let key = key_with_first_byte(0xFF);
166        assert_eq!(shard(&key), "ff");
167    }
168
169    #[test]
170    fn cache_010_shard_dir_joins_cache_root_and_shard() {
171        let key = key_with_first_byte(0xAB);
172        let root = Path::new("/ws/.haz/cache");
173        assert_eq!(shard_dir(root, &key), Path::new("/ws/.haz/cache/ab"));
174    }
175
176    #[test]
177    fn cache_010_entry_dir_is_shard_dir_joined_with_full_hex_key() {
178        let key = key_with_first_byte(0xAB);
179        let root = Path::new("/ws/.haz/cache");
180        let entry = entry_dir(root, &key);
181        let expected = format!("/ws/.haz/cache/ab/{}", key.to_hex());
182        assert_eq!(entry, Path::new(&expected));
183    }
184
185    #[test]
186    fn cache_011_manifest_path_lives_inside_entry_dir() {
187        let key = key_with_first_byte(0xAB);
188        let root = Path::new("/ws/.haz/cache");
189        let mpath = manifest_path(root, &key);
190        let expected = format!("/ws/.haz/cache/ab/{}/manifest.json", key.to_hex());
191        assert_eq!(mpath, Path::new(&expected));
192    }
193
194    #[test]
195    fn cache_012_stdout_and_stderr_paths_use_canonical_names() {
196        let key = key_with_first_byte(0xAB);
197        let root = Path::new("/ws/.haz/cache");
198        assert!(stdout_path(root, &key).ends_with("stdout"));
199        assert!(stderr_path(root, &key).ends_with("stderr"));
200    }
201
202    #[test]
203    fn cache_013_outputs_dir_is_outputs_under_entry_dir() {
204        let key = key_with_first_byte(0xAB);
205        let root = Path::new("/ws/.haz/cache");
206        let od = outputs_dir(root, &key);
207        let expected = format!("/ws/.haz/cache/ab/{}/outputs", key.to_hex());
208        assert_eq!(od, Path::new(&expected));
209    }
210
211    #[test]
212    fn cache_013_output_blob_path_is_keyed_by_content_hash() {
213        let key = key_with_first_byte(0xAB);
214        let content_hash = [0xCDu8; 32];
215        let root = Path::new("/ws/.haz/cache");
216        let blob = output_blob_path(root, &key, &content_hash);
217        let expected = format!(
218            "/ws/.haz/cache/ab/{}/outputs/{}",
219            key.to_hex(),
220            "cd".repeat(32)
221        );
222        assert_eq!(blob, Path::new(&expected));
223    }
224
225    #[test]
226    fn cache_017_tmp_entry_dir_uses_dot_tmp_prefix_with_random_suffix() {
227        let key = key_with_first_byte(0xAB);
228        let root = Path::new("/ws/.haz/cache");
229        let tmp = tmp_entry_dir(root, &key, "r4nd0m");
230        let expected = format!("/ws/.haz/cache/ab/.tmp-{}-r4nd0m", key.to_hex());
231        assert_eq!(tmp, Path::new(&expected));
232    }
233
234    #[test]
235    fn cache_017_tmp_entry_dir_and_entry_dir_have_same_parent() {
236        // The rename of the tmp dir into its final entry path
237        // must be same-directory for `CACHE-017` atomicity. The
238        // layout helpers must preserve that invariant.
239        let key = key_with_first_byte(0xAB);
240        let root = Path::new("/ws/.haz/cache");
241        let tmp = tmp_entry_dir(root, &key, "rnd");
242        let final_entry = entry_dir(root, &key);
243        assert_eq!(tmp.parent().unwrap(), final_entry.parent().unwrap());
244    }
245
246    #[test]
247    fn restore_staging_dir_lives_directly_under_cache_root() {
248        let key = key_with_first_byte(0xAB);
249        let root = Path::new("/ws/.haz/cache");
250        let staging = restore_staging_dir(root, &key, "r4nd0m");
251        let expected = format!("/ws/.haz/cache/.restore-{}-r4nd0m", key.to_hex());
252        assert_eq!(staging, Path::new(&expected));
253    }
254
255    #[test]
256    fn restore_staging_dir_name_is_distinct_from_tmp_entry_dir() {
257        // Future invalidation logic must be able to discriminate
258        // store-time tmp directories (incomplete entries) from
259        // restore-time staging directories (transient publishing
260        // state). The two naming patterns must not collide.
261        let key = key_with_first_byte(0xAB);
262        let root = Path::new("/ws/.haz/cache");
263        let store_tmp = tmp_entry_dir(root, &key, "rnd");
264        let restore_staging = restore_staging_dir(root, &key, "rnd");
265        assert_ne!(store_tmp, restore_staging);
266        let store_name = store_tmp.file_name().unwrap().to_string_lossy();
267        let restore_name = restore_staging.file_name().unwrap().to_string_lossy();
268        assert!(store_name.starts_with(".tmp-"));
269        assert!(restore_name.starts_with(".restore-"));
270    }
271}