Skip to main content

kache_core/
lib.rs

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/// Which source produced a candidate, i.e. how much to trust it
21/// (kunobi-ninja/kache#617).
22///
23/// `Unknown` is the `#[serde(other)]` arm so a newer planner naming a source
24/// this build has never heard of degrades to "untrusted" instead of failing
25/// the whole plan.
26#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
27#[cfg_attr(kani, derive(kani::Arbitrary))]
28#[serde(rename_all = "snake_case")]
29pub enum CandidateSource {
30    /// Exact lockfile-shard match: this build's dependency set produced it.
31    Shard,
32    /// This machine built this crate before.
33    History,
34    /// A crate NAME matched something in the remote listing. A crate name is
35    /// not a build identity, so this is a guess.
36    KeyCache,
37    #[default]
38    #[serde(other)]
39    Unknown,
40}
41
42impl CandidateSource {
43    /// Confidence rank, lower is better. Only the ORDER matters; these are not
44    /// probabilities and must not be presented as any.
45    pub fn confidence_rank(self) -> u8 {
46        match self {
47            CandidateSource::Shard => 0,
48            CandidateSource::History => 1,
49            CandidateSource::KeyCache => 2,
50            CandidateSource::Unknown => 3,
51        }
52    }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
56pub struct PrefetchCandidate {
57    pub cache_key: String,
58    pub crate_name: String,
59    /// What a miss would cost to rebuild. `None` = unknown, which is NOT the
60    /// same as zero: an un-backfilled store row reads 0, and treating that as
61    /// "free to fetch and worthless to have" would bury it (#617).
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub compile_time_ms: Option<u64>,
64    /// Stored artifact size. An admission and ranking ESTIMATE, never a
65    /// promise about compressed transfer bytes: anything enforced has to be
66    /// counted on the wire.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub size_bytes: Option<u64>,
69    #[serde(default)]
70    pub source: CandidateSource,
71    /// Position in the build's dependency order, i.e. roughly when the build
72    /// will ask for it. `None` = not in the intent's crate list.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub demand_index: Option<u32>,
75}
76
77impl PrefetchCandidate {
78    /// A candidate with no metadata, as the pre-#617 wire produced.
79    pub fn new(cache_key: String, crate_name: String) -> Self {
80        Self {
81            cache_key,
82            crate_name,
83            compile_time_ms: None,
84            size_bytes: None,
85            source: CandidateSource::Unknown,
86            demand_index: None,
87        }
88    }
89
90    pub fn with_source(mut self, source: CandidateSource) -> Self {
91        self.source = source;
92        self
93    }
94}
95
96#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
97#[serde(rename_all = "snake_case")]
98pub enum PrefetchDisposition {
99    Execute,
100    UseFallback,
101    DoNothing,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105pub struct PrefetchPlan {
106    #[serde(default)]
107    pub plan_id: Option<String>,
108    #[serde(default)]
109    pub planner: Option<String>,
110    pub disposition: PrefetchDisposition,
111    #[serde(default)]
112    pub candidates: Vec<PrefetchCandidate>,
113}
114
115/// How many candidates each source may contribute to one plan
116/// (kunobi-ninja/kache#616).
117///
118/// These bound plan COMPOSITION, which is a different job from the daemon's
119/// key/byte/time budgets: those bound resource use and are the trust boundary,
120/// these stop one low-confidence source from crowding out better candidates
121/// before the budget is even reached. `0` disables a cap.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub struct PlanLimits {
124    /// Key-cache variants per crate. A crate name is not a build identity, so
125    /// of `n` variants at most one can be the right one; taking many is paying
126    /// `n` downloads for at most one hit.
127    pub key_cache_per_crate: usize,
128    /// Key-cache candidates across the whole plan, so a build with hundreds of
129    /// unresolved crates cannot fill the plan with guesses.
130    pub key_cache_total: usize,
131    /// History entries per crate. Unlike shards, where one crate legitimately
132    /// has several compile units, extra history rows for one crate are older
133    /// variants.
134    pub history_per_crate: usize,
135}
136
137impl Default for PlanLimits {
138    fn default() -> Self {
139        Self {
140            key_cache_per_crate: 2,
141            key_cache_total: 64,
142            history_per_crate: 2,
143        }
144    }
145}
146
147/// Urgency bucket width, in dependency-order positions (#617).
148///
149/// Demand order is bucketed rather than used exactly because it comes from a
150/// guppy graph traversal, which only approximates when cargo will actually ask
151/// (cargo reorders for parallelism, build scripts, proc macros, features).
152/// Treating position 40 and 45 as meaningfully different is false precision;
153/// 40 versus 400 is real. Roughly the prefetch concurrency, so one window is
154/// about one wave of downloads.
155#[cfg(feature = "planning")]
156const URGENCY_BUCKET: u32 = 16;
157
158/// Sort key for dispatch order, lowest first (#617).
159///
160/// Lexicographic, deliberately, rather than a weighted score: a weighted sum
161/// needs coefficients, and nothing can calibrate them until #618 makes
162/// "arrived before it was demanded" measurable. Every element here is an
163/// ordering, not a magnitude.
164///
165/// 1. Urgency bucket. Prefetch races the build, so a high-value artifact
166///    needed at minute eight loses to a medium-value one needed at second
167///    five. Candidates with no demand index sort last.
168/// 2. Confidence. Within one wave, prefer the source most likely to be right.
169/// 3. Value, descending. Expensive rebuilds first, so the limited slots buy
170///    the most avoided work. Unknown cost sorts after known cost rather than
171///    being scored as zero.
172///
173/// Callers must apply this as a STABLE sort: equal keys keep source order,
174/// which is the planner's confidence-merge order.
175#[cfg(feature = "planning")]
176pub fn dispatch_sort_key(candidate: &PrefetchCandidate) -> (u32, u8, std::cmp::Reverse<u64>) {
177    let bucket = candidate
178        .demand_index
179        .map(|index| index / URGENCY_BUCKET)
180        .unwrap_or(u32::MAX);
181    (
182        bucket,
183        candidate.source.confidence_rank(),
184        // `None` -> 0 -> sorts last under Reverse, without claiming the
185        // candidate is worthless.
186        std::cmp::Reverse(candidate.compile_time_ms.unwrap_or(0)),
187    )
188}
189
190#[cfg(feature = "planning")]
191fn sort_candidates_for_dispatch(candidates: &mut [PrefetchCandidate]) {
192    candidates.sort_by_key(dispatch_sort_key);
193}
194
195/// Truncate `items` to `limit`, returning how many were dropped. `0` disables.
196///
197/// Shared by every composition cap so "0 means unlimited" is defined once
198/// rather than re-derived at each call site.
199#[cfg(feature = "planning")]
200fn cap_to(items: &mut Vec<PrefetchCandidate>, limit: usize) -> usize {
201    if limit == 0 || items.len() <= limit {
202        return 0;
203    }
204    let dropped = items.len() - limit;
205    items.truncate(limit);
206    dropped
207}
208
209/// What a plan left out, so a truncated plan is distinguishable from one that
210/// had nothing more to offer (#616).
211#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
212pub struct PlanComposition {
213    pub from_shards: usize,
214    pub from_history: usize,
215    pub from_key_cache: usize,
216    /// Dropped by [`PlanLimits`], by source.
217    pub dropped_history_per_crate: usize,
218    pub dropped_key_cache_per_crate: usize,
219    pub dropped_key_cache_total: usize,
220}
221
222impl PlanComposition {
223    pub fn dropped_total(&self) -> usize {
224        self.dropped_history_per_crate
225            + self.dropped_key_cache_per_crate
226            + self.dropped_key_cache_total
227    }
228}
229
230#[cfg(feature = "planning")]
231#[async_trait]
232pub trait PlannerDataSource {
233    async fn shard_candidates(
234        &self,
235        namespace: &str,
236        deps: &[(String, String)],
237    ) -> Result<Vec<PrefetchCandidate>>;
238
239    async fn history_candidates(&self, crate_names: &[String]) -> Result<Vec<PrefetchCandidate>>;
240
241    async fn key_cache_keys_for_crate(&self, crate_name: &str) -> Result<Vec<String>>;
242}
243
244#[cfg(feature = "planning")]
245pub async fn build_prefetch_plan<T>(
246    source: &T,
247    intent: &BuildIntent,
248    planner_name: &str,
249) -> Result<PrefetchPlan>
250where
251    T: PlannerDataSource + Sync + ?Sized,
252{
253    build_prefetch_plan_with_limits(source, intent, planner_name, PlanLimits::default())
254        .await
255        .map(|(plan, _composition)| plan)
256}
257
258/// [`build_prefetch_plan`] with explicit composition limits, also returning
259/// what the limits dropped so a caller can report it (#616).
260#[cfg(feature = "planning")]
261pub async fn build_prefetch_plan_with_limits<T>(
262    source: &T,
263    intent: &BuildIntent,
264    planner_name: &str,
265    limits: PlanLimits,
266) -> Result<(PrefetchPlan, PlanComposition)>
267where
268    T: PlannerDataSource + Sync + ?Sized,
269{
270    let crate_order = crate_query_order(intent);
271    let demand_index = demand_index_map(&crate_order);
272    let mut seen = HashSet::new();
273    let mut resolved_crates = HashSet::new();
274    let mut candidates = Vec::new();
275    let mut composition = PlanComposition::default();
276
277    // Sources are merged in descending order of confidence, each one filling
278    // only the crates the ones before it left unresolved. Shard lookups used
279    // to RETURN as soon as they produced anything (kunobi-ninja/kache#614),
280    // but a shard hit is exact per bucket: one dependency bump invalidates one
281    // of `NUM_SHARDS` buckets while the rest still match, so a single matching
282    // shard was enough to short-circuit history and key-cache recovery for
283    // every crate in every bucket that missed.
284    if let Some(namespace) = intent.namespace.as_deref()
285        && !intent.cargo_lock_deps.is_empty()
286    {
287        // A failed shard lookup is not fatal: the sources below still run.
288        if let Ok(shard_candidates) = source
289            .shard_candidates(namespace, &intent.cargo_lock_deps)
290            .await
291        {
292            for candidate in order_candidates_by_crate_order(shard_candidates, intent) {
293                resolved_crates.insert(candidate.crate_name.clone());
294                if seen.insert(candidate.cache_key.clone()) {
295                    candidates.push(candidate.with_source(CandidateSource::Shard));
296                }
297            }
298        }
299    }
300
301    let unresolved = |resolved: &HashSet<String>| -> Vec<String> {
302        crate_order
303            .iter()
304            .filter(|name| !resolved.contains(*name))
305            .cloned()
306            .collect()
307    };
308
309    composition.from_shards = candidates.len();
310
311    let history_query = unresolved(&resolved_crates);
312    if !history_query.is_empty() {
313        // Group by crate so the per-crate cap keeps the FIRST entries, which
314        // both data sources return most-recently-used first.
315        let mut by_crate: HashMap<String, Vec<PrefetchCandidate>> = HashMap::new();
316        for candidate in order_candidates_by_crate_order(
317            source.history_candidates(&history_query).await?,
318            intent,
319        ) {
320            by_crate
321                .entry(candidate.crate_name.clone())
322                .or_default()
323                .push(candidate.with_source(CandidateSource::History));
324        }
325
326        for crate_name in &history_query {
327            let Some(mut for_crate) = by_crate.remove(crate_name) else {
328                continue;
329            };
330            composition.dropped_history_per_crate +=
331                cap_to(&mut for_crate, limits.history_per_crate);
332            for candidate in for_crate {
333                resolved_crates.insert(candidate.crate_name.clone());
334                if seen.insert(candidate.cache_key.clone()) {
335                    composition.from_history += 1;
336                    candidates.push(candidate);
337                }
338            }
339        }
340    }
341
342    // The key cache is the weakest source: it maps a crate NAME to every cache
343    // key in the bucket, with no target, toolchain, profile, or feature
344    // dimension, so of `n` variants at most one can be right. Capping is not a
345    // fix for that (dimensioning the remote layout is, separately) but it stops
346    // the guesses crowding out better candidates (#616).
347    for crate_name in unresolved(&resolved_crates) {
348        if limits.key_cache_total > 0 && composition.from_key_cache >= limits.key_cache_total {
349            // Whatever this crate would have offered is dropped wholesale; count
350            // it so the truncation is visible rather than inferred.
351            composition.dropped_key_cache_total += source
352                .key_cache_keys_for_crate(&crate_name)
353                .await?
354                .into_iter()
355                .filter(|key| !seen.contains(key))
356                .count();
357            continue;
358        }
359
360        let mut for_crate: Vec<PrefetchCandidate> = source
361            .key_cache_keys_for_crate(&crate_name)
362            .await?
363            .into_iter()
364            .map(|cache_key| {
365                PrefetchCandidate::new(cache_key, crate_name.clone())
366                    .with_source(CandidateSource::KeyCache)
367            })
368            .collect();
369
370        composition.dropped_key_cache_per_crate +=
371            cap_to(&mut for_crate, limits.key_cache_per_crate);
372
373        for candidate in for_crate {
374            if limits.key_cache_total > 0 && composition.from_key_cache >= limits.key_cache_total {
375                composition.dropped_key_cache_total += 1;
376                continue;
377            }
378            if seen.insert(candidate.cache_key.clone()) {
379                composition.from_key_cache += 1;
380                candidates.push(candidate);
381            }
382        }
383    }
384
385    // Stamp demand position so the daemon can rank without the intent: it only
386    // receives the plan, and `PrefetchRequest::from_plan` drops everything else.
387    for candidate in &mut candidates {
388        candidate.demand_index = demand_index.get(&candidate.crate_name).copied();
389    }
390
391    // Dispatch order (#617). Stable, so equal keys keep the confidence-merge
392    // order the sources were appended in.
393    sort_candidates_for_dispatch(&mut candidates);
394
395    Ok((execute_plan(planner_name, candidates), composition))
396}
397
398#[cfg(feature = "planning")]
399fn execute_plan(planner_name: &str, candidates: Vec<PrefetchCandidate>) -> PrefetchPlan {
400    let planner = planner_name.trim();
401    PrefetchPlan {
402        plan_id: None,
403        planner: Some(if planner.is_empty() {
404            "planner".to_string()
405        } else {
406            planner.to_string()
407        }),
408        disposition: PrefetchDisposition::Execute,
409        candidates,
410    }
411}
412
413/// crate name -> position in dependency order, for [`dispatch_sort_key`].
414#[cfg(feature = "planning")]
415fn demand_index_map(crate_order: &[String]) -> HashMap<String, u32> {
416    crate_order
417        .iter()
418        .enumerate()
419        .map(|(index, name)| (name.clone(), index as u32))
420        .collect()
421}
422
423#[cfg(feature = "planning")]
424fn crate_query_order(intent: &BuildIntent) -> Vec<String> {
425    let mut seen = HashSet::new();
426    intent
427        .crate_names
428        .iter()
429        .filter(|crate_name| seen.insert((*crate_name).clone()))
430        .cloned()
431        .collect()
432}
433
434#[cfg(feature = "planning")]
435fn order_candidates_by_crate_order(
436    mut candidates: Vec<PrefetchCandidate>,
437    intent: &BuildIntent,
438) -> Vec<PrefetchCandidate> {
439    if intent.crate_names.is_empty() {
440        return candidates;
441    }
442
443    let mut priority = HashMap::new();
444    for (index, crate_name) in crate_query_order(intent).iter().enumerate() {
445        priority.entry(crate_name.clone()).or_insert(index);
446    }
447
448    let mut indexed_candidates = candidates.drain(..).enumerate().collect::<Vec<_>>();
449    indexed_candidates.sort_by_key(|(index, candidate)| {
450        (
451            priority
452                .get(&candidate.crate_name)
453                .copied()
454                .unwrap_or(usize::MAX),
455            *index,
456        )
457    });
458    indexed_candidates
459        .into_iter()
460        .map(|(_, candidate)| candidate)
461        .collect()
462}
463
464/// Bounded model-checking harnesses for the planner's dispatch contract.
465///
466/// These mirror the three lexicographic priorities documented by
467/// [`dispatch_sort_key`] and cover every primitive input value, rather than a
468/// sampled set. Kani injects its support crate when `cargo kani` sets
469/// `cfg(kani)`, so normal builds carry no additional dependency.
470#[cfg(all(kani, feature = "planning"))]
471mod kani_proofs {
472    use super::*;
473
474    fn candidate(
475        source: CandidateSource,
476        compile_time_ms: Option<u64>,
477        demand_index: Option<u32>,
478    ) -> PrefetchCandidate {
479        PrefetchCandidate {
480            cache_key: String::new(),
481            crate_name: String::new(),
482            compile_time_ms,
483            size_bytes: None,
484            source,
485            demand_index,
486        }
487    }
488
489    #[kani::proof]
490    fn known_demand_always_precedes_unknown_demand() {
491        let demand_index: u32 = kani::any();
492        let known = candidate(kani::any(), kani::any(), Some(demand_index));
493        let unknown = candidate(kani::any(), kani::any(), None);
494        kani::cover!();
495
496        assert!(dispatch_sort_key(&known) < dispatch_sort_key(&unknown));
497    }
498
499    #[kani::proof]
500    fn earlier_urgency_bucket_always_wins() {
501        let earlier: u32 = kani::any();
502        let later: u32 = kani::any();
503        kani::assume(earlier / URGENCY_BUCKET < later / URGENCY_BUCKET);
504        let earlier_candidate = candidate(kani::any(), kani::any(), Some(earlier));
505        let later_candidate = candidate(kani::any(), kani::any(), Some(later));
506        kani::cover!();
507
508        assert!(dispatch_sort_key(&earlier_candidate) < dispatch_sort_key(&later_candidate));
509    }
510
511    #[kani::proof]
512    fn confidence_always_wins_within_a_bucket() {
513        let better_source: CandidateSource = kani::any();
514        let worse_source: CandidateSource = kani::any();
515        kani::assume(better_source.confidence_rank() < worse_source.confidence_rank());
516        let better_demand: u32 = kani::any();
517        let worse_demand: u32 = kani::any();
518        kani::assume(better_demand / URGENCY_BUCKET == worse_demand / URGENCY_BUCKET);
519        let better = candidate(better_source, kani::any(), Some(better_demand));
520        let worse = candidate(worse_source, kani::any(), Some(worse_demand));
521        kani::cover!();
522
523        assert!(dispatch_sort_key(&better) < dispatch_sort_key(&worse));
524    }
525
526    #[kani::proof]
527    fn higher_compile_cost_wins_after_urgency_and_confidence() {
528        let cheaper: u64 = kani::any();
529        let costlier: u64 = kani::any();
530        kani::assume(cheaper < costlier);
531        let cheaper_demand: u32 = kani::any();
532        let costlier_demand: u32 = kani::any();
533        kani::assume(cheaper_demand / URGENCY_BUCKET == costlier_demand / URGENCY_BUCKET);
534        let source: CandidateSource = kani::any();
535        let cheaper_candidate = candidate(source, Some(cheaper), Some(cheaper_demand));
536        let costlier_candidate = candidate(source, Some(costlier), Some(costlier_demand));
537        kani::cover!();
538
539        assert!(dispatch_sort_key(&costlier_candidate) < dispatch_sort_key(&cheaper_candidate));
540    }
541
542    #[kani::proof]
543    fn known_positive_compile_cost_precedes_unknown_cost() {
544        let known_cost: u64 = kani::any();
545        kani::assume(known_cost > 0);
546        let demand_index: u32 = kani::any();
547        let source: CandidateSource = kani::any();
548        let known = candidate(source, Some(known_cost), Some(demand_index));
549        let unknown = candidate(source, None, Some(demand_index));
550        kani::cover!();
551
552        assert!(dispatch_sort_key(&known) < dispatch_sort_key(&unknown));
553    }
554
555    #[kani::proof]
556    fn dispatch_sort_preserves_equal_key_order() {
557        let source: CandidateSource = kani::any();
558        let compile_time_ms: Option<u64> = kani::any();
559        let demand_index: Option<u32> = kani::any();
560        let mut first = candidate(source, compile_time_ms, demand_index);
561        first.size_bytes = Some(1);
562        let mut second = candidate(source, compile_time_ms, demand_index);
563        second.size_bytes = Some(2);
564        let mut candidates = [first, second];
565
566        sort_candidates_for_dispatch(&mut candidates);
567        kani::cover!();
568
569        assert_eq!(candidates[0].size_bytes, Some(1));
570        assert_eq!(candidates[1].size_bytes, Some(2));
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577
578    #[cfg(feature = "planning")]
579    use std::collections::HashMap;
580
581    #[cfg(feature = "planning")]
582    use anyhow::anyhow;
583
584    #[cfg(feature = "planning")]
585    use proptest::prelude::*;
586
587    #[cfg(feature = "planning")]
588    #[derive(Default)]
589    struct FakePlannerDataSource {
590        shard_candidates: Vec<PrefetchCandidate>,
591        shard_error: bool,
592        history_candidates: Vec<PrefetchCandidate>,
593        history_by_crate: HashMap<String, String>,
594        key_cache: HashMap<String, Vec<String>>,
595    }
596
597    #[cfg(feature = "planning")]
598    #[async_trait]
599    impl PlannerDataSource for FakePlannerDataSource {
600        async fn shard_candidates(
601            &self,
602            _namespace: &str,
603            _deps: &[(String, String)],
604        ) -> Result<Vec<PrefetchCandidate>> {
605            if self.shard_error {
606                Err(anyhow!("shard lookup failed"))
607            } else {
608                Ok(self.shard_candidates.clone())
609            }
610        }
611
612        async fn history_candidates(
613            &self,
614            crate_names: &[String],
615        ) -> Result<Vec<PrefetchCandidate>> {
616            if !self.history_candidates.is_empty() {
617                return Ok(self.history_candidates.clone());
618            }
619
620            Ok(crate_names
621                .iter()
622                .filter_map(|crate_name| {
623                    self.history_by_crate.get(crate_name).map(|cache_key| {
624                        PrefetchCandidate::new(cache_key.clone(), crate_name.clone())
625                    })
626                })
627                .collect())
628        }
629
630        async fn key_cache_keys_for_crate(&self, crate_name: &str) -> Result<Vec<String>> {
631            Ok(self.key_cache.get(crate_name).cloned().unwrap_or_default())
632        }
633    }
634
635    #[test]
636    fn test_build_intent_serde_roundtrip() {
637        let intent = BuildIntent {
638            crate_names: vec!["serde".into(), "tokio".into()],
639            namespace: Some("x86_64/hash/release".into()),
640            cargo_lock_deps: vec![("serde".into(), "1.0.0".into())],
641        };
642
643        let json = serde_json::to_string(&intent).unwrap();
644        let parsed: BuildIntent = serde_json::from_str(&json).unwrap();
645        assert_eq!(parsed, intent);
646    }
647
648    #[test]
649    fn test_build_intent_defaults_missing_fields() {
650        let parsed: BuildIntent = serde_json::from_str(r#"{"crate_names":["serde"]}"#).unwrap();
651        assert_eq!(parsed.crate_names, vec!["serde"]);
652        assert!(parsed.namespace.is_none());
653        assert!(parsed.cargo_lock_deps.is_empty());
654    }
655
656    #[test]
657    fn test_prefetch_plan_serde_roundtrip() {
658        let plan = PrefetchPlan {
659            plan_id: Some("plan-1".into()),
660            planner: Some("local".into()),
661            disposition: PrefetchDisposition::Execute,
662            candidates: vec![PrefetchCandidate::new("abc".into(), "serde".into())],
663        };
664
665        let json = serde_json::to_string(&plan).unwrap();
666        let parsed: PrefetchPlan = serde_json::from_str(&json).unwrap();
667        assert_eq!(parsed, plan);
668    }
669
670    #[test]
671    fn test_prefetch_plan_missing_disposition_is_rejected() {
672        let err = serde_json::from_str::<PrefetchPlan>(
673            r#"{"planner":"legacy","candidates":[{"cache_key":"abc","crate_name":"serde"}]}"#,
674        )
675        .unwrap_err();
676        assert!(err.to_string().contains("missing field"));
677    }
678
679    #[test]
680    fn test_prefetch_plan_do_nothing_roundtrip() {
681        let plan = PrefetchPlan {
682            plan_id: Some("plan-2".into()),
683            planner: Some("remote".into()),
684            disposition: PrefetchDisposition::DoNothing,
685            candidates: vec![],
686        };
687
688        let json = serde_json::to_string(&plan).unwrap();
689        let parsed: PrefetchPlan = serde_json::from_str(&json).unwrap();
690        assert_eq!(parsed, plan);
691    }
692
693    #[cfg(feature = "planning")]
694    #[tokio::test]
695    async fn test_build_prefetch_plan_prefers_shard_candidates() {
696        let source = FakePlannerDataSource {
697            shard_candidates: vec![PrefetchCandidate::new("from-shard".into(), "serde".into())],
698            ..Default::default()
699        };
700        let intent = BuildIntent {
701            crate_names: vec!["serde".into()],
702            namespace: Some("linux/hash/release".into()),
703            cargo_lock_deps: vec![("serde".into(), "1.0.0".into())],
704        };
705
706        let plan = build_prefetch_plan(&source, &intent, "fallback")
707            .await
708            .unwrap();
709
710        assert_eq!(plan.disposition, PrefetchDisposition::Execute);
711        assert_eq!(plan.planner.as_deref(), Some("fallback"));
712        assert_eq!(plan.candidates.len(), 1);
713        assert_eq!(plan.candidates[0].cache_key, "from-shard");
714    }
715
716    #[cfg(feature = "planning")]
717    #[tokio::test]
718    async fn test_build_prefetch_plan_falls_back_to_history_and_key_cache() {
719        let mut source = FakePlannerDataSource {
720            shard_error: true,
721            history_candidates: vec![PrefetchCandidate::new("history-key".into(), "serde".into())],
722            ..Default::default()
723        };
724        source.key_cache.insert(
725            "tokio".into(),
726            vec!["tokio-key".into(), "history-key".into()],
727        );
728
729        let intent = BuildIntent {
730            crate_names: vec!["serde".into(), "tokio".into()],
731            namespace: Some("linux/hash/debug".into()),
732            cargo_lock_deps: vec![("serde".into(), "1.0.0".into())],
733        };
734
735        let plan = build_prefetch_plan(&source, &intent, "fallback")
736            .await
737            .unwrap();
738
739        assert_eq!(plan.disposition, PrefetchDisposition::Execute);
740        assert_eq!(plan.candidates.len(), 2);
741        assert_eq!(plan.candidates[0].cache_key, "history-key");
742        assert_eq!(plan.candidates[1].cache_key, "tokio-key");
743    }
744
745    #[cfg(feature = "planning")]
746    #[tokio::test]
747    async fn test_build_prefetch_plan_orders_shard_candidates_by_crate_order() {
748        let source = FakePlannerDataSource {
749            shard_candidates: vec![
750                PrefetchCandidate::new("app-key".into(), "app".into()),
751                PrefetchCandidate::new("dep-key".into(), "dep".into()),
752                PrefetchCandidate::new("middle-key".into(), "middle".into()),
753            ],
754            ..Default::default()
755        };
756        let intent = BuildIntent {
757            crate_names: vec!["dep".into(), "middle".into(), "app".into()],
758            namespace: Some("linux/hash/debug".into()),
759            cargo_lock_deps: vec![("dep".into(), "1.0.0".into())],
760        };
761
762        let plan = build_prefetch_plan(&source, &intent, "fallback")
763            .await
764            .unwrap();
765
766        let keys = plan
767            .candidates
768            .iter()
769            .map(|candidate| candidate.cache_key.as_str())
770            .collect::<Vec<_>>();
771        assert_eq!(keys, vec!["dep-key", "middle-key", "app-key"]);
772    }
773
774    /// A PARTIAL shard hit must not suppress the lower-confidence sources
775    /// (kunobi-ninja/kache#614).
776    ///
777    /// Shard matching is exact per bucket, so a dependency bump invalidates
778    /// one bucket while the rest still match. The planner used to return as
779    /// soon as shards produced anything, so the crates in the missed buckets
780    /// were dropped from the plan even though history and the key cache could
781    /// resolve them.
782    #[cfg(feature = "planning")]
783    #[tokio::test]
784    async fn test_build_prefetch_plan_fills_crates_a_partial_shard_hit_missed() {
785        let mut source = FakePlannerDataSource {
786            // Only `dep` is in a bucket that still matches.
787            shard_candidates: vec![PrefetchCandidate::new("dep-shard-key".into(), "dep".into())],
788            history_by_crate: HashMap::from([("middle".into(), "middle-history-key".into())]),
789            ..Default::default()
790        };
791        source
792            .key_cache
793            .insert("app".into(), vec!["app-key-cache-key".into()]);
794
795        let intent = BuildIntent {
796            crate_names: vec!["dep".into(), "middle".into(), "app".into()],
797            namespace: Some("linux/hash/debug".into()),
798            cargo_lock_deps: vec![("dep".into(), "1.0.0".into())],
799        };
800
801        let plan = build_prefetch_plan(&source, &intent, "fallback")
802            .await
803            .unwrap();
804
805        let keys = plan
806            .candidates
807            .iter()
808            .map(|candidate| candidate.cache_key.as_str())
809            .collect::<Vec<_>>();
810        assert_eq!(
811            keys,
812            vec!["dep-shard-key", "middle-history-key", "app-key-cache-key"],
813            "each source should fill the crates the higher-confidence ones left unresolved"
814        );
815    }
816
817    /// A crate the shards already resolved is not re-queried from the
818    /// lower-confidence sources (#614): shard keys are exact, history and the
819    /// key cache are not, so they only fill gaps.
820    #[cfg(feature = "planning")]
821    #[tokio::test]
822    async fn test_build_prefetch_plan_does_not_requery_shard_resolved_crates() {
823        let mut source = FakePlannerDataSource {
824            shard_candidates: vec![PrefetchCandidate::new(
825                "serde-shard-key".into(),
826                "serde".into(),
827            )],
828            history_by_crate: HashMap::from([("serde".into(), "serde-stale-history-key".into())]),
829            ..Default::default()
830        };
831        source
832            .key_cache
833            .insert("serde".into(), vec!["serde-stale-key-cache-key".into()]);
834
835        let intent = BuildIntent {
836            crate_names: vec!["serde".into()],
837            namespace: Some("linux/hash/debug".into()),
838            cargo_lock_deps: vec![("serde".into(), "1.0.0".into())],
839        };
840
841        let plan = build_prefetch_plan(&source, &intent, "fallback")
842            .await
843            .unwrap();
844
845        let keys = plan
846            .candidates
847            .iter()
848            .map(|candidate| candidate.cache_key.as_str())
849            .collect::<Vec<_>>();
850        assert_eq!(keys, vec!["serde-shard-key"]);
851    }
852
853    #[cfg(feature = "planning")]
854    #[tokio::test]
855    async fn test_build_prefetch_plan_queries_history_by_crate_order() {
856        let source = FakePlannerDataSource {
857            history_by_crate: HashMap::from([
858                ("app".into(), "app-key".into()),
859                ("dep".into(), "dep-key".into()),
860                ("middle".into(), "middle-key".into()),
861            ]),
862            ..Default::default()
863        };
864        let intent = BuildIntent {
865            crate_names: vec!["dep".into(), "middle".into(), "app".into()],
866            namespace: None,
867            cargo_lock_deps: vec![],
868        };
869
870        let plan = build_prefetch_plan(&source, &intent, "fallback")
871            .await
872            .unwrap();
873
874        let keys = plan
875            .candidates
876            .iter()
877            .map(|candidate| candidate.cache_key.as_str())
878            .collect::<Vec<_>>();
879        assert_eq!(keys, vec!["dep-key", "middle-key", "app-key"]);
880    }
881
882    #[cfg(feature = "planning")]
883    proptest! {
884        #[test]
885        fn property_crate_query_order_preserves_first_occurrence(
886            crate_ids in proptest::collection::vec(any::<u8>(), 1..64),
887        ) {
888            let mut crate_names = crate_ids
889                .into_iter()
890                .map(|id| format!("crate-{id}"))
891                .collect::<Vec<_>>();
892            crate_names.push(crate_names[0].clone());
893            let intent = BuildIntent {
894                crate_names: crate_names.clone(),
895                ..Default::default()
896            };
897
898            let mut expected = Vec::new();
899            for crate_name in crate_names {
900                if !expected.contains(&crate_name) {
901                    expected.push(crate_name);
902                }
903            }
904
905            prop_assert_eq!(crate_query_order(&intent), expected);
906        }
907
908        #[test]
909        fn property_candidate_ordering_is_a_stable_priority_permutation(
910            requested_ids in proptest::collection::vec(0u8..8, 1..24),
911            candidate_ids in proptest::collection::vec(0u8..16, 0..48),
912        ) {
913            let requested_names = requested_ids
914                .into_iter()
915                .map(|id| format!("crate-{id}"))
916                .collect::<Vec<_>>();
917            let intent = BuildIntent {
918                crate_names: requested_names.clone(),
919                ..Default::default()
920            };
921
922            // Interleave two unknown and two known candidates so even minimal
923            // shrunk inputs exercise both priority and stable tie ordering.
924            let mut candidate_names = Vec::with_capacity(candidate_ids.len() + 4);
925            candidate_names.push("not-requested".to_string());
926            candidate_names.push(requested_names[0].clone());
927            candidate_names.extend(
928                candidate_ids
929                    .into_iter()
930                    .map(|id| format!("crate-{id}")),
931            );
932            candidate_names.push("not-requested".to_string());
933            candidate_names.push(requested_names[0].clone());
934
935            let candidates = candidate_names
936                .into_iter()
937                .enumerate()
938                .map(|(index, crate_name)| {
939                    PrefetchCandidate::new(format!("key-{index}"), crate_name)
940                })
941                .collect::<Vec<_>>();
942
943            let mut expected = candidates.clone();
944            expected.sort_by_key(|candidate| {
945                requested_names
946                    .iter()
947                    .position(|name| name == &candidate.crate_name)
948                    .unwrap_or(usize::MAX)
949            });
950
951            prop_assert_eq!(
952                order_candidates_by_crate_order(candidates, &intent),
953                expected
954            );
955        }
956    }
957    // ── Composition caps and ranking (#616, #617) ────────────────────────
958
959    #[cfg(feature = "planning")]
960    fn candidate(
961        key: &str,
962        crate_name: &str,
963        source: CandidateSource,
964        compile_time_ms: Option<u64>,
965        demand_index: Option<u32>,
966    ) -> PrefetchCandidate {
967        PrefetchCandidate {
968            cache_key: key.into(),
969            crate_name: crate_name.into(),
970            compile_time_ms,
971            size_bytes: None,
972            source,
973            demand_index,
974        }
975    }
976
977    /// Confidence ordering is the whole point of the rank; assert the ORDER,
978    /// not the literal numbers.
979    #[test]
980    fn test_confidence_rank_orders_sources() {
981        assert!(
982            CandidateSource::Shard.confidence_rank() < CandidateSource::History.confidence_rank()
983        );
984        assert!(
985            CandidateSource::History.confidence_rank()
986                < CandidateSource::KeyCache.confidence_rank()
987        );
988        assert!(
989            CandidateSource::KeyCache.confidence_rank()
990                < CandidateSource::Unknown.confidence_rank()
991        );
992    }
993
994    /// An unrecognised source name degrades to `Unknown` instead of failing the
995    /// whole plan (forward compatibility with a newer planner).
996    #[test]
997    fn test_unknown_candidate_source_deserializes() {
998        let candidate: PrefetchCandidate =
999            serde_json::from_str(r#"{"cache_key":"k","crate_name":"c","source":"telepathy"}"#)
1000                .unwrap();
1001        assert_eq!(candidate.source, CandidateSource::Unknown);
1002    }
1003
1004    /// A pre-#617 candidate has no metadata and must not be rejected, and the
1005    /// missing fields must read as unknown rather than zero.
1006    #[test]
1007    fn test_legacy_candidate_wire_still_parses() {
1008        let candidate: PrefetchCandidate =
1009            serde_json::from_str(r#"{"cache_key":"k","crate_name":"c"}"#).unwrap();
1010        assert_eq!(candidate.compile_time_ms, None);
1011        assert_eq!(candidate.size_bytes, None);
1012        assert_eq!(candidate.demand_index, None);
1013        assert_eq!(candidate.source, CandidateSource::Unknown);
1014    }
1015
1016    /// Urgency dominates value: prefetch races the build, so a costly artifact
1017    /// needed late loses to a cheaper one needed now (#617).
1018    #[cfg(feature = "planning")]
1019    #[test]
1020    fn test_dispatch_order_prefers_urgency_over_value() {
1021        let urgent_cheap = candidate("a", "a", CandidateSource::Shard, Some(10), Some(0));
1022        let late_expensive = candidate("b", "b", CandidateSource::Shard, Some(100_000), Some(500));
1023        assert!(dispatch_sort_key(&urgent_cheap) < dispatch_sort_key(&late_expensive));
1024    }
1025
1026    /// Within one urgency window, value decides.
1027    #[cfg(feature = "planning")]
1028    #[test]
1029    fn test_dispatch_order_prefers_value_within_a_bucket() {
1030        let cheap = candidate("a", "a", CandidateSource::Shard, Some(10), Some(0));
1031        let costly = candidate("b", "b", CandidateSource::Shard, Some(5_000), Some(1));
1032        assert!(
1033            dispatch_sort_key(&costly) < dispatch_sort_key(&cheap),
1034            "same bucket, same source: the expensive rebuild goes first"
1035        );
1036    }
1037
1038    /// Positions inside one bucket are treated as equally urgent: guppy order
1039    /// only approximates demand time, so finer distinctions are false precision.
1040    #[cfg(feature = "planning")]
1041    #[test]
1042    fn test_dispatch_order_buckets_nearby_positions_together() {
1043        let first = candidate("a", "a", CandidateSource::Shard, Some(10), Some(0));
1044        let nearby = candidate("b", "b", CandidateSource::Shard, Some(10), Some(15));
1045        let next_bucket = candidate("c", "c", CandidateSource::Shard, Some(10), Some(16));
1046        assert_eq!(
1047            dispatch_sort_key(&first),
1048            dispatch_sort_key(&nearby),
1049            "positions 0 and 15 share a bucket"
1050        );
1051        assert!(
1052            dispatch_sort_key(&nearby) < dispatch_sort_key(&next_bucket),
1053            "position 16 starts the next bucket"
1054        );
1055    }
1056
1057    /// Within a bucket, confidence beats value: a shard match is worth more
1058    /// than a bigger number from a guess.
1059    #[cfg(feature = "planning")]
1060    #[test]
1061    fn test_dispatch_order_prefers_confidence_within_a_bucket() {
1062        let shard_cheap = candidate("a", "a", CandidateSource::Shard, Some(1), Some(0));
1063        let guess_costly = candidate("b", "b", CandidateSource::KeyCache, Some(99_999), Some(0));
1064        assert!(dispatch_sort_key(&shard_cheap) < dispatch_sort_key(&guess_costly));
1065    }
1066
1067    /// Unknown cost sorts after known cost, but is not dropped and is not
1068    /// treated as zero-value against a *different* bucket.
1069    #[cfg(feature = "planning")]
1070    #[test]
1071    fn test_dispatch_order_places_unknown_cost_last_within_its_bucket() {
1072        let known = candidate("a", "a", CandidateSource::Shard, Some(1), Some(0));
1073        let unknown = candidate("b", "b", CandidateSource::Shard, None, Some(0));
1074        assert!(dispatch_sort_key(&known) < dispatch_sort_key(&unknown));
1075
1076        // ...but an unknown-cost candidate needed NOW still beats a known-cost
1077        // one needed much later.
1078        let late_known = candidate("c", "c", CandidateSource::Shard, Some(50_000), Some(999));
1079        assert!(dispatch_sort_key(&unknown) < dispatch_sort_key(&late_known));
1080    }
1081
1082    /// A candidate outside the intent's crate list sorts last rather than first.
1083    #[cfg(feature = "planning")]
1084    #[test]
1085    fn test_dispatch_order_places_unknown_demand_last() {
1086        let known = candidate("a", "a", CandidateSource::Shard, Some(1), Some(100_000));
1087        let no_demand = candidate("b", "b", CandidateSource::Shard, Some(50_000), None);
1088        assert!(dispatch_sort_key(&known) < dispatch_sort_key(&no_demand));
1089    }
1090
1091    /// The production dispatch path applies the key with a stable sort.
1092    #[cfg(feature = "planning")]
1093    #[test]
1094    fn test_dispatch_sort_orders_candidates_and_preserves_ties() {
1095        let late = candidate("late", "late", CandidateSource::Shard, Some(1), Some(32));
1096        let first_tie = candidate("first", "first", CandidateSource::Shard, Some(10), Some(0));
1097        let second_tie = candidate(
1098            "second",
1099            "second",
1100            CandidateSource::Shard,
1101            Some(10),
1102            Some(0),
1103        );
1104        let mut candidates = vec![late, first_tie, second_tie];
1105
1106        sort_candidates_for_dispatch(&mut candidates);
1107
1108        let keys = candidates
1109            .iter()
1110            .map(|candidate| candidate.cache_key.as_str())
1111            .collect::<Vec<_>>();
1112        assert_eq!(keys, vec!["first", "second", "late"]);
1113    }
1114
1115    /// The per-crate key-cache cap keeps the first N and reports the rest.
1116    #[cfg(feature = "planning")]
1117    #[tokio::test]
1118    async fn test_key_cache_per_crate_cap() {
1119        let mut source = FakePlannerDataSource::default();
1120        source.key_cache.insert(
1121            "serde".into(),
1122            vec!["k1".into(), "k2".into(), "k3".into(), "k4".into()],
1123        );
1124        let intent = BuildIntent {
1125            crate_names: vec!["serde".into()],
1126            ..Default::default()
1127        };
1128
1129        let (plan, composition) = build_prefetch_plan_with_limits(
1130            &source,
1131            &intent,
1132            "fallback",
1133            PlanLimits {
1134                key_cache_per_crate: 2,
1135                ..PlanLimits::default()
1136            },
1137        )
1138        .await
1139        .unwrap();
1140
1141        assert_eq!(plan.candidates.len(), 2, "capped to two variants");
1142        assert_eq!(composition.from_key_cache, 2);
1143        assert_eq!(composition.dropped_key_cache_per_crate, 2);
1144        assert!(composition.dropped_total() > 0, "truncation is visible");
1145    }
1146
1147    /// The plan-wide key-cache cap stops one weak source filling the plan, and
1148    /// counts what whole crates it skipped.
1149    #[cfg(feature = "planning")]
1150    #[tokio::test]
1151    async fn test_key_cache_total_cap_counts_skipped_crates() {
1152        let mut source = FakePlannerDataSource::default();
1153        for name in ["a", "b", "c"] {
1154            source
1155                .key_cache
1156                .insert(name.into(), vec![format!("{name}-k1")]);
1157        }
1158        let intent = BuildIntent {
1159            crate_names: vec!["a".into(), "b".into(), "c".into()],
1160            ..Default::default()
1161        };
1162
1163        let (plan, composition) = build_prefetch_plan_with_limits(
1164            &source,
1165            &intent,
1166            "fallback",
1167            PlanLimits {
1168                key_cache_total: 1,
1169                ..PlanLimits::default()
1170            },
1171        )
1172        .await
1173        .unwrap();
1174
1175        assert_eq!(plan.candidates.len(), 1);
1176        assert_eq!(composition.from_key_cache, 1);
1177        assert_eq!(
1178            composition.dropped_key_cache_total, 2,
1179            "the two skipped crates are counted, not silently missing"
1180        );
1181    }
1182
1183    /// `0` disables a cap rather than dropping everything.
1184    #[cfg(feature = "planning")]
1185    #[tokio::test]
1186    async fn test_zero_limit_disables_the_cap() {
1187        let mut source = FakePlannerDataSource::default();
1188        source
1189            .key_cache
1190            .insert("serde".into(), vec!["k1".into(), "k2".into(), "k3".into()]);
1191        let intent = BuildIntent {
1192            crate_names: vec!["serde".into()],
1193            ..Default::default()
1194        };
1195
1196        let (plan, composition) = build_prefetch_plan_with_limits(
1197            &source,
1198            &intent,
1199            "fallback",
1200            PlanLimits {
1201                key_cache_per_crate: 0,
1202                key_cache_total: 0,
1203                history_per_crate: 0,
1204            },
1205        )
1206        .await
1207        .unwrap();
1208
1209        assert_eq!(plan.candidates.len(), 3);
1210        assert_eq!(composition.dropped_total(), 0);
1211    }
1212
1213    /// Candidates are stamped with their source and demand position, so the
1214    /// daemon can rank without the intent (it only receives the plan).
1215    #[cfg(feature = "planning")]
1216    #[tokio::test]
1217    async fn test_plan_stamps_source_and_demand_index() {
1218        let source = FakePlannerDataSource {
1219            history_by_crate: HashMap::from([
1220                ("dep".into(), "dep-key".into()),
1221                ("app".into(), "app-key".into()),
1222            ]),
1223            ..Default::default()
1224        };
1225        let intent = BuildIntent {
1226            crate_names: vec!["dep".into(), "app".into()],
1227            ..Default::default()
1228        };
1229
1230        let (plan, _) =
1231            build_prefetch_plan_with_limits(&source, &intent, "fallback", PlanLimits::default())
1232                .await
1233                .unwrap();
1234
1235        let dep = plan
1236            .candidates
1237            .iter()
1238            .find(|c| c.crate_name == "dep")
1239            .expect("dep planned");
1240        let app = plan
1241            .candidates
1242            .iter()
1243            .find(|c| c.crate_name == "app")
1244            .expect("app planned");
1245        assert_eq!(dep.source, CandidateSource::History);
1246        assert_eq!(dep.demand_index, Some(0));
1247        assert_eq!(app.demand_index, Some(1), "position follows crate order");
1248    }
1249
1250    /// `cap_to` computes the DROPPED count, not a ratio. 5 items capped to 2
1251    /// drops 3; a divide would say 2. (Mutation-driven: `-` vs `/` agree on
1252    /// 4-and-2, so the earlier cap test could not tell them apart.)
1253    #[cfg(feature = "planning")]
1254    #[test]
1255    fn test_cap_to_reports_the_dropped_count() {
1256        let mut items: Vec<PrefetchCandidate> = (0..5)
1257            .map(|i| PrefetchCandidate::new(format!("k{i}"), "c".into()))
1258            .collect();
1259        assert_eq!(cap_to(&mut items, 2), 3);
1260        assert_eq!(items.len(), 2);
1261        assert_eq!(items[0].cache_key, "k0", "keeps the FIRST N");
1262
1263        let mut exact: Vec<PrefetchCandidate> =
1264            vec![PrefetchCandidate::new("a".into(), "c".into())];
1265        assert_eq!(cap_to(&mut exact, 1), 0, "exactly at the limit drops none");
1266        assert_eq!(cap_to(&mut exact, 0), 0, "0 disables the cap");
1267        assert_eq!(exact.len(), 1);
1268    }
1269
1270    /// `dropped_total` sums every drop class. Each field is distinct so a
1271    /// swapped operator or a missing term changes the result.
1272    #[test]
1273    fn test_dropped_total_sums_every_class() {
1274        let composition = PlanComposition {
1275            dropped_history_per_crate: 1,
1276            dropped_key_cache_per_crate: 2,
1277            dropped_key_cache_total: 4,
1278            ..PlanComposition::default()
1279        };
1280        assert_eq!(composition.dropped_total(), 7);
1281        assert_eq!(PlanComposition::default().dropped_total(), 0);
1282    }
1283
1284    /// The per-crate history cap keeps the most-recent entries and reports the
1285    /// rest. History is ordered most-recently-used first by both data sources,
1286    /// so the cap keeps the freshest variants.
1287    #[cfg(feature = "planning")]
1288    #[tokio::test]
1289    async fn test_history_per_crate_cap() {
1290        let source = FakePlannerDataSource {
1291            history_candidates: vec![
1292                PrefetchCandidate::new("serde-newest".into(), "serde".into()),
1293                PrefetchCandidate::new("serde-older".into(), "serde".into()),
1294                PrefetchCandidate::new("serde-oldest".into(), "serde".into()),
1295            ],
1296            ..Default::default()
1297        };
1298        let intent = BuildIntent {
1299            crate_names: vec!["serde".into()],
1300            ..Default::default()
1301        };
1302
1303        let (plan, composition) = build_prefetch_plan_with_limits(
1304            &source,
1305            &intent,
1306            "fallback",
1307            PlanLimits {
1308                history_per_crate: 1,
1309                ..PlanLimits::default()
1310            },
1311        )
1312        .await
1313        .unwrap();
1314
1315        assert_eq!(composition.from_history, 1);
1316        assert_eq!(composition.dropped_history_per_crate, 2);
1317        assert_eq!(
1318            plan.candidates
1319                .iter()
1320                .map(|c| c.cache_key.as_str())
1321                .collect::<Vec<_>>(),
1322            vec!["serde-newest"]
1323        );
1324    }
1325
1326    /// Once the plan-wide key-cache budget is spent, a later crate is skipped
1327    /// WHOLESALE, and everything it would have offered is counted, not just the
1328    /// per-crate-capped subset.
1329    #[cfg(feature = "planning")]
1330    #[tokio::test]
1331    async fn test_key_cache_total_skips_a_whole_crate_and_counts_all_of_it() {
1332        let mut source = FakePlannerDataSource::default();
1333        source.key_cache.insert("a".into(), vec!["a1".into()]);
1334        source.key_cache.insert(
1335            "b".into(),
1336            vec![
1337                "b1".into(),
1338                "b2".into(),
1339                "b3".into(),
1340                "b4".into(),
1341                "b5".into(),
1342            ],
1343        );
1344        let intent = BuildIntent {
1345            crate_names: vec!["a".into(), "b".into()],
1346            ..Default::default()
1347        };
1348
1349        let (_plan, composition) = build_prefetch_plan_with_limits(
1350            &source,
1351            &intent,
1352            "fallback",
1353            PlanLimits {
1354                key_cache_per_crate: 2,
1355                key_cache_total: 1,
1356                history_per_crate: 2,
1357            },
1358        )
1359        .await
1360        .unwrap();
1361
1362        assert_eq!(composition.from_key_cache, 1, "only crate `a` fits");
1363        assert_eq!(
1364            composition.dropped_key_cache_total, 5,
1365            "all five of `b`'s variants are counted, not the two a per-crate \
1366             cap would have admitted"
1367        );
1368        assert_eq!(
1369            composition.dropped_key_cache_per_crate, 0,
1370            "`b` was skipped before the per-crate cap applied"
1371        );
1372    }
1373
1374    /// The budget can also run out PART WAY through one crate's variants: the
1375    /// remainder is dropped and counted individually.
1376    #[cfg(feature = "planning")]
1377    #[tokio::test]
1378    async fn test_key_cache_total_can_run_out_mid_crate() {
1379        let mut source = FakePlannerDataSource::default();
1380        source
1381            .key_cache
1382            .insert("serde".into(), vec!["k1".into(), "k2".into(), "k3".into()]);
1383        let intent = BuildIntent {
1384            crate_names: vec!["serde".into()],
1385            ..Default::default()
1386        };
1387
1388        let (_plan, composition) = build_prefetch_plan_with_limits(
1389            &source,
1390            &intent,
1391            "fallback",
1392            PlanLimits {
1393                key_cache_per_crate: 2,
1394                key_cache_total: 1,
1395                history_per_crate: 2,
1396            },
1397        )
1398        .await
1399        .unwrap();
1400
1401        assert_eq!(composition.from_key_cache, 1);
1402        assert_eq!(
1403            composition.dropped_key_cache_per_crate, 1,
1404            "k3 dropped by the per-crate cap"
1405        );
1406        assert_eq!(
1407            composition.dropped_key_cache_total, 1,
1408            "k2 dropped by the plan-wide cap, mid-crate"
1409        );
1410    }
1411}