Skip to main content

ic_testkit/pic/
baseline_pool.rs

1use candid::Principal;
2use std::{
3    collections::{BTreeMap, BTreeSet},
4    num::NonZeroUsize,
5    ops::Deref,
6    panic::{AssertUnwindSafe, catch_unwind},
7    time::{Duration, Instant},
8};
9
10use super::{
11    CachedPocketIcBaseline, ControllerSnapshots, SnapshotRestoreFunding,
12    bounded_pool::{BoundedSlotLease, BoundedSlotPool},
13};
14
15/// Caller-owned stable identity for one pooled fixture recipe.
16#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
17pub struct FixtureRecipeId(String);
18
19impl From<SnapshotRestoreFunding> for CycleResetPolicy {
20    fn from(funding: SnapshotRestoreFunding) -> Self {
21        match funding {
22            SnapshotRestoreFunding::Preserve => Self::PreserveCurrent,
23            SnapshotRestoreFunding::TopUpTo { minimum_cycles } => Self::TopUpTo(minimum_cycles),
24        }
25    }
26}
27
28/// Reset domain whose handling is declared by a pooled baseline recipe.
29#[non_exhaustive]
30#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
31pub enum ResetDomainKind {
32    /// PocketIC simulated time.
33    PocketIcTime,
34    /// Canisters outside the captured baseline set.
35    ExtraCanisters,
36    /// Pending ingress, timers, or cross-canister messages.
37    PendingMessages,
38    /// Subnet metrics, routing, allocation, or other subnet-global state.
39    SubnetState,
40    /// Files, processes, services, or other caller-owned resources.
41    ExternalResources,
42}
43
44/// Cycle handling required or achieved by one reset.
45#[non_exhaustive]
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum CycleResetPolicy {
48    /// Do not proactively add or remove cycles before snapshot restoration.
49    ///
50    /// PocketIC may still charge cycles while performing the restore.
51    PreserveCurrent,
52    /// Add cycles as needed to reach this minimum immediately before restore,
53    /// without removing excess.
54    TopUpTo(u128),
55    /// Restore the exact balance recorded by the recipe baseline.
56    RestoreExactBaseline,
57    /// Treat any relevant cycle mutation as requiring slot reconstruction.
58    RebuildOnMutation,
59}
60
61/// PocketIC time handling required or achieved by one reset.
62#[non_exhaustive]
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub enum TimeResetPolicy {
65    /// Preserve the current simulator time rather than claiming it was reset.
66    PreserveCurrent,
67    /// Restore the exact time recorded by the recipe baseline.
68    RestoreBaseline,
69    /// Treat any relevant time mutation as requiring slot reconstruction.
70    RebuildOnMutation,
71}
72
73/// Extra-canister handling required or achieved by one reset.
74#[non_exhaustive]
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76pub enum ExtraCanisterPolicy {
77    /// Validate that the baseline canister set is unchanged.
78    RequireBaselineSet,
79    /// Remove canisters explicitly tracked by the recipe.
80    RemoveTracked,
81    /// Treat any extra-canister change as requiring slot reconstruction.
82    RebuildOnChange,
83}
84
85/// Generic handling for reset domains without a more specific policy.
86#[non_exhaustive]
87#[derive(Clone, Copy, Debug, Eq, PartialEq)]
88pub enum StateResetPolicy {
89    /// Reset the domain through caller-owned recipe logic.
90    ResetByRecipe,
91    /// Validate that the domain remained unchanged.
92    ValidateUnchanged,
93    /// Explicitly declare the domain irrelevant to this recipe's guarantees.
94    IrrelevantByRecipeContract,
95    /// Treat any relevant change as requiring slot reconstruction.
96    RebuildOnChange,
97}
98
99/// Policy for one non-snapshot reset domain.
100///
101/// [`ResetRequirements`] declares policies that a recipe requires;
102/// [`ResetReceipt`] reports policies achieved by its reset operation. The pool
103/// checks every required domain against the reported policy before reuse.
104///
105/// ```
106/// use ic_testkit::pic::{
107///     CycleResetPolicy, ResetDomainKind, ResetDomainPolicy, ResetReceipt,
108///     ResetRequirements, TimeResetPolicy,
109/// };
110///
111/// let requirements = ResetRequirements::try_new(
112///     CycleResetPolicy::PreserveCurrent,
113///     [ResetDomainPolicy::PocketIcTime(TimeResetPolicy::PreserveCurrent)],
114/// )?;
115/// // The reset operation deliberately preserves simulated time.
116/// let receipt = ResetReceipt::try_new([
117///     ResetDomainPolicy::PocketIcTime(TimeResetPolicy::PreserveCurrent),
118/// ])?;
119/// assert_eq!(
120///     requirements.get(ResetDomainKind::PocketIcTime),
121///     receipt.get(ResetDomainKind::PocketIcTime),
122/// );
123/// # Ok::<(), ic_testkit::pic::BaselinePoolContractError>(())
124/// ```
125#[non_exhaustive]
126#[derive(Clone, Debug, Eq, PartialEq)]
127pub enum ResetDomainPolicy {
128    /// PocketIC time policy.
129    PocketIcTime(TimeResetPolicy),
130    /// Extra-canister policy.
131    ExtraCanisters(ExtraCanisterPolicy),
132    /// Pending-message policy.
133    PendingMessages(StateResetPolicy),
134    /// Subnet-state policy.
135    SubnetState(StateResetPolicy),
136    /// External-resource policy.
137    ExternalResources(StateResetPolicy),
138}
139
140/// Typed reset guarantees required by one fixture recipe.
141#[derive(Clone, Debug, Eq, PartialEq)]
142pub struct ResetRequirements {
143    cycle_policy: CycleResetPolicy,
144    domains: BTreeMap<ResetDomainKind, ResetDomainPolicy>,
145}
146
147/// Typed reset guarantees achieved by one preparation pass.
148#[derive(Clone, Debug, Default, Eq, PartialEq)]
149pub struct ResetReceipt(BTreeMap<ResetDomainKind, ResetDomainPolicy>);
150
151/// Receipt for restoring the recipe's captured canister set.
152#[derive(Clone, Debug, Eq, PartialEq)]
153pub struct CanisterRestoreReceipt {
154    canister_ids: Vec<Principal>,
155    cycle_policy: CycleResetPolicy,
156}
157
158/// Receipt identifying the readiness boundary reached after reset.
159#[derive(Clone, Debug, Eq, PartialEq)]
160pub struct ReadinessReceipt {
161    identity: String,
162}
163
164/// Receipt proving the recipe's final invariant validation ran successfully.
165#[derive(Clone, Debug, Eq, PartialEq)]
166pub struct ValidationReceipt {
167    recipe_id: FixtureRecipeId,
168    invariant_identity: String,
169}
170
171/// Contract failure while constructing or verifying recipe reset evidence.
172#[non_exhaustive]
173#[derive(Clone, Debug, Eq, PartialEq)]
174pub enum BaselinePoolContractError {
175    /// Recipe identity was empty or whitespace-only.
176    EmptyRecipeIdentity,
177    /// A receipt identity was empty or whitespace-only.
178    EmptyReceiptIdentity { receipt: &'static str },
179    /// A reset domain was declared more than once.
180    DuplicateResetDomain { domain: ResetDomainKind },
181    /// A restored canister appeared more than once.
182    DuplicateCanisterId { canister_id: Principal },
183    /// A restore receipt contained no canisters.
184    EmptyCanisterSet,
185    /// The restore receipt did not satisfy the required cycle policy.
186    CyclePolicyMismatch {
187        required: CycleResetPolicy,
188        achieved: CycleResetPolicy,
189    },
190    /// The restored canister receipt did not identify the complete snapshot set.
191    RestoreCanisterSetMismatch {
192        expected: Vec<Principal>,
193        actual: Vec<Principal>,
194    },
195    /// A required reset domain had no matching achievement.
196    MissingResetDomain { domain: ResetDomainKind },
197    /// A reset achievement did not satisfy the required policy.
198    ResetPolicyMismatch {
199        requirement: ResetDomainPolicy,
200        achievement: ResetDomainPolicy,
201    },
202    /// Final validation reported a recipe other than the pool-owned recipe.
203    RecipeIdentityMismatch {
204        expected: FixtureRecipeId,
205        actual: FixtureRecipeId,
206    },
207}
208
209/// Whether validation is observing a newly built or restored baseline.
210#[non_exhaustive]
211#[derive(Clone, Debug, Eq, PartialEq)]
212pub enum PreparedBaseline {
213    /// The recipe just built this baseline.
214    Built,
215    /// The recipe restored and reset an existing baseline.
216    Restored {
217        /// Captured canisters restored by the recipe.
218        canisters: CanisterRestoreReceipt,
219        /// Typed non-snapshot reset receipt.
220        reset: ResetReceipt,
221        /// Readiness boundary reached after reset.
222        readiness: ReadinessReceipt,
223    },
224}
225
226/// Recipe stage associated with a structured preparation failure.
227#[non_exhaustive]
228#[derive(Clone, Copy, Debug, Eq, PartialEq)]
229pub enum BaselinePreparationStage {
230    /// Constructing a new baseline.
231    Build,
232    /// Restoring captured canisters.
233    RestoreCanisters,
234    /// Resetting state outside the snapshots.
235    ResetNonSnapshotState,
236    /// Driving the restored topology to readiness.
237    DriveToReadiness,
238    /// Validating a newly built baseline.
239    ValidateBuilt,
240    /// Validating a restored baseline.
241    ValidateRestored,
242}
243
244/// Why an invalid or failed slot was reconstructed.
245#[non_exhaustive]
246#[derive(Clone, Debug, Eq, PartialEq)]
247pub enum RebuildReason {
248    /// PocketIC transport was no longer reachable.
249    DeadPocketIcTransport,
250    /// Captured snapshot restoration failed.
251    SnapshotRestoreFailure,
252    /// Non-snapshot reset failed.
253    ResetFailure,
254    /// Readiness or quiescence could not be established.
255    ReadinessFailure,
256    /// Required and achieved reset domains did not match.
257    ResetCoverageMismatch,
258    /// Final invariant validation failed.
259    InvariantValidationFailure,
260    /// A caller explicitly invalidated its lease.
261    ExplicitLeaseInvalidation,
262    /// A lease was dropped while its thread was unwinding.
263    UnwindWhileLeased,
264    /// Recipe-specific structured reason.
265    RecipeClassified { code: String },
266}
267
268/// Recipe decision for a failed restored-slot preparation stage.
269#[non_exhaustive]
270#[derive(Clone, Debug, Eq, PartialEq)]
271pub enum FailureDisposition {
272    /// Return the failure without rebuilding during this acquisition.
273    Fatal,
274    /// Invalidate and rebuild the slot once.
275    Rebuild(RebuildReason),
276}
277
278/// Timings for one baseline-pool acquisition.
279#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
280pub struct BaselinePoolTimings {
281    wait: Duration,
282    build: Option<Duration>,
283    restore: Option<Duration>,
284    reset: Option<Duration>,
285    readiness: Option<Duration>,
286    validation: Option<Duration>,
287    stale_teardown: Option<Duration>,
288    total: Duration,
289}
290
291/// Whether a baseline-pool lease was built, restored, or rebuilt.
292#[non_exhaustive]
293#[derive(Clone, Debug, Eq, PartialEq)]
294pub enum BaselinePoolOutcome {
295    /// An empty slot was constructed and validated.
296    Built {
297        /// Diagnostic slot index.
298        slot: usize,
299        /// Acquisition phase timings.
300        timings: BaselinePoolTimings,
301    },
302    /// An existing slot was restored, reset, and validated.
303    Restored {
304        /// Diagnostic slot index.
305        slot: usize,
306        /// Acquisition phase timings.
307        timings: BaselinePoolTimings,
308    },
309    /// An invalid or failed slot was reconstructed and validated.
310    Rebuilt {
311        /// Diagnostic slot index.
312        slot: usize,
313        /// Reason the previous slot could not be reused.
314        reason: RebuildReason,
315        /// Acquisition phase timings.
316        timings: BaselinePoolTimings,
317    },
318}
319
320/// One failed recipe or contract stage while preparing a baseline slot.
321#[non_exhaustive]
322#[derive(Debug)]
323pub enum BaselinePoolPreparationError<E> {
324    /// Caller-owned recipe logic returned an error.
325    Recipe {
326        /// Failed lifecycle stage.
327        stage: BaselinePreparationStage,
328        /// Caller-owned structured source error.
329        source: E,
330    },
331    /// Typed reset or recipe evidence violated the pool contract.
332    Contract(BaselinePoolContractError),
333}
334
335/// Failure to acquire a validated baseline-pool lease.
336#[non_exhaustive]
337#[derive(Debug)]
338pub enum BaselinePoolError<E> {
339    /// Initial construction or a non-rebuilt preparation failed.
340    Preparation {
341        /// Recipe or contract failure that stopped acquisition.
342        error: BaselinePoolPreparationError<E>,
343        /// Phase timings recorded before acquisition failed.
344        timings: Box<BaselinePoolTimings>,
345    },
346    /// Reused-slot preparation failed and its one rebuild attempt also failed.
347    RecoveryFailed {
348        /// Original restore/reset/readiness/validation failure.
349        original: Box<BaselinePoolPreparationError<E>>,
350        /// Failure while rebuilding or validating the replacement.
351        rebuild: Box<BaselinePoolPreparationError<E>>,
352        /// Combined timings for preparation, stale teardown, and rebuilding.
353        timings: Box<BaselinePoolTimings>,
354    },
355}
356
357/// Complete caller-owned lifecycle recipe for one pooled PocketIC baseline.
358pub trait PocketIcBaselineRecipe: Send + Sync + 'static {
359    /// Metadata retained beside every baseline owned by this recipe.
360    type Metadata: Send + 'static;
361    /// Structured caller error shared by recipe lifecycle stages.
362    type Error: std::error::Error + Send + Sync + 'static;
363
364    /// Stable caller-owned recipe identity.
365    fn id(&self) -> &FixtureRecipeId;
366
367    /// Reset guarantees required before an existing slot may be reused.
368    fn reset_requirements(&self) -> &ResetRequirements;
369
370    /// Construct and capture one complete baseline.
371    fn build(&self) -> Result<CachedPocketIcBaseline<Self::Metadata>, Self::Error>;
372
373    /// Restore every captured canister and report the cycle policy applied.
374    fn restore_canisters(
375        &self,
376        baseline: &CachedPocketIcBaseline<Self::Metadata>,
377    ) -> Result<CanisterRestoreReceipt, Self::Error>;
378
379    /// Reset state not covered by canister snapshots.
380    fn reset_non_snapshot_state(
381        &self,
382        baseline: &CachedPocketIcBaseline<Self::Metadata>,
383    ) -> Result<ResetReceipt, Self::Error>;
384
385    /// Drive the topology to the recipe's readiness boundary.
386    fn drive_to_readiness(
387        &self,
388        baseline: &CachedPocketIcBaseline<Self::Metadata>,
389    ) -> Result<ReadinessReceipt, Self::Error>;
390
391    /// Validate the same baseline invariants after build and restore.
392    fn validate(
393        &self,
394        baseline: &CachedPocketIcBaseline<Self::Metadata>,
395        preparation: &PreparedBaseline,
396    ) -> Result<ValidationReceipt, Self::Error>;
397
398    /// Classify a restored-slot recipe failure as fatal or rebuildable.
399    fn classify_failure(
400        &self,
401        stage: BaselinePreparationStage,
402        _error: &Self::Error,
403    ) -> FailureDisposition {
404        FailureDisposition::Rebuild(stage.default_rebuild_reason())
405    }
406}
407
408/// Caller-owned runtime-capacity pool of independently restorable PocketIC baselines.
409///
410/// One pool structurally owns one [`PocketIcBaselineRecipe`]. A warm
411/// acquisition restores the complete captured canister set, applies the
412/// recipe's non-snapshot reset, reaches its readiness boundary, checks typed
413/// reset coverage, and validates final invariants before exposing a lease.
414/// Each capacity slot owns an independent PocketIC instance.
415///
416/// Snapshot reuse is not a complete PocketIC rollback. The recipe must account
417/// for time, extra canisters, pending messages, subnet state, cycles, and
418/// external resources when those domains matter to its tests.
419pub struct CachedPocketIcBaselinePool<R>
420where
421    R: PocketIcBaselineRecipe,
422{
423    recipe: R,
424    slots: BoundedSlotPool<CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
425}
426
427/// Exclusive lease of one validated pooled PocketIC baseline.
428pub struct CachedPocketIcBaselinePoolGuard<'a, R>
429where
430    R: PocketIcBaselineRecipe,
431{
432    slot: BoundedSlotLease<'a, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
433}
434
435impl FixtureRecipeId {
436    /// Construct a nonempty caller-owned stable recipe identity.
437    pub fn try_new(identity: impl Into<String>) -> Result<Self, BaselinePoolContractError> {
438        let identity = identity.into();
439        if identity.trim().is_empty() {
440            return Err(BaselinePoolContractError::EmptyRecipeIdentity);
441        }
442        Ok(Self(identity))
443    }
444
445    /// Borrow the recipe identity.
446    #[must_use]
447    pub fn as_str(&self) -> &str {
448        &self.0
449    }
450}
451
452impl ResetDomainPolicy {
453    /// Domain governed by this policy.
454    #[must_use]
455    pub const fn domain(&self) -> ResetDomainKind {
456        match self {
457            Self::PocketIcTime(_) => ResetDomainKind::PocketIcTime,
458            Self::ExtraCanisters(_) => ResetDomainKind::ExtraCanisters,
459            Self::PendingMessages(_) => ResetDomainKind::PendingMessages,
460            Self::SubnetState(_) => ResetDomainKind::SubnetState,
461            Self::ExternalResources(_) => ResetDomainKind::ExternalResources,
462        }
463    }
464}
465
466fn collect_reset_domains(
467    policies: impl IntoIterator<Item = ResetDomainPolicy>,
468) -> Result<BTreeMap<ResetDomainKind, ResetDomainPolicy>, BaselinePoolContractError> {
469    let mut domains = BTreeMap::new();
470    for policy in policies {
471        let domain = policy.domain();
472        if domains.insert(domain, policy).is_some() {
473            return Err(BaselinePoolContractError::DuplicateResetDomain { domain });
474        }
475    }
476    Ok(domains)
477}
478
479impl ResetRequirements {
480    /// Construct a duplicate-checked reset requirement set.
481    ///
482    /// Every recipe restores its complete snapshot set. The required cycle
483    /// policy is explicit; `requirements` describe only non-snapshot domains.
484    pub fn try_new<I>(
485        cycle_policy: CycleResetPolicy,
486        requirements: I,
487    ) -> Result<Self, BaselinePoolContractError>
488    where
489        I: IntoIterator<Item = ResetDomainPolicy>,
490    {
491        Ok(Self {
492            cycle_policy,
493            domains: collect_reset_domains(requirements)?,
494        })
495    }
496
497    /// Cycle policy required of the canister restore receipt.
498    #[must_use]
499    pub const fn cycle_policy(&self) -> CycleResetPolicy {
500        self.cycle_policy
501    }
502
503    /// Read the requirement for one domain.
504    #[must_use]
505    pub fn get(&self, domain: ResetDomainKind) -> Option<&ResetDomainPolicy> {
506        self.domains.get(&domain)
507    }
508
509    /// Iterate over requirements in deterministic domain order.
510    pub fn iter(&self) -> impl Iterator<Item = &ResetDomainPolicy> {
511        self.domains.values()
512    }
513
514    fn verify(
515        &self,
516        restore: &CanisterRestoreReceipt,
517        receipt: &ResetReceipt,
518    ) -> Result<(), BaselinePoolContractError> {
519        if restore.cycle_policy != self.cycle_policy {
520            return Err(BaselinePoolContractError::CyclePolicyMismatch {
521                required: self.cycle_policy,
522                achieved: restore.cycle_policy,
523            });
524        }
525        for (domain, requirement) in &self.domains {
526            let Some(achievement) = receipt.0.get(domain) else {
527                return Err(BaselinePoolContractError::MissingResetDomain { domain: *domain });
528            };
529            if achievement != requirement {
530                return Err(BaselinePoolContractError::ResetPolicyMismatch {
531                    requirement: requirement.clone(),
532                    achievement: achievement.clone(),
533                });
534            }
535        }
536        Ok(())
537    }
538}
539
540impl ResetReceipt {
541    /// Construct a duplicate-checked non-snapshot reset achievement set.
542    ///
543    /// Snapshot restoration and cycle policy are verified separately through
544    /// [`CanisterRestoreReceipt`].
545    pub fn try_new<I>(achievements: I) -> Result<Self, BaselinePoolContractError>
546    where
547        I: IntoIterator<Item = ResetDomainPolicy>,
548    {
549        Ok(Self(collect_reset_domains(achievements)?))
550    }
551
552    /// Create an empty receipt for recipes with no non-snapshot reset achievements.
553    #[must_use]
554    pub const fn empty() -> Self {
555        Self(BTreeMap::new())
556    }
557
558    /// Read the achievement for one domain.
559    #[must_use]
560    pub fn get(&self, domain: ResetDomainKind) -> Option<&ResetDomainPolicy> {
561        self.0.get(&domain)
562    }
563
564    /// Iterate over achievements in deterministic domain order.
565    pub fn iter(&self) -> impl Iterator<Item = &ResetDomainPolicy> {
566        self.0.values()
567    }
568}
569
570impl CanisterRestoreReceipt {
571    /// Construct a deterministic, duplicate-checked canister restore receipt.
572    pub fn try_new<I>(
573        canister_ids: I,
574        cycle_policy: CycleResetPolicy,
575    ) -> Result<Self, BaselinePoolContractError>
576    where
577        I: IntoIterator<Item = Principal>,
578    {
579        let mut unique = BTreeSet::new();
580        for canister_id in canister_ids {
581            if !unique.insert(canister_id) {
582                return Err(BaselinePoolContractError::DuplicateCanisterId { canister_id });
583            }
584        }
585        if unique.is_empty() {
586            return Err(BaselinePoolContractError::EmptyCanisterSet);
587        }
588        Ok(Self {
589            canister_ids: unique.into_iter().collect(),
590            cycle_policy,
591        })
592    }
593
594    // Only a completed baseline restore produces this receipt. Capture already
595    // admitted the nonempty set, duplicate validation and deterministic order.
596    pub(super) fn from_restore(
597        snapshots: &ControllerSnapshots,
598        funding: SnapshotRestoreFunding,
599    ) -> Self {
600        Self {
601            canister_ids: snapshots.canister_ids().collect(),
602            cycle_policy: funding.into(),
603        }
604    }
605
606    /// Restored canister ids in deterministic order.
607    #[must_use]
608    pub fn canister_ids(&self) -> &[Principal] {
609        &self.canister_ids
610    }
611
612    /// Cycle policy applied while restoring canisters.
613    #[must_use]
614    pub const fn cycle_policy(&self) -> CycleResetPolicy {
615        self.cycle_policy
616    }
617}
618
619impl ReadinessReceipt {
620    /// Construct a nonempty caller-owned readiness identity.
621    pub fn try_new(identity: impl Into<String>) -> Result<Self, BaselinePoolContractError> {
622        Ok(Self {
623            identity: nonempty_receipt_identity("readiness", identity.into())?,
624        })
625    }
626
627    /// Borrow the readiness identity.
628    #[must_use]
629    pub fn identity(&self) -> &str {
630        &self.identity
631    }
632}
633
634impl ValidationReceipt {
635    /// Construct final validation evidence for one recipe.
636    pub fn try_new(
637        recipe_id: FixtureRecipeId,
638        invariant_identity: impl Into<String>,
639    ) -> Result<Self, BaselinePoolContractError> {
640        Ok(Self {
641            recipe_id,
642            invariant_identity: nonempty_receipt_identity("validation", invariant_identity.into())?,
643        })
644    }
645
646    /// Recipe identity validated by this receipt.
647    #[must_use]
648    pub const fn recipe_id(&self) -> &FixtureRecipeId {
649        &self.recipe_id
650    }
651
652    /// Borrow the caller-owned invariant identity.
653    #[must_use]
654    pub fn invariant_identity(&self) -> &str {
655        &self.invariant_identity
656    }
657}
658
659impl BaselinePreparationStage {
660    /// Default rebuild reason used by [`PocketIcBaselineRecipe::classify_failure`].
661    ///
662    /// Recipes that override classification can use this for their fallback
663    /// after handling a more specific error such as dead PocketIC transport.
664    #[must_use]
665    pub fn default_rebuild_reason(self) -> RebuildReason {
666        match self {
667            Self::RestoreCanisters => RebuildReason::SnapshotRestoreFailure,
668            Self::ResetNonSnapshotState => RebuildReason::ResetFailure,
669            Self::DriveToReadiness => RebuildReason::ReadinessFailure,
670            Self::ValidateRestored | Self::ValidateBuilt => {
671                RebuildReason::InvariantValidationFailure
672            }
673            Self::Build => RebuildReason::RecipeClassified {
674                code: "build".to_owned(),
675            },
676        }
677    }
678}
679
680impl BaselinePoolTimings {
681    /// Time spent waiting for a capacity slot.
682    #[must_use]
683    pub const fn wait(self) -> Duration {
684        self.wait
685    }
686
687    /// Time spent constructing a new baseline.
688    #[must_use]
689    pub const fn build(self) -> Option<Duration> {
690        self.build
691    }
692
693    /// Time spent restoring captured canisters.
694    #[must_use]
695    pub const fn restore(self) -> Option<Duration> {
696        self.restore
697    }
698
699    /// Time spent resetting non-snapshot state.
700    #[must_use]
701    pub const fn reset(self) -> Option<Duration> {
702        self.reset
703    }
704
705    /// Time spent driving the topology to readiness.
706    #[must_use]
707    pub const fn readiness(self) -> Option<Duration> {
708        self.readiness
709    }
710
711    /// Time spent validating final baseline invariants.
712    #[must_use]
713    pub const fn validation(self) -> Option<Duration> {
714        self.validation
715    }
716
717    /// Time spent dropping an invalid baseline before rebuilding.
718    #[must_use]
719    pub const fn stale_teardown(self) -> Option<Duration> {
720        self.stale_teardown
721    }
722
723    /// Complete acquisition duration.
724    #[must_use]
725    pub const fn total(self) -> Duration {
726        self.total
727    }
728}
729
730impl BaselinePoolOutcome {
731    /// Diagnostic slot index used by this acquisition.
732    #[must_use]
733    pub const fn slot(&self) -> usize {
734        match self {
735            Self::Built { slot, .. } | Self::Restored { slot, .. } | Self::Rebuilt { slot, .. } => {
736                *slot
737            }
738        }
739    }
740
741    /// Acquisition phase timings.
742    #[must_use]
743    pub const fn timings(&self) -> BaselinePoolTimings {
744        match self {
745            Self::Built { timings, .. }
746            | Self::Restored { timings, .. }
747            | Self::Rebuilt { timings, .. } => *timings,
748        }
749    }
750}
751
752impl std::fmt::Display for BaselinePoolTimings {
753    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
754        write!(
755            formatter,
756            "total={:?} wait={:?} build={:?} restore={:?} reset={:?} readiness={:?} validation={:?} stale_teardown={:?}",
757            self.total,
758            self.wait,
759            self.build,
760            self.restore,
761            self.reset,
762            self.readiness,
763            self.validation,
764            self.stale_teardown,
765        )
766    }
767}
768
769impl std::fmt::Display for BaselinePoolOutcome {
770    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
771        match self {
772            Self::Built { slot, timings } => write!(formatter, "built slot={slot} {timings}"),
773            Self::Restored { slot, timings } => {
774                write!(formatter, "restored slot={slot} {timings}")
775            }
776            Self::Rebuilt {
777                slot,
778                reason,
779                timings,
780            } => write!(formatter, "rebuilt slot={slot} reason={reason:?} {timings}"),
781        }
782    }
783}
784
785impl<E> BaselinePoolError<E> {
786    /// Timings recorded before this acquisition failed.
787    #[must_use]
788    pub const fn timings(&self) -> BaselinePoolTimings {
789        match self {
790            Self::Preparation { timings, .. } | Self::RecoveryFailed { timings, .. } => **timings,
791        }
792    }
793}
794
795impl<R> CachedPocketIcBaselinePool<R>
796where
797    R: PocketIcBaselineRecipe,
798{
799    /// Create a runtime-capacity pool that structurally owns one recipe.
800    ///
801    /// Construction is const and lazy: slots are allocated on first acquisition,
802    /// and the recipe builds a baseline only when a slot needs one.
803    #[must_use]
804    pub const fn new(capacity: NonZeroUsize, recipe: R) -> Self {
805        Self {
806            recipe,
807            slots: BoundedSlotPool::new(capacity),
808        }
809    }
810
811    /// Borrow the caller-owned identity of this pool's only recipe.
812    #[must_use]
813    pub fn recipe_id(&self) -> &FixtureRecipeId {
814        self.recipe.id()
815    }
816
817    /// Maximum number of simultaneously leased PocketIC baselines.
818    #[must_use]
819    pub const fn capacity(&self) -> NonZeroUsize {
820        self.slots.capacity()
821    }
822
823    /// Acquire one fully built or restored and validated baseline lease.
824    ///
825    /// A rebuildable warm-preparation failure discards the stale slot and
826    /// performs at most one build attempt. Recipe and caller panics are never
827    /// converted into cache misses; the lease is invalidated while the panic
828    /// continues unwinding.
829    ///
830    /// # Errors
831    ///
832    /// Returns a stage-specific recipe or contract error. If warm preparation
833    /// and its one recovery build both fail, the error preserves both causes.
834    pub fn acquire(
835        &self,
836    ) -> Result<
837        (CachedPocketIcBaselinePoolGuard<'_, R>, BaselinePoolOutcome),
838        BaselinePoolError<R::Error>,
839    > {
840        let total_started = Instant::now();
841        let mut slot = self.slots.acquire();
842        let mut timings = BaselinePoolTimings {
843            wait: slot.wait(),
844            ..BaselinePoolTimings::default()
845        };
846        let slot_index = slot.slot_index();
847
848        let (rebuild_reason, original) = if slot.is_reusable() {
849            match self.prepare_reused(&slot, &mut timings) {
850                Ok(()) => {
851                    timings.total = total_started.elapsed();
852                    return Ok((
853                        CachedPocketIcBaselinePoolGuard { slot },
854                        BaselinePoolOutcome::Restored {
855                            slot: slot_index,
856                            timings,
857                        },
858                    ));
859                }
860                Err(original) => match self.failure_disposition(&original) {
861                    FailureDisposition::Fatal => {
862                        // A failed restore may have partially changed the instance.
863                        // Discard it without treating a fatal failure as a rebuild.
864                        Self::discard_stale_slot(&mut slot, &mut timings);
865                        timings.total = total_started.elapsed();
866                        return Err(BaselinePoolError::Preparation {
867                            error: original,
868                            timings: Box::new(timings),
869                        });
870                    }
871                    FailureDisposition::Rebuild(reason) => (Some(reason), Some(original)),
872                },
873            }
874        } else {
875            let reason = if slot.invalidated_by_unwind() {
876                Some(RebuildReason::UnwindWhileLeased)
877            } else {
878                slot.invalidation_reason().cloned()
879            };
880            (reason, None)
881        };
882
883        if slot.is_populated() {
884            Self::discard_stale_slot(&mut slot, &mut timings);
885        }
886        let build = self.build_slot(&mut slot, &mut timings);
887        timings.total = total_started.elapsed();
888        if let Err(error) = build {
889            return Err(match original {
890                Some(original) => BaselinePoolError::RecoveryFailed {
891                    original: Box::new(original),
892                    rebuild: Box::new(error),
893                    timings: Box::new(timings),
894                },
895                None => BaselinePoolError::Preparation {
896                    error,
897                    timings: Box::new(timings),
898                },
899            });
900        }
901
902        let outcome = rebuild_reason.map_or_else(
903            || BaselinePoolOutcome::Built {
904                slot: slot_index,
905                timings,
906            },
907            |reason| BaselinePoolOutcome::Rebuilt {
908                slot: slot_index,
909                reason,
910                timings,
911            },
912        );
913        Ok((CachedPocketIcBaselinePoolGuard { slot }, outcome))
914    }
915
916    fn prepare_reused(
917        &self,
918        slot: &BoundedSlotLease<'_, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
919        timings: &mut BaselinePoolTimings,
920    ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
921        let baseline = slot
922            .get()
923            .expect("reusable baseline slot must be populated");
924
925        let started = Instant::now();
926        let restore = self.recipe.restore_canisters(baseline);
927        add_timing(&mut timings.restore, started.elapsed());
928        let canisters = restore.map_err(|source| BaselinePoolPreparationError::Recipe {
929            stage: BaselinePreparationStage::RestoreCanisters,
930            source,
931        })?;
932        if !baseline
933            .snapshot_canister_ids()
934            .eq(canisters.canister_ids().iter().copied())
935        {
936            return Err(BaselinePoolPreparationError::Contract(
937                BaselinePoolContractError::RestoreCanisterSetMismatch {
938                    expected: baseline.snapshot_canister_ids().collect(),
939                    actual: canisters.canister_ids().to_vec(),
940                },
941            ));
942        }
943
944        let started = Instant::now();
945        let reset_result = self.recipe.reset_non_snapshot_state(baseline);
946        add_timing(&mut timings.reset, started.elapsed());
947        let reset = reset_result.map_err(|source| BaselinePoolPreparationError::Recipe {
948            stage: BaselinePreparationStage::ResetNonSnapshotState,
949            source,
950        })?;
951
952        let started = Instant::now();
953        let readiness_result = self.recipe.drive_to_readiness(baseline);
954        add_timing(&mut timings.readiness, started.elapsed());
955        let readiness =
956            readiness_result.map_err(|source| BaselinePoolPreparationError::Recipe {
957                stage: BaselinePreparationStage::DriveToReadiness,
958                source,
959            })?;
960        self.recipe
961            .reset_requirements()
962            .verify(&canisters, &reset)
963            .map_err(BaselinePoolPreparationError::Contract)?;
964
965        let preparation = PreparedBaseline::Restored {
966            canisters,
967            reset,
968            readiness,
969        };
970        self.validate_baseline(
971            baseline,
972            &preparation,
973            BaselinePreparationStage::ValidateRestored,
974            timings,
975        )?;
976        Ok(())
977    }
978
979    fn build_slot(
980        &self,
981        slot: &mut BoundedSlotLease<'_, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
982        timings: &mut BaselinePoolTimings,
983    ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
984        let started = Instant::now();
985        let build = self.recipe.build();
986        add_timing(&mut timings.build, started.elapsed());
987        let baseline = build.map_err(|source| BaselinePoolPreparationError::Recipe {
988            stage: BaselinePreparationStage::Build,
989            source,
990        })?;
991
992        if let Err(error) = self.validate_baseline(
993            &baseline,
994            &PreparedBaseline::Built,
995            BaselinePreparationStage::ValidateBuilt,
996            timings,
997        ) {
998            drop_baseline_safely(baseline);
999            return Err(error);
1000        }
1001        let replaced = slot.replace(baseline);
1002        debug_assert!(replaced.is_none());
1003        Ok(())
1004    }
1005
1006    fn validate_baseline(
1007        &self,
1008        baseline: &CachedPocketIcBaseline<R::Metadata>,
1009        preparation: &PreparedBaseline,
1010        stage: BaselinePreparationStage,
1011        timings: &mut BaselinePoolTimings,
1012    ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
1013        let started = Instant::now();
1014        let validation = self.recipe.validate(baseline, preparation);
1015        add_timing(&mut timings.validation, started.elapsed());
1016        let receipt =
1017            validation.map_err(|source| BaselinePoolPreparationError::Recipe { stage, source })?;
1018        if receipt.recipe_id() != self.recipe.id() {
1019            return Err(BaselinePoolPreparationError::Contract(
1020                BaselinePoolContractError::RecipeIdentityMismatch {
1021                    expected: self.recipe.id().clone(),
1022                    actual: receipt.recipe_id().clone(),
1023                },
1024            ));
1025        }
1026        Ok(())
1027    }
1028
1029    fn failure_disposition(
1030        &self,
1031        error: &BaselinePoolPreparationError<R::Error>,
1032    ) -> FailureDisposition {
1033        match error {
1034            BaselinePoolPreparationError::Recipe { stage, source } => {
1035                self.recipe.classify_failure(*stage, source)
1036            }
1037            BaselinePoolPreparationError::Contract(
1038                BaselinePoolContractError::RecipeIdentityMismatch { .. },
1039            ) => FailureDisposition::Fatal,
1040            BaselinePoolPreparationError::Contract(_) => {
1041                FailureDisposition::Rebuild(rebuild_reason_for_error(error))
1042            }
1043        }
1044    }
1045
1046    fn discard_stale_slot(
1047        slot: &mut BoundedSlotLease<'_, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
1048        timings: &mut BaselinePoolTimings,
1049    ) {
1050        let started = Instant::now();
1051        if let Some(stale) = slot.take() {
1052            drop_baseline_safely(stale);
1053        }
1054        timings.stale_teardown = Some(started.elapsed());
1055    }
1056}
1057
1058impl<R> CachedPocketIcBaselinePoolGuard<'_, R>
1059where
1060    R: PocketIcBaselineRecipe,
1061{
1062    /// Diagnostic slot index held by this lease.
1063    #[must_use]
1064    pub const fn slot(&self) -> usize {
1065        self.slot.slot_index()
1066    }
1067
1068    /// Mark this slot non-reusable with a structured rebuild reason.
1069    pub fn invalidate(&mut self, reason: RebuildReason) {
1070        self.slot.invalidate(reason);
1071    }
1072}
1073
1074impl<R> Deref for CachedPocketIcBaselinePoolGuard<'_, R>
1075where
1076    R: PocketIcBaselineRecipe,
1077{
1078    type Target = CachedPocketIcBaseline<R::Metadata>;
1079
1080    fn deref(&self) -> &Self::Target {
1081        self.slot
1082            .get()
1083            .expect("leased baseline pool slot must be populated")
1084    }
1085}
1086
1087fn nonempty_receipt_identity(
1088    receipt: &'static str,
1089    identity: String,
1090) -> Result<String, BaselinePoolContractError> {
1091    if identity.trim().is_empty() {
1092        return Err(BaselinePoolContractError::EmptyReceiptIdentity { receipt });
1093    }
1094    Ok(identity)
1095}
1096
1097const fn add_timing(total: &mut Option<Duration>, elapsed: Duration) {
1098    *total = Some(match *total {
1099        None => elapsed,
1100        Some(previous) => previous.saturating_add(elapsed),
1101    });
1102}
1103
1104fn rebuild_reason_for_error<E>(error: &BaselinePoolPreparationError<E>) -> RebuildReason {
1105    match error {
1106        BaselinePoolPreparationError::Recipe { stage, .. } => stage.default_rebuild_reason(),
1107        BaselinePoolPreparationError::Contract(
1108            BaselinePoolContractError::MissingResetDomain { .. }
1109            | BaselinePoolContractError::ResetPolicyMismatch { .. }
1110            | BaselinePoolContractError::CyclePolicyMismatch { .. }
1111            | BaselinePoolContractError::DuplicateResetDomain { .. }
1112            | BaselinePoolContractError::RestoreCanisterSetMismatch { .. },
1113        ) => RebuildReason::ResetCoverageMismatch,
1114        BaselinePoolPreparationError::Contract(_) => RebuildReason::InvariantValidationFailure,
1115    }
1116}
1117
1118fn drop_baseline_safely<M>(baseline: CachedPocketIcBaseline<M>) {
1119    let _ = catch_unwind(AssertUnwindSafe(|| drop(baseline)));
1120}
1121
1122impl std::fmt::Display for FixtureRecipeId {
1123    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1124        formatter.write_str(&self.0)
1125    }
1126}
1127
1128impl std::fmt::Display for BaselinePreparationStage {
1129    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1130        formatter.write_str(match self {
1131            Self::Build => "build",
1132            Self::RestoreCanisters => "canister restore",
1133            Self::ResetNonSnapshotState => "non-snapshot reset",
1134            Self::DriveToReadiness => "readiness",
1135            Self::ValidateBuilt => "built-baseline validation",
1136            Self::ValidateRestored => "restored-baseline validation",
1137        })
1138    }
1139}
1140
1141impl std::fmt::Display for BaselinePoolContractError {
1142    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1143        match self {
1144            Self::EmptyRecipeIdentity => formatter.write_str("fixture recipe identity is empty"),
1145            Self::EmptyReceiptIdentity { receipt } => {
1146                write!(formatter, "{receipt} receipt identity is empty")
1147            }
1148            Self::DuplicateResetDomain { domain } => {
1149                write!(
1150                    formatter,
1151                    "reset domain {domain:?} was reported more than once"
1152                )
1153            }
1154            Self::DuplicateCanisterId { canister_id } => {
1155                write!(formatter, "restore receipt repeats canister {canister_id}")
1156            }
1157            Self::EmptyCanisterSet => formatter.write_str("restore receipt contains no canisters"),
1158            Self::CyclePolicyMismatch { required, achieved } => write!(
1159                formatter,
1160                "restore cycle policy {achieved:?} does not satisfy {required:?}",
1161            ),
1162            Self::RestoreCanisterSetMismatch { expected, actual } => write!(
1163                formatter,
1164                "restore receipt identified canisters {actual:?}, expected captured set {expected:?}",
1165            ),
1166            Self::MissingResetDomain { domain } => {
1167                write!(
1168                    formatter,
1169                    "required reset domain {domain:?} was not achieved"
1170                )
1171            }
1172            Self::ResetPolicyMismatch {
1173                requirement,
1174                achievement,
1175            } => write!(
1176                formatter,
1177                "reset achievement {achievement:?} does not satisfy {requirement:?}",
1178            ),
1179            Self::RecipeIdentityMismatch { expected, actual } => write!(
1180                formatter,
1181                "validation receipt used recipe `{actual}` instead of `{expected}`",
1182            ),
1183        }
1184    }
1185}
1186
1187impl std::error::Error for BaselinePoolContractError {}
1188
1189impl<E> std::fmt::Display for BaselinePoolPreparationError<E>
1190where
1191    E: std::fmt::Display,
1192{
1193    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1194        match self {
1195            Self::Recipe { stage, source } => {
1196                write!(formatter, "baseline {stage} failed: {source}")
1197            }
1198            Self::Contract(error) => write!(formatter, "baseline pool contract failed: {error}"),
1199        }
1200    }
1201}
1202
1203impl<E> std::error::Error for BaselinePoolPreparationError<E>
1204where
1205    E: std::error::Error + 'static,
1206{
1207    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1208        match self {
1209            Self::Recipe { source, .. } => Some(source),
1210            Self::Contract(error) => Some(error),
1211        }
1212    }
1213}
1214
1215impl<E> std::fmt::Display for BaselinePoolError<E>
1216where
1217    E: std::fmt::Display,
1218{
1219    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1220        match self {
1221            Self::Preparation { error, .. } => error.fmt(formatter),
1222            Self::RecoveryFailed {
1223                original, rebuild, ..
1224            } => write!(
1225                formatter,
1226                "baseline preparation failed ({original}); rebuilding the slot also failed: {rebuild}",
1227            ),
1228        }
1229    }
1230}
1231
1232impl<E> std::error::Error for BaselinePoolError<E>
1233where
1234    E: std::error::Error + 'static,
1235{
1236    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1237        match self {
1238            Self::Preparation { error, .. } => Some(error),
1239            Self::RecoveryFailed { original, .. } => Some(original.as_ref()),
1240        }
1241    }
1242}
1243
1244#[cfg(test)]
1245mod tests {
1246    use super::{
1247        BaselinePoolContractError, BaselinePoolTimings, CanisterRestoreReceipt, CycleResetPolicy,
1248        ExtraCanisterPolicy, FixtureRecipeId, ResetDomainKind, ResetDomainPolicy, ResetReceipt,
1249        ResetRequirements, StateResetPolicy, TimeResetPolicy, add_timing,
1250    };
1251    use candid::Principal;
1252    use std::time::Duration;
1253
1254    #[test]
1255    fn phase_timing_preserves_absence_zero_and_saturation() {
1256        let mut timings = BaselinePoolTimings::default();
1257        assert_eq!(timings.restore(), None);
1258        add_timing(&mut timings.restore, Duration::ZERO);
1259        assert_eq!(timings.restore(), Some(Duration::ZERO));
1260        add_timing(&mut timings.restore, Duration::from_secs(2));
1261        assert_eq!(timings.restore(), Some(Duration::from_secs(2)));
1262        add_timing(&mut timings.restore, Duration::MAX);
1263        assert_eq!(timings.restore(), Some(Duration::MAX));
1264        assert_eq!(timings.build(), None);
1265    }
1266
1267    #[test]
1268    fn recipe_identity_must_be_nonempty() {
1269        assert!(matches!(
1270            FixtureRecipeId::try_new("  "),
1271            Err(BaselinePoolContractError::EmptyRecipeIdentity)
1272        ));
1273    }
1274
1275    #[test]
1276    fn caller_restore_receipts_validate_and_order_canister_ids() {
1277        let first = Principal::from_slice(&[1]);
1278        let second = Principal::from_slice(&[2]);
1279        let policy = CycleResetPolicy::TopUpTo(123);
1280        let receipt = CanisterRestoreReceipt::try_new([second, first], policy).unwrap();
1281        assert_eq!(receipt.canister_ids(), [first, second]);
1282        assert_eq!(receipt.cycle_policy(), policy);
1283        assert_eq!(
1284            CanisterRestoreReceipt::try_new([], policy),
1285            Err(BaselinePoolContractError::EmptyCanisterSet),
1286        );
1287        assert_eq!(
1288            CanisterRestoreReceipt::try_new([second, first, second], policy),
1289            Err(BaselinePoolContractError::DuplicateCanisterId {
1290                canister_id: second,
1291            }),
1292        );
1293    }
1294
1295    #[test]
1296    fn reset_declarations_and_receipts_reject_duplicate_domains() {
1297        let policies = [
1298            ResetDomainPolicy::PocketIcTime(TimeResetPolicy::PreserveCurrent),
1299            ResetDomainPolicy::PocketIcTime(TimeResetPolicy::RebuildOnMutation),
1300        ];
1301        let error = BaselinePoolContractError::DuplicateResetDomain {
1302            domain: ResetDomainKind::PocketIcTime,
1303        };
1304        assert_eq!(
1305            ResetRequirements::try_new(CycleResetPolicy::PreserveCurrent, policies.clone()),
1306            Err(error.clone()),
1307        );
1308        assert_eq!(ResetReceipt::try_new(policies), Err(error));
1309    }
1310
1311    #[test]
1312    fn required_policy_must_match_achieved_policy() {
1313        let restore = CanisterRestoreReceipt::try_new(
1314            [Principal::anonymous()],
1315            CycleResetPolicy::PreserveCurrent,
1316        )
1317        .unwrap();
1318        for (requirement, achievement) in [
1319            (
1320                ResetDomainPolicy::PocketIcTime(TimeResetPolicy::PreserveCurrent),
1321                ResetDomainPolicy::PocketIcTime(TimeResetPolicy::RebuildOnMutation),
1322            ),
1323            (
1324                ResetDomainPolicy::ExtraCanisters(ExtraCanisterPolicy::RequireBaselineSet),
1325                ResetDomainPolicy::ExtraCanisters(ExtraCanisterPolicy::RemoveTracked),
1326            ),
1327            (
1328                ResetDomainPolicy::PendingMessages(StateResetPolicy::ValidateUnchanged),
1329                ResetDomainPolicy::PendingMessages(StateResetPolicy::IrrelevantByRecipeContract),
1330            ),
1331            (
1332                ResetDomainPolicy::SubnetState(StateResetPolicy::ResetByRecipe),
1333                ResetDomainPolicy::SubnetState(StateResetPolicy::RebuildOnChange),
1334            ),
1335            (
1336                ResetDomainPolicy::ExternalResources(StateResetPolicy::ValidateUnchanged),
1337                ResetDomainPolicy::ExternalResources(StateResetPolicy::ResetByRecipe),
1338            ),
1339        ] {
1340            let requirements = ResetRequirements::try_new(
1341                CycleResetPolicy::PreserveCurrent,
1342                [requirement.clone()],
1343            )
1344            .unwrap();
1345            let receipt = ResetReceipt::try_new([requirement.clone()]).unwrap();
1346            assert_eq!(requirements.verify(&restore, &receipt), Ok(()));
1347            assert_eq!(
1348                requirements.verify(&restore, &ResetReceipt::empty()),
1349                Err(BaselinePoolContractError::MissingResetDomain {
1350                    domain: requirement.domain(),
1351                }),
1352            );
1353            let receipt = ResetReceipt::try_new([achievement.clone()]).unwrap();
1354            assert_eq!(
1355                requirements.verify(&restore, &receipt),
1356                Err(BaselinePoolContractError::ResetPolicyMismatch {
1357                    requirement,
1358                    achievement,
1359                }),
1360            );
1361        }
1362    }
1363
1364    #[test]
1365    fn restore_cycle_policy_must_match_required_policy() {
1366        let requirements =
1367            ResetRequirements::try_new(CycleResetPolicy::RestoreExactBaseline, []).unwrap();
1368        let restore = CanisterRestoreReceipt::try_new(
1369            [Principal::anonymous()],
1370            CycleResetPolicy::PreserveCurrent,
1371        )
1372        .unwrap();
1373        assert!(matches!(
1374            requirements.verify(&restore, &ResetReceipt::empty()),
1375            Err(BaselinePoolContractError::CyclePolicyMismatch {
1376                required: CycleResetPolicy::RestoreExactBaseline,
1377                achieved: CycleResetPolicy::PreserveCurrent,
1378            })
1379        ));
1380    }
1381}