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