1#[cfg(feature = "planning")]
2use std::collections::{HashMap, HashSet};
3
4#[cfg(feature = "planning")]
5use anyhow::Result;
6#[cfg(feature = "planning")]
7use async_trait::async_trait;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
11pub struct BuildIntent {
12 #[serde(default)]
13 pub crate_names: Vec<String>,
14 #[serde(default)]
15 pub namespace: Option<String>,
16 #[serde(default)]
17 pub cargo_lock_deps: Vec<(String, String)>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct PrefetchCandidate {
22 pub cache_key: String,
23 pub crate_name: String,
24}
25
26#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
27#[serde(rename_all = "snake_case")]
28pub enum PrefetchDisposition {
29 Execute,
30 UseFallback,
31 DoNothing,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35pub struct PrefetchPlan {
36 #[serde(default)]
37 pub plan_id: Option<String>,
38 #[serde(default)]
39 pub planner: Option<String>,
40 pub disposition: PrefetchDisposition,
41 #[serde(default)]
42 pub candidates: Vec<PrefetchCandidate>,
43}
44
45#[cfg(feature = "planning")]
46#[async_trait]
47pub trait PlannerDataSource {
48 async fn shard_candidates(
49 &self,
50 namespace: &str,
51 deps: &[(String, String)],
52 ) -> Result<Vec<PrefetchCandidate>>;
53
54 async fn history_candidates(&self, crate_names: &[String]) -> Result<Vec<PrefetchCandidate>>;
55
56 async fn key_cache_keys_for_crate(&self, crate_name: &str) -> Result<Vec<String>>;
57}
58
59#[cfg(feature = "planning")]
60pub async fn build_prefetch_plan<T>(
61 source: &T,
62 intent: &BuildIntent,
63 planner_name: &str,
64) -> Result<PrefetchPlan>
65where
66 T: PlannerDataSource + Sync + ?Sized,
67{
68 let crate_order = crate_query_order(intent);
69 let mut seen = HashSet::new();
70 let mut resolved_crates = HashSet::new();
71 let mut candidates = Vec::new();
72
73 if let Some(namespace) = intent.namespace.as_deref()
81 && !intent.cargo_lock_deps.is_empty()
82 {
83 if let Ok(shard_candidates) = source
85 .shard_candidates(namespace, &intent.cargo_lock_deps)
86 .await
87 {
88 for candidate in order_candidates_by_crate_order(shard_candidates, intent) {
89 resolved_crates.insert(candidate.crate_name.clone());
90 if seen.insert(candidate.cache_key.clone()) {
91 candidates.push(candidate);
92 }
93 }
94 }
95 }
96
97 let unresolved = |resolved: &HashSet<String>| -> Vec<String> {
98 crate_order
99 .iter()
100 .filter(|name| !resolved.contains(*name))
101 .cloned()
102 .collect()
103 };
104
105 let history_query = unresolved(&resolved_crates);
106 if !history_query.is_empty() {
107 for candidate in order_candidates_by_crate_order(
108 source.history_candidates(&history_query).await?,
109 intent,
110 ) {
111 resolved_crates.insert(candidate.crate_name.clone());
112 if seen.insert(candidate.cache_key.clone()) {
113 candidates.push(candidate);
114 }
115 }
116 }
117
118 for crate_name in unresolved(&resolved_crates) {
119 for cache_key in source.key_cache_keys_for_crate(&crate_name).await? {
120 if seen.insert(cache_key.clone()) {
121 candidates.push(PrefetchCandidate {
122 cache_key,
123 crate_name: crate_name.clone(),
124 });
125 }
126 }
127 }
128
129 Ok(execute_plan(planner_name, candidates))
130}
131
132#[cfg(feature = "planning")]
133fn execute_plan(planner_name: &str, candidates: Vec<PrefetchCandidate>) -> PrefetchPlan {
134 let planner = planner_name.trim();
135 PrefetchPlan {
136 plan_id: None,
137 planner: Some(if planner.is_empty() {
138 "planner".to_string()
139 } else {
140 planner.to_string()
141 }),
142 disposition: PrefetchDisposition::Execute,
143 candidates,
144 }
145}
146
147#[cfg(feature = "planning")]
148fn crate_query_order(intent: &BuildIntent) -> Vec<String> {
149 let mut seen = HashSet::new();
150 intent
151 .crate_names
152 .iter()
153 .filter(|crate_name| seen.insert((*crate_name).clone()))
154 .cloned()
155 .collect()
156}
157
158#[cfg(feature = "planning")]
159fn order_candidates_by_crate_order(
160 mut candidates: Vec<PrefetchCandidate>,
161 intent: &BuildIntent,
162) -> Vec<PrefetchCandidate> {
163 if intent.crate_names.is_empty() {
164 return candidates;
165 }
166
167 let mut priority = HashMap::new();
168 for (index, crate_name) in crate_query_order(intent).iter().enumerate() {
169 priority.entry(crate_name.clone()).or_insert(index);
170 }
171
172 let mut indexed_candidates = candidates.drain(..).enumerate().collect::<Vec<_>>();
173 indexed_candidates.sort_by_key(|(index, candidate)| {
174 (
175 priority
176 .get(&candidate.crate_name)
177 .copied()
178 .unwrap_or(usize::MAX),
179 *index,
180 )
181 });
182 indexed_candidates
183 .into_iter()
184 .map(|(_, candidate)| candidate)
185 .collect()
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[cfg(feature = "planning")]
193 use std::collections::HashMap;
194
195 #[cfg(feature = "planning")]
196 use anyhow::anyhow;
197
198 #[cfg(feature = "planning")]
199 #[derive(Default)]
200 struct FakePlannerDataSource {
201 shard_candidates: Vec<PrefetchCandidate>,
202 shard_error: bool,
203 history_candidates: Vec<PrefetchCandidate>,
204 history_by_crate: HashMap<String, String>,
205 key_cache: HashMap<String, Vec<String>>,
206 }
207
208 #[cfg(feature = "planning")]
209 #[async_trait]
210 impl PlannerDataSource for FakePlannerDataSource {
211 async fn shard_candidates(
212 &self,
213 _namespace: &str,
214 _deps: &[(String, String)],
215 ) -> Result<Vec<PrefetchCandidate>> {
216 if self.shard_error {
217 Err(anyhow!("shard lookup failed"))
218 } else {
219 Ok(self.shard_candidates.clone())
220 }
221 }
222
223 async fn history_candidates(
224 &self,
225 crate_names: &[String],
226 ) -> Result<Vec<PrefetchCandidate>> {
227 if !self.history_candidates.is_empty() {
228 return Ok(self.history_candidates.clone());
229 }
230
231 Ok(crate_names
232 .iter()
233 .filter_map(|crate_name| {
234 self.history_by_crate
235 .get(crate_name)
236 .map(|cache_key| PrefetchCandidate {
237 cache_key: cache_key.clone(),
238 crate_name: crate_name.clone(),
239 })
240 })
241 .collect())
242 }
243
244 async fn key_cache_keys_for_crate(&self, crate_name: &str) -> Result<Vec<String>> {
245 Ok(self.key_cache.get(crate_name).cloned().unwrap_or_default())
246 }
247 }
248
249 #[test]
250 fn test_build_intent_serde_roundtrip() {
251 let intent = BuildIntent {
252 crate_names: vec!["serde".into(), "tokio".into()],
253 namespace: Some("x86_64/hash/release".into()),
254 cargo_lock_deps: vec![("serde".into(), "1.0.0".into())],
255 };
256
257 let json = serde_json::to_string(&intent).unwrap();
258 let parsed: BuildIntent = serde_json::from_str(&json).unwrap();
259 assert_eq!(parsed, intent);
260 }
261
262 #[test]
263 fn test_build_intent_defaults_missing_fields() {
264 let parsed: BuildIntent = serde_json::from_str(r#"{"crate_names":["serde"]}"#).unwrap();
265 assert_eq!(parsed.crate_names, vec!["serde"]);
266 assert!(parsed.namespace.is_none());
267 assert!(parsed.cargo_lock_deps.is_empty());
268 }
269
270 #[test]
271 fn test_prefetch_plan_serde_roundtrip() {
272 let plan = PrefetchPlan {
273 plan_id: Some("plan-1".into()),
274 planner: Some("local".into()),
275 disposition: PrefetchDisposition::Execute,
276 candidates: vec![PrefetchCandidate {
277 cache_key: "abc".into(),
278 crate_name: "serde".into(),
279 }],
280 };
281
282 let json = serde_json::to_string(&plan).unwrap();
283 let parsed: PrefetchPlan = serde_json::from_str(&json).unwrap();
284 assert_eq!(parsed, plan);
285 }
286
287 #[test]
288 fn test_prefetch_plan_missing_disposition_is_rejected() {
289 let err = serde_json::from_str::<PrefetchPlan>(
290 r#"{"planner":"legacy","candidates":[{"cache_key":"abc","crate_name":"serde"}]}"#,
291 )
292 .unwrap_err();
293 assert!(err.to_string().contains("missing field"));
294 }
295
296 #[test]
297 fn test_prefetch_plan_do_nothing_roundtrip() {
298 let plan = PrefetchPlan {
299 plan_id: Some("plan-2".into()),
300 planner: Some("remote".into()),
301 disposition: PrefetchDisposition::DoNothing,
302 candidates: vec![],
303 };
304
305 let json = serde_json::to_string(&plan).unwrap();
306 let parsed: PrefetchPlan = serde_json::from_str(&json).unwrap();
307 assert_eq!(parsed, plan);
308 }
309
310 #[cfg(feature = "planning")]
311 #[tokio::test]
312 async fn test_build_prefetch_plan_prefers_shard_candidates() {
313 let source = FakePlannerDataSource {
314 shard_candidates: vec![PrefetchCandidate {
315 cache_key: "from-shard".into(),
316 crate_name: "serde".into(),
317 }],
318 ..Default::default()
319 };
320 let intent = BuildIntent {
321 crate_names: vec!["serde".into()],
322 namespace: Some("linux/hash/release".into()),
323 cargo_lock_deps: vec![("serde".into(), "1.0.0".into())],
324 };
325
326 let plan = build_prefetch_plan(&source, &intent, "fallback")
327 .await
328 .unwrap();
329
330 assert_eq!(plan.disposition, PrefetchDisposition::Execute);
331 assert_eq!(plan.planner.as_deref(), Some("fallback"));
332 assert_eq!(plan.candidates.len(), 1);
333 assert_eq!(plan.candidates[0].cache_key, "from-shard");
334 }
335
336 #[cfg(feature = "planning")]
337 #[tokio::test]
338 async fn test_build_prefetch_plan_falls_back_to_history_and_key_cache() {
339 let mut source = FakePlannerDataSource {
340 shard_error: true,
341 history_candidates: vec![PrefetchCandidate {
342 cache_key: "history-key".into(),
343 crate_name: "serde".into(),
344 }],
345 ..Default::default()
346 };
347 source.key_cache.insert(
348 "tokio".into(),
349 vec!["tokio-key".into(), "history-key".into()],
350 );
351
352 let intent = BuildIntent {
353 crate_names: vec!["serde".into(), "tokio".into()],
354 namespace: Some("linux/hash/debug".into()),
355 cargo_lock_deps: vec![("serde".into(), "1.0.0".into())],
356 };
357
358 let plan = build_prefetch_plan(&source, &intent, "fallback")
359 .await
360 .unwrap();
361
362 assert_eq!(plan.disposition, PrefetchDisposition::Execute);
363 assert_eq!(plan.candidates.len(), 2);
364 assert_eq!(plan.candidates[0].cache_key, "history-key");
365 assert_eq!(plan.candidates[1].cache_key, "tokio-key");
366 }
367
368 #[cfg(feature = "planning")]
369 #[tokio::test]
370 async fn test_build_prefetch_plan_orders_shard_candidates_by_crate_order() {
371 let source = FakePlannerDataSource {
372 shard_candidates: vec![
373 PrefetchCandidate {
374 cache_key: "app-key".into(),
375 crate_name: "app".into(),
376 },
377 PrefetchCandidate {
378 cache_key: "dep-key".into(),
379 crate_name: "dep".into(),
380 },
381 PrefetchCandidate {
382 cache_key: "middle-key".into(),
383 crate_name: "middle".into(),
384 },
385 ],
386 ..Default::default()
387 };
388 let intent = BuildIntent {
389 crate_names: vec!["dep".into(), "middle".into(), "app".into()],
390 namespace: Some("linux/hash/debug".into()),
391 cargo_lock_deps: vec![("dep".into(), "1.0.0".into())],
392 };
393
394 let plan = build_prefetch_plan(&source, &intent, "fallback")
395 .await
396 .unwrap();
397
398 let keys = plan
399 .candidates
400 .iter()
401 .map(|candidate| candidate.cache_key.as_str())
402 .collect::<Vec<_>>();
403 assert_eq!(keys, vec!["dep-key", "middle-key", "app-key"]);
404 }
405
406 #[cfg(feature = "planning")]
415 #[tokio::test]
416 async fn test_build_prefetch_plan_fills_crates_a_partial_shard_hit_missed() {
417 let mut source = FakePlannerDataSource {
418 shard_candidates: vec![PrefetchCandidate {
420 cache_key: "dep-shard-key".into(),
421 crate_name: "dep".into(),
422 }],
423 history_by_crate: HashMap::from([("middle".into(), "middle-history-key".into())]),
424 ..Default::default()
425 };
426 source
427 .key_cache
428 .insert("app".into(), vec!["app-key-cache-key".into()]);
429
430 let intent = BuildIntent {
431 crate_names: vec!["dep".into(), "middle".into(), "app".into()],
432 namespace: Some("linux/hash/debug".into()),
433 cargo_lock_deps: vec![("dep".into(), "1.0.0".into())],
434 };
435
436 let plan = build_prefetch_plan(&source, &intent, "fallback")
437 .await
438 .unwrap();
439
440 let keys = plan
441 .candidates
442 .iter()
443 .map(|candidate| candidate.cache_key.as_str())
444 .collect::<Vec<_>>();
445 assert_eq!(
446 keys,
447 vec!["dep-shard-key", "middle-history-key", "app-key-cache-key"],
448 "each source should fill the crates the higher-confidence ones left unresolved"
449 );
450 }
451
452 #[cfg(feature = "planning")]
456 #[tokio::test]
457 async fn test_build_prefetch_plan_does_not_requery_shard_resolved_crates() {
458 let mut source = FakePlannerDataSource {
459 shard_candidates: vec![PrefetchCandidate {
460 cache_key: "serde-shard-key".into(),
461 crate_name: "serde".into(),
462 }],
463 history_by_crate: HashMap::from([("serde".into(), "serde-stale-history-key".into())]),
464 ..Default::default()
465 };
466 source
467 .key_cache
468 .insert("serde".into(), vec!["serde-stale-key-cache-key".into()]);
469
470 let intent = BuildIntent {
471 crate_names: vec!["serde".into()],
472 namespace: Some("linux/hash/debug".into()),
473 cargo_lock_deps: vec![("serde".into(), "1.0.0".into())],
474 };
475
476 let plan = build_prefetch_plan(&source, &intent, "fallback")
477 .await
478 .unwrap();
479
480 let keys = plan
481 .candidates
482 .iter()
483 .map(|candidate| candidate.cache_key.as_str())
484 .collect::<Vec<_>>();
485 assert_eq!(keys, vec!["serde-shard-key"]);
486 }
487
488 #[cfg(feature = "planning")]
489 #[tokio::test]
490 async fn test_build_prefetch_plan_queries_history_by_crate_order() {
491 let source = FakePlannerDataSource {
492 history_by_crate: HashMap::from([
493 ("app".into(), "app-key".into()),
494 ("dep".into(), "dep-key".into()),
495 ("middle".into(), "middle-key".into()),
496 ]),
497 ..Default::default()
498 };
499 let intent = BuildIntent {
500 crate_names: vec!["dep".into(), "middle".into(), "app".into()],
501 namespace: None,
502 cargo_lock_deps: vec![],
503 };
504
505 let plan = build_prefetch_plan(&source, &intent, "fallback")
506 .await
507 .unwrap();
508
509 let keys = plan
510 .candidates
511 .iter()
512 .map(|candidate| candidate.cache_key.as_str())
513 .collect::<Vec<_>>();
514 assert_eq!(keys, vec!["dep-key", "middle-key", "app-key"]);
515 }
516}