1use blake3::Hasher;
26
27pub const POOLED_TARGET_ROOT: &str = ".rch-pool";
30
31#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct PooledTargetDimensions {
35 pub repo_identity: String,
38 pub toolchain: String,
40 pub target_triple: String,
42 pub profile: String,
44 pub features: Vec<String>,
47 pub runtime: Option<String>,
50}
51
52impl PooledTargetDimensions {
53 #[must_use]
55 pub fn new(
56 repo_identity: impl Into<String>,
57 toolchain: impl Into<String>,
58 target_triple: impl Into<String>,
59 profile: impl Into<String>,
60 ) -> Self {
61 Self {
62 repo_identity: repo_identity.into(),
63 toolchain: toolchain.into(),
64 target_triple: target_triple.into(),
65 profile: profile.into(),
66 features: Vec::new(),
67 runtime: None,
68 }
69 }
70
71 #[must_use]
73 pub fn with_features<I, S>(mut self, features: I) -> Self
74 where
75 I: IntoIterator<Item = S>,
76 S: Into<String>,
77 {
78 self.features = features.into_iter().map(Into::into).collect();
79 self
80 }
81
82 #[must_use]
84 pub fn with_runtime(mut self, runtime: impl Into<String>) -> Self {
85 self.runtime = Some(runtime.into());
86 self
87 }
88
89 #[must_use]
92 pub fn canonical_features(&self) -> Vec<String> {
93 let mut f = self.features.clone();
94 f.sort();
95 f.dedup();
96 f
97 }
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct PooledTargetKey {
104 hex: String,
105}
106
107fn absorb(hasher: &mut Hasher, tag: &str, value: &str) {
110 hasher.update(tag.as_bytes());
113 hasher.update(b"\0");
114 hasher.update(value.len().to_string().as_bytes());
115 hasher.update(b"\0");
116 hasher.update(value.as_bytes());
117 hasher.update(b"\0");
118}
119
120impl PooledTargetKey {
121 #[must_use]
123 pub fn derive(dims: &PooledTargetDimensions) -> Self {
124 let mut hasher = Hasher::new();
125 absorb(&mut hasher, "v", "1"); absorb(&mut hasher, "repo", &dims.repo_identity);
127 absorb(&mut hasher, "toolchain", &dims.toolchain);
128 absorb(&mut hasher, "triple", &dims.target_triple);
129 absorb(&mut hasher, "profile", &dims.profile);
130 absorb(
131 &mut hasher,
132 "runtime",
133 dims.runtime.as_deref().unwrap_or(""),
134 );
135 let features = dims.canonical_features();
138 absorb(&mut hasher, "features.n", &features.len().to_string());
139 for feature in &features {
140 absorb(&mut hasher, "feature", feature);
141 }
142 let hex = hasher.finalize().to_hex();
145 Self {
146 hex: hex[..32].to_string(),
147 }
148 }
149
150 #[must_use]
152 pub fn as_str(&self) -> &str {
153 &self.hex
154 }
155
156 #[must_use]
159 pub fn pooled_dir_name(&self) -> String {
160 format!("{POOLED_TARGET_ROOT}/{}", self.hex)
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 fn base() -> PooledTargetDimensions {
169 PooledTargetDimensions::new(
170 "/data/projects/acme",
171 "nightly-2025-11-01",
172 "x86_64-unknown-linux-gnu",
173 "dev",
174 )
175 }
176
177 fn key_of(dims: &PooledTargetDimensions) -> String {
178 PooledTargetKey::derive(dims).as_str().to_string()
179 }
180
181 #[test]
182 fn cache_reuse_identical_dimensions_share_a_key() {
183 assert_eq!(key_of(&base()), key_of(&base()));
185 }
186
187 #[test]
188 fn feature_order_and_duplicates_do_not_change_the_key() {
189 let a = base().with_features(["serde", "tokio"]);
190 let b = base().with_features(["tokio", "serde", "serde"]);
191 assert_eq!(key_of(&a), key_of(&b), "feature SET must determine the key");
192 }
193
194 #[test]
195 fn incompatible_features_get_separate_pools() {
196 let none = base();
198 let with = base().with_features(["tokio"]);
199 let other = base().with_features(["async-std"]);
200 assert_ne!(key_of(&none), key_of(&with));
201 assert_ne!(key_of(&with), key_of(&other));
202 }
203
204 #[test]
205 fn distinct_repos_never_share_a_pool() {
206 let acme = base();
207 let other = PooledTargetDimensions::new(
208 "/data/projects/other",
209 "nightly-2025-11-01",
210 "x86_64-unknown-linux-gnu",
211 "dev",
212 );
213 assert_ne!(
214 key_of(&acme),
215 key_of(&other),
216 "cross-project contamination guard"
217 );
218 }
219
220 #[test]
221 fn field_boundaries_are_unambiguous_no_contamination() {
222 let left = PooledTargetDimensions::new("ab", "c", "t", "dev");
224 let right = PooledTargetDimensions::new("a", "bc", "t", "dev");
225 assert_ne!(key_of(&left), key_of(&right));
226 let p1 = PooledTargetDimensions::new("r", "t", "abc", "");
228 let p2 = PooledTargetDimensions::new("r", "t", "ab", "c");
229 assert_ne!(key_of(&p1), key_of(&p2));
230 }
231
232 #[test]
233 fn each_dimension_change_changes_the_key() {
234 let b = base();
235 let bk = key_of(&b);
236 assert_ne!(
237 bk,
238 key_of(&PooledTargetDimensions::new(
239 b.repo_identity.clone(),
240 "nightly-2026-01-01",
241 b.target_triple.clone(),
242 b.profile.clone(),
243 )),
244 "toolchain"
245 );
246 assert_ne!(
247 bk,
248 key_of(&PooledTargetDimensions::new(
249 b.repo_identity.clone(),
250 b.toolchain.clone(),
251 "wasm32-unknown-unknown",
252 b.profile.clone(),
253 )),
254 "triple"
255 );
256 assert_ne!(
257 bk,
258 key_of(&PooledTargetDimensions::new(
259 b.repo_identity.clone(),
260 b.toolchain.clone(),
261 b.target_triple.clone(),
262 "release",
263 )),
264 "profile"
265 );
266 assert_ne!(bk, key_of(&base().with_runtime("bun")), "runtime");
267 }
268
269 #[test]
270 fn key_is_filesystem_safe_and_stable_length() {
271 let key = PooledTargetKey::derive(&base().with_features(["a", "b"]));
272 let s = key.as_str();
273 assert_eq!(s.len(), 32, "128-bit truncated hex");
274 assert!(
275 s.chars()
276 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
277 "lowercase hex only: {s}"
278 );
279 assert_eq!(key.pooled_dir_name(), format!(".rch-pool/{s}"));
280 }
281
282 #[test]
283 fn runtime_none_and_empty_string_are_equivalent() {
284 let none = base();
287 let mut empty = base();
288 empty.runtime = Some(String::new());
289 assert_eq!(key_of(&none), key_of(&empty));
290 }
291}