1use std::time::Duration;
12
13use indexmap::IndexMap;
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17use gen_config::CacheConfig;
18use gen_types::ContentHash;
19
20#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
26pub struct CacheKey {
27 pub hash: ContentHash,
28 pub label: String,
31}
32
33impl CacheKey {
34 #[must_use]
35 pub fn new(hash: ContentHash, label: impl Into<String>) -> Self {
36 Self {
37 hash,
38 label: label.into(),
39 }
40 }
41
42 #[must_use]
44 pub fn store_path(&self) -> String {
45 format!("/{}-{}", self.hash.hex(), self.label)
46 }
47}
48
49#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
52pub enum CacheOutcome {
53 Hit {
54 substituter_url: String,
55 size_bytes: Option<u64>,
59 },
60 Miss {
61 reason: MissReason,
65 },
66}
67
68#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(tag = "kind", rename_all = "kebab-case")]
70pub enum MissReason {
71 AllSubstitutersMissing,
73 AlwaysBuildOverride,
75 NoSubstituters,
77 TransportError { substituter_url: String, detail: String },
80}
81
82#[derive(Clone, Debug, Default, Serialize, Deserialize)]
84pub struct CacheReport {
85 pub outcomes: IndexMap<String, CacheOutcome>,
86}
87
88impl CacheReport {
89 #[must_use]
90 pub fn hit_count(&self) -> usize {
91 self.outcomes
92 .values()
93 .filter(|o| matches!(o, CacheOutcome::Hit { .. }))
94 .count()
95 }
96
97 #[must_use]
98 pub fn miss_count(&self) -> usize {
99 self.outcomes
100 .values()
101 .filter(|o| matches!(o, CacheOutcome::Miss { .. }))
102 .count()
103 }
104
105 #[must_use]
106 pub fn hit_rate(&self) -> f64 {
107 let total = self.outcomes.len();
108 if total == 0 {
109 return 0.0;
110 }
111 self.hit_count() as f64 / total as f64
112 }
113}
114
115pub trait CacheClient {
118 fn probe(&self, key: &CacheKey) -> CacheOutcome;
121
122 fn probe_batch(&self, keys: &[CacheKey]) -> CacheReport {
125 let mut report = CacheReport::default();
126 for k in keys {
127 let outcome = self.probe(k);
128 report.outcomes.insert(k.label.clone(), outcome);
129 }
130 report
131 }
132}
133
134#[derive(Clone, Copy, Debug, Default)]
137pub struct NoCacheClient;
138
139impl CacheClient for NoCacheClient {
140 fn probe(&self, _key: &CacheKey) -> CacheOutcome {
141 CacheOutcome::Miss {
142 reason: MissReason::NoSubstituters,
143 }
144 }
145}
146
147#[derive(Clone, Debug)]
156pub struct AtticClient {
157 pub substituters: Vec<String>,
158 pub trusted_public_keys: Vec<String>,
159 pub timeout: Duration,
160}
161
162impl AtticClient {
163 #[must_use]
164 pub fn from_config(cfg: &CacheConfig) -> Self {
165 Self {
166 substituters: cfg.substituters.clone(),
167 trusted_public_keys: cfg.trusted_public_keys.clone(),
168 timeout: Duration::from_secs(5),
169 }
170 }
171
172 fn probe_url(substituter: &str, key: &CacheKey) -> String {
173 format!(
175 "{}/{}.narinfo",
176 substituter.trim_end_matches('/'),
177 key.hash.hex()
178 )
179 }
180}
181
182impl CacheClient for AtticClient {
183 fn probe(&self, key: &CacheKey) -> CacheOutcome {
184 if self.substituters.is_empty() {
185 return CacheOutcome::Miss {
186 reason: MissReason::NoSubstituters,
187 };
188 }
189 let url = Self::probe_url(&self.substituters[0], key);
194 CacheOutcome::Miss {
195 reason: MissReason::TransportError {
196 substituter_url: url,
197 detail: "no HTTP runtime in base build; enable the `http` feature".to_string(),
198 },
199 }
200 }
201}
202
203#[must_use]
207pub fn client_from_config(cfg: &CacheConfig) -> Box<dyn CacheClient + Send + Sync> {
208 if cfg.always_build || cfg.substituters.is_empty() {
209 Box::new(NoCacheClient)
210 } else {
211 Box::new(AtticClient::from_config(cfg))
212 }
213}
214
215#[derive(Debug, Error)]
216pub enum CacheError {
217 #[error("invalid substituter URL `{url}`: {detail}")]
218 InvalidSubstituter { url: String, detail: String },
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use gen_types::ContentHash;
225
226 fn k(label: &str) -> CacheKey {
227 CacheKey::new(ContentHash::of(label.as_bytes()), label)
228 }
229
230 #[test]
231 fn no_cache_client_always_reports_miss() {
232 let c = NoCacheClient;
233 let outcome = c.probe(&k("foo"));
234 assert!(matches!(
235 outcome,
236 CacheOutcome::Miss {
237 reason: MissReason::NoSubstituters
238 }
239 ));
240 }
241
242 #[test]
243 fn attic_client_with_no_substituters_misses() {
244 let c = AtticClient {
245 substituters: vec![],
246 trusted_public_keys: vec![],
247 timeout: Duration::from_secs(1),
248 };
249 assert!(matches!(
250 c.probe(&k("foo")),
251 CacheOutcome::Miss {
252 reason: MissReason::NoSubstituters
253 }
254 ));
255 }
256
257 #[test]
258 fn attic_client_without_http_reports_transport_error() {
259 let c = AtticClient {
260 substituters: vec!["https://cache.example.org".to_string()],
261 trusted_public_keys: vec![],
262 timeout: Duration::from_secs(1),
263 };
264 let outcome = c.probe(&k("foo"));
265 match outcome {
266 CacheOutcome::Miss {
267 reason: MissReason::TransportError { substituter_url, .. },
268 } => {
269 assert!(substituter_url.contains("cache.example.org"));
270 assert!(substituter_url.ends_with(".narinfo"));
271 }
272 other => panic!("expected TransportError miss, got {other:?}"),
273 }
274 }
275
276 #[test]
277 fn probe_url_is_substituter_plus_hash_narinfo() {
278 let key = k("demo");
279 let url = AtticClient::probe_url("https://cache.example.org/foo", &key);
280 assert_eq!(url, format!("https://cache.example.org/foo/{}.narinfo", key.hash.hex()));
281 }
282
283 #[test]
284 fn probe_url_strips_trailing_slash() {
285 let key = k("demo");
286 let url = AtticClient::probe_url("https://cache.example.org/", &key);
287 let after_scheme = url.strip_prefix("https://").unwrap();
288 assert!(!after_scheme.contains("//"), "host+path should not contain `//`: {after_scheme}");
289 }
290
291 #[test]
292 fn batch_probe_returns_one_outcome_per_key() {
293 let c = NoCacheClient;
294 let keys = vec![k("a"), k("b"), k("c")];
295 let report = c.probe_batch(&keys);
296 assert_eq!(report.outcomes.len(), 3);
297 assert_eq!(report.miss_count(), 3);
298 assert_eq!(report.hit_count(), 0);
299 assert_eq!(report.hit_rate(), 0.0);
300 }
301
302 #[test]
303 fn cache_key_store_path_is_stable() {
304 let key = CacheKey::new(ContentHash::of(b"x"), "demo");
305 let p = key.store_path();
306 assert!(p.starts_with("/"));
307 assert!(p.ends_with("-demo"));
308 }
309
310 #[test]
311 fn client_from_config_selects_no_cache_when_substituters_empty() {
312 let cfg = CacheConfig {
313 substituters: vec![],
314 trusted_public_keys: vec![],
315 always_build: false,
316 };
317 let c = client_from_config(&cfg);
318 assert!(matches!(c.probe(&k("foo")), CacheOutcome::Miss { .. }));
319 }
320
321 #[test]
322 fn client_from_config_respects_always_build_override() {
323 let cfg = CacheConfig {
324 substituters: vec!["https://x".to_string()],
325 trusted_public_keys: vec![],
326 always_build: true,
327 };
328 let c = client_from_config(&cfg);
329 let r = c.probe(&k("foo"));
331 match r {
332 CacheOutcome::Miss {
333 reason: MissReason::NoSubstituters,
334 } => (),
335 other => panic!("expected NoSubstituters miss, got {other:?}"),
336 }
337 }
338
339 #[test]
340 fn hit_rate_computes_for_mixed_outcomes() {
341 let mut report = CacheReport::default();
342 report.outcomes.insert(
343 "a".to_string(),
344 CacheOutcome::Hit {
345 substituter_url: "x".into(),
346 size_bytes: None,
347 },
348 );
349 report.outcomes.insert(
350 "b".to_string(),
351 CacheOutcome::Miss {
352 reason: MissReason::NoSubstituters,
353 },
354 );
355 assert!((report.hit_rate() - 0.5).abs() < 1e-9);
356 }
357}