1use std::{
2 num::NonZeroUsize,
3 ops::Deref,
4 time::{Duration, Instant},
5};
6
7use super::{
8 CachedPocketIcBaseline, ControllerSnapshotError, SnapshotRestoreFunding,
9 bounded_pool::{BoundedSlotLease, BoundedSlotPool, Slot},
10};
11
12#[non_exhaustive]
14#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
15pub enum ResetDomainKind {
16 PocketIcTime,
18 ExtraCanisters,
20 PendingMessages,
22 SubnetState,
24 ExternalResources,
26}
27
28#[non_exhaustive]
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub enum TimeResetPolicy {
32 PreserveCurrent,
34 RestoreBaseline,
36 RebuildOnMutation,
38}
39
40#[non_exhaustive]
42#[derive(Clone, Copy, Debug, Eq, PartialEq)]
43pub enum ExtraCanisterPolicy {
44 RequireBaselineSet,
46 RemoveTracked,
48 RebuildOnChange,
50}
51
52#[non_exhaustive]
54#[derive(Clone, Copy, Debug, Eq, PartialEq)]
55pub enum StateResetPolicy {
56 ResetByRecipe,
58 ValidateUnchanged,
60 IrrelevantByRecipeContract,
62 RebuildOnChange,
64}
65
66#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
94pub struct ResetPolicies {
95 pub pocket_ic_time: Option<TimeResetPolicy>,
97 pub extra_canisters: Option<ExtraCanisterPolicy>,
99 pub pending_messages: Option<StateResetPolicy>,
101 pub subnet_state: Option<StateResetPolicy>,
103 pub external_resources: Option<StateResetPolicy>,
105}
106
107#[derive(Clone, Copy, Debug, Eq, PartialEq)]
109pub struct ResetRequirements {
110 pub funding: SnapshotRestoreFunding,
112 pub policies: ResetPolicies,
114}
115
116#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
121pub struct ResetReceipt {
122 pub policies: ResetPolicies,
124}
125
126#[non_exhaustive]
128#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
129pub enum BaselinePoolContractError {
130 #[error("required reset domain {domain:?} was not achieved")]
132 MissingResetDomain {
133 domain: ResetDomainKind,
135 },
136 #[error("reset achievement {achievement:?} does not satisfy {requirement:?} for {domain:?}")]
138 ResetPolicyMismatch {
139 domain: ResetDomainKind,
141 requirement: ResetPolicies,
143 achievement: ResetPolicies,
145 },
146}
147
148#[non_exhaustive]
150#[derive(Clone, Debug, Eq, PartialEq)]
151pub enum PreparedBaseline {
152 Built,
154 Restored {
156 reset: ResetReceipt,
158 },
159}
160
161#[non_exhaustive]
163#[derive(Clone, Copy, Debug, Eq, PartialEq)]
164pub enum BaselinePreparationStage {
165 Build,
167 RestoreCanisters,
169 ResetNonSnapshotState,
171 DriveToReadiness,
173 ValidateBuilt,
175 ValidateRestored,
177}
178
179#[non_exhaustive]
181#[derive(Clone, Debug, Eq, PartialEq)]
182pub enum RebuildReason {
183 DeadPocketIcTransport,
185 SnapshotRestoreFailure,
187 ResetFailure,
189 ReadinessFailure,
191 ResetCoverageMismatch,
193 InvariantValidationFailure,
195 ExplicitLeaseInvalidation,
197 UnwindWhileLeased,
199 PreparationCancelled,
201 RecipeClassified {
203 code: String,
205 },
206}
207
208#[non_exhaustive]
210#[derive(Clone, Debug, Eq, PartialEq)]
211pub enum FailureDisposition {
212 Fatal,
214 Rebuild(RebuildReason),
216}
217
218#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
220pub struct BaselinePoolTimings {
221 wait: Duration,
222 build: Option<Duration>,
223 restore: Option<Duration>,
224 reset: Option<Duration>,
225 readiness: Option<Duration>,
226 validation: Option<Duration>,
227 stale_teardown: Option<Duration>,
228 total: Duration,
229}
230
231#[derive(Clone, Debug, Eq, PartialEq)]
233pub struct BaselinePoolOutcome {
234 pub slot: usize,
236 pub timings: BaselinePoolTimings,
238 pub disposition: BaselinePoolDisposition,
240}
241
242#[non_exhaustive]
244#[derive(Clone, Debug, Eq, PartialEq)]
245pub enum BaselinePoolDisposition {
246 Built,
248 Restored,
250 Rebuilt(RebuildReason),
252}
253
254#[non_exhaustive]
256#[derive(Debug, thiserror::Error)]
257pub enum BaselinePoolPreparationError<E> {
258 #[error("baseline {stage} failed: {source}")]
260 Recipe {
261 stage: BaselinePreparationStage,
263 source: E,
265 },
266 #[error("baseline canister restore failed: {0}")]
268 Restore(#[source] ControllerSnapshotError),
269 #[error("baseline pool contract failed: {0}")]
271 Contract(#[source] BaselinePoolContractError),
272}
273
274#[non_exhaustive]
276#[derive(Debug)]
277pub enum BaselinePoolError<E> {
278 Closed {
280 timings: Box<BaselinePoolTimings>,
282 },
283 Preparation {
285 error: BaselinePoolPreparationError<E>,
287 timings: Box<BaselinePoolTimings>,
289 },
290 RecoveryFailed {
292 original: Box<BaselinePoolPreparationError<E>>,
294 rebuild: Box<BaselinePoolPreparationError<E>>,
296 timings: Box<BaselinePoolTimings>,
298 },
299}
300
301enum PreparationFailure<E> {
303 Single(BaselinePoolPreparationError<E>),
304 Recovery {
305 original: Box<BaselinePoolPreparationError<E>>,
306 rebuild: Box<BaselinePoolPreparationError<E>>,
307 },
308}
309
310impl<E> PreparationFailure<E> {
311 fn with_timings(self, timings: BaselinePoolTimings) -> BaselinePoolError<E> {
312 let timings = Box::new(timings);
313 match self {
314 Self::Single(error) => BaselinePoolError::Preparation { error, timings },
315 Self::Recovery { original, rebuild } => BaselinePoolError::RecoveryFailed {
316 original,
317 rebuild,
318 timings,
319 },
320 }
321 }
322}
323
324pub trait PocketIcBaselineRecipe: Send + Sync + 'static {
326 type Metadata: Send + Sync + 'static;
328 type Error: std::error::Error + Send + Sync + 'static;
330
331 fn reset_requirements(&self) -> &ResetRequirements;
333
334 fn build(
341 &self,
342 ) -> impl std::future::Future<
343 Output = Result<CachedPocketIcBaseline<Self::Metadata>, Self::Error>,
344 > + Send;
345
346 fn prepare_restore(
356 &self,
357 _baseline: &CachedPocketIcBaseline<Self::Metadata>,
358 ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send {
359 async { Ok(()) }
360 }
361
362 fn reset_non_snapshot_state(
367 &self,
368 baseline: &CachedPocketIcBaseline<Self::Metadata>,
369 ) -> impl std::future::Future<Output = Result<ResetReceipt, Self::Error>> + Send;
370
371 fn drive_to_readiness(
379 &self,
380 baseline: &CachedPocketIcBaseline<Self::Metadata>,
381 ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
382
383 fn validate(
389 &self,
390 baseline: &CachedPocketIcBaseline<Self::Metadata>,
391 preparation: &PreparedBaseline,
392 ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
393
394 fn classify_failure(
396 &self,
397 stage: BaselinePreparationStage,
398 _error: &(dyn std::error::Error + 'static),
399 ) -> FailureDisposition {
400 FailureDisposition::Rebuild(stage.default_rebuild_reason())
401 }
402}
403
404pub struct CachedPocketIcBaselinePool<R>
416where
417 R: PocketIcBaselineRecipe,
418{
419 recipe: R,
420 slots: BoundedSlotPool<CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
421}
422
423pub struct CachedPocketIcBaselinePoolGuard<'a, R>
425where
426 R: PocketIcBaselineRecipe,
427{
428 slot: BoundedSlotLease<'a, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
429}
430
431impl ResetRequirements {
432 fn verify(&self, receipt: ResetReceipt) -> Result<(), BaselinePoolContractError> {
433 self.verify_policy(
434 receipt,
435 ResetDomainKind::PocketIcTime,
436 self.policies.pocket_ic_time,
437 receipt.policies.pocket_ic_time,
438 )?;
439 self.verify_policy(
440 receipt,
441 ResetDomainKind::ExtraCanisters,
442 self.policies.extra_canisters,
443 receipt.policies.extra_canisters,
444 )?;
445 self.verify_policy(
446 receipt,
447 ResetDomainKind::PendingMessages,
448 self.policies.pending_messages,
449 receipt.policies.pending_messages,
450 )?;
451 self.verify_policy(
452 receipt,
453 ResetDomainKind::SubnetState,
454 self.policies.subnet_state,
455 receipt.policies.subnet_state,
456 )?;
457 self.verify_policy(
458 receipt,
459 ResetDomainKind::ExternalResources,
460 self.policies.external_resources,
461 receipt.policies.external_resources,
462 )
463 }
464
465 fn verify_policy<P: PartialEq>(
466 &self,
467 receipt: ResetReceipt,
468 domain: ResetDomainKind,
469 requirement: Option<P>,
470 achievement: Option<P>,
471 ) -> Result<(), BaselinePoolContractError> {
472 let Some(requirement) = requirement else {
473 return Ok(());
474 };
475 let Some(achievement) = achievement else {
476 return Err(BaselinePoolContractError::MissingResetDomain { domain });
477 };
478 if requirement != achievement {
479 return Err(BaselinePoolContractError::ResetPolicyMismatch {
480 domain,
481 requirement: self.policies,
482 achievement: receipt.policies,
483 });
484 }
485 Ok(())
486 }
487}
488
489impl BaselinePreparationStage {
490 #[must_use]
495 pub fn default_rebuild_reason(self) -> RebuildReason {
496 match self {
497 Self::RestoreCanisters => RebuildReason::SnapshotRestoreFailure,
498 Self::ResetNonSnapshotState => RebuildReason::ResetFailure,
499 Self::DriveToReadiness => RebuildReason::ReadinessFailure,
500 Self::ValidateRestored | Self::ValidateBuilt => {
501 RebuildReason::InvariantValidationFailure
502 }
503 Self::Build => RebuildReason::RecipeClassified {
504 code: "build".to_owned(),
505 },
506 }
507 }
508}
509
510impl BaselinePoolTimings {
511 #[must_use]
513 pub const fn wait(self) -> Duration {
514 self.wait
515 }
516
517 #[must_use]
519 pub const fn build(self) -> Option<Duration> {
520 self.build
521 }
522
523 #[must_use]
525 pub const fn restore(self) -> Option<Duration> {
526 self.restore
527 }
528
529 #[must_use]
531 pub const fn reset(self) -> Option<Duration> {
532 self.reset
533 }
534
535 #[must_use]
537 pub const fn readiness(self) -> Option<Duration> {
538 self.readiness
539 }
540
541 #[must_use]
543 pub const fn validation(self) -> Option<Duration> {
544 self.validation
545 }
546
547 #[must_use]
549 pub const fn stale_teardown(self) -> Option<Duration> {
550 self.stale_teardown
551 }
552
553 #[must_use]
555 pub const fn total(self) -> Duration {
556 self.total
557 }
558}
559
560impl std::fmt::Display for BaselinePoolTimings {
561 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
562 write!(
563 formatter,
564 "total={:?} wait={:?} build={:?} restore={:?} reset={:?} readiness={:?} validation={:?} stale_teardown={:?}",
565 self.total,
566 self.wait,
567 self.build,
568 self.restore,
569 self.reset,
570 self.readiness,
571 self.validation,
572 self.stale_teardown,
573 )
574 }
575}
576
577impl std::fmt::Display for BaselinePoolOutcome {
578 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
579 match &self.disposition {
580 BaselinePoolDisposition::Built => formatter.write_str("built")?,
581 BaselinePoolDisposition::Restored => formatter.write_str("restored")?,
582 BaselinePoolDisposition::Rebuilt(reason) => {
583 write!(
584 formatter,
585 "rebuilt slot={} reason={reason:?} {}",
586 self.slot, self.timings
587 )?;
588 return Ok(());
589 }
590 }
591 write!(formatter, " slot={} {}", self.slot, self.timings)
592 }
593}
594
595impl<E> BaselinePoolError<E> {
596 #[must_use]
598 pub const fn timings(&self) -> BaselinePoolTimings {
599 match self {
600 Self::Closed { timings }
601 | Self::Preparation { timings, .. }
602 | Self::RecoveryFailed { timings, .. } => **timings,
603 }
604 }
605}
606
607impl<R> CachedPocketIcBaselinePool<R>
608where
609 R: PocketIcBaselineRecipe,
610{
611 #[must_use]
616 pub const fn new(capacity: NonZeroUsize, recipe: R) -> Self {
617 Self {
618 recipe,
619 slots: BoundedSlotPool::new(capacity),
620 }
621 }
622
623 pub async fn close(self) -> Vec<crate::pocket_ic::ClientError> {
625 self.shutdown().await
626 }
627
628 pub async fn shutdown(&self) -> Vec<crate::pocket_ic::ClientError> {
645 self.slots.shutdown(CachedPocketIcBaseline::close).await
646 }
647
648 #[must_use]
650 pub const fn capacity(&self) -> NonZeroUsize {
651 self.slots.capacity()
652 }
653
654 #[expect(
666 clippy::significant_drop_tightening,
667 reason = "lease and coordinator scope deliberately cover the complete operation"
668 )]
669 pub async fn acquire(
670 &self,
671 ) -> Result<
672 (CachedPocketIcBaselinePoolGuard<'_, R>, BaselinePoolOutcome),
673 BaselinePoolError<R::Error>,
674 > {
675 let total_started = Instant::now();
676 let mut slot = self.slots.acquire().await.ok_or_else(|| {
677 let wait = total_started.elapsed();
678 BaselinePoolError::Closed {
679 timings: Box::new(BaselinePoolTimings {
680 wait,
681 total: wait,
682 ..BaselinePoolTimings::default()
683 }),
684 }
685 })?;
686 slot.begin_preparation();
687 let mut timings = BaselinePoolTimings {
688 wait: slot.wait(),
689 ..BaselinePoolTimings::default()
690 };
691 let slot_index = slot.slot_index();
692
693 let result = Box::pin(self.prepare_slot(&mut slot, &mut timings)).await;
694 slot.finish_preparation();
695 timings.total = total_started.elapsed();
696 let disposition = result.map_err(|error| error.with_timings(timings))?;
697 Ok((
698 CachedPocketIcBaselinePoolGuard { slot },
699 BaselinePoolOutcome {
700 slot: slot_index,
701 timings,
702 disposition,
703 },
704 ))
705 }
706
707 async fn prepare_slot(
708 &self,
709 slot: &mut BoundedSlotLease<'_, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
710 timings: &mut BaselinePoolTimings,
711 ) -> Result<BaselinePoolDisposition, PreparationFailure<R::Error>> {
712 let (disposition, original) = match slot.state() {
713 Slot::Reusable(_) => {
714 let Err(original) = self.prepare_reused(slot, timings).await else {
715 return Ok(BaselinePoolDisposition::Restored);
716 };
717 let FailureDisposition::Rebuild(reason) = self.failure_disposition(&original)
718 else {
719 Self::discard_stale_slot(slot, timings).await;
722 return Err(PreparationFailure::Single(original));
723 };
724 (BaselinePoolDisposition::Rebuilt(reason), Some(original))
725 }
726 Slot::Empty => (BaselinePoolDisposition::Built, None),
727 Slot::Invalidated { reason, .. } => {
728 (BaselinePoolDisposition::Rebuilt(reason.clone()), None)
729 }
730 Slot::Interrupted(_) => (
731 BaselinePoolDisposition::Rebuilt(RebuildReason::PreparationCancelled),
732 None,
733 ),
734 Slot::Unwound(_) => (
735 BaselinePoolDisposition::Rebuilt(RebuildReason::UnwindWhileLeased),
736 None,
737 ),
738 };
739
740 if slot.get().is_some() {
741 Self::discard_stale_slot(slot, timings).await;
742 }
743 let build = self.build_slot(slot, timings).await;
744 if let Err(error) = build {
745 return Err(match original {
746 Some(original) => PreparationFailure::Recovery {
747 original: Box::new(original),
748 rebuild: Box::new(error),
749 },
750 None => PreparationFailure::Single(error),
751 });
752 }
753
754 Ok(disposition)
755 }
756
757 async fn prepare_reused(
758 &self,
759 slot: &BoundedSlotLease<'_, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
760 timings: &mut BaselinePoolTimings,
761 ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
762 let baseline = slot
763 .get()
764 .expect("reusable baseline slot must be populated");
765
766 let started = Instant::now();
767 let restore = async {
768 self.recipe
769 .prepare_restore(baseline)
770 .await
771 .map_err(|source| BaselinePoolPreparationError::Recipe {
772 stage: BaselinePreparationStage::RestoreCanisters,
773 source,
774 })?;
775 baseline
776 .restore(self.recipe.reset_requirements().funding)
777 .await
778 .map_err(BaselinePoolPreparationError::Restore)
779 }
780 .await;
781 add_timing(&mut timings.restore, started.elapsed());
782 restore?;
783
784 let reset = timed_recipe_stage(
785 &mut timings.reset,
786 BaselinePreparationStage::ResetNonSnapshotState,
787 self.recipe.reset_non_snapshot_state(baseline),
788 )
789 .await?;
790 timed_recipe_stage(
791 &mut timings.readiness,
792 BaselinePreparationStage::DriveToReadiness,
793 self.recipe.drive_to_readiness(baseline),
794 )
795 .await?;
796 self.recipe
797 .reset_requirements()
798 .verify(reset)
799 .map_err(BaselinePoolPreparationError::Contract)?;
800
801 let preparation = PreparedBaseline::Restored { reset };
802 self.validate_baseline(baseline, &preparation, timings)
803 .await?;
804 Ok(())
805 }
806
807 async fn build_slot(
808 &self,
809 slot: &mut BoundedSlotLease<'_, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
810 timings: &mut BaselinePoolTimings,
811 ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
812 let baseline = timed_recipe_stage(
813 &mut timings.build,
814 BaselinePreparationStage::Build,
815 self.recipe.build(),
816 )
817 .await?;
818
819 if let Err(error) = self
820 .validate_baseline(&baseline, &PreparedBaseline::Built, timings)
821 .await
822 {
823 drop_baseline_safely(baseline).await;
824 return Err(error);
825 }
826 let replaced = slot.replace(baseline);
827 debug_assert!(replaced.is_none());
828 Ok(())
829 }
830
831 async fn validate_baseline(
832 &self,
833 baseline: &CachedPocketIcBaseline<R::Metadata>,
834 preparation: &PreparedBaseline,
835 timings: &mut BaselinePoolTimings,
836 ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
837 let stage = match preparation {
838 PreparedBaseline::Built => BaselinePreparationStage::ValidateBuilt,
839 PreparedBaseline::Restored { .. } => BaselinePreparationStage::ValidateRestored,
840 };
841 timed_recipe_stage(
842 &mut timings.validation,
843 stage,
844 self.recipe.validate(baseline, preparation),
845 )
846 .await
847 }
848
849 fn failure_disposition(
850 &self,
851 error: &BaselinePoolPreparationError<R::Error>,
852 ) -> FailureDisposition {
853 match error {
854 BaselinePoolPreparationError::Recipe { stage, source } => {
855 self.recipe.classify_failure(*stage, source)
856 }
857 BaselinePoolPreparationError::Restore(source) => self
858 .recipe
859 .classify_failure(BaselinePreparationStage::RestoreCanisters, source),
860 BaselinePoolPreparationError::Contract(_) => {
861 FailureDisposition::Rebuild(RebuildReason::ResetCoverageMismatch)
862 }
863 }
864 }
865
866 async fn discard_stale_slot(
867 slot: &mut BoundedSlotLease<'_, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
868 timings: &mut BaselinePoolTimings,
869 ) {
870 let started = Instant::now();
871 if let Some(stale) = slot.take() {
872 drop_baseline_safely(stale).await;
873 }
874 timings.stale_teardown = Some(started.elapsed());
875 }
876}
877
878impl<R> CachedPocketIcBaselinePoolGuard<'_, R>
879where
880 R: PocketIcBaselineRecipe,
881{
882 #[must_use]
884 pub const fn slot(&self) -> usize {
885 self.slot.slot_index()
886 }
887
888 pub fn invalidate(&mut self, reason: RebuildReason) {
890 self.slot.invalidate(reason);
891 }
892}
893
894impl<R> Deref for CachedPocketIcBaselinePoolGuard<'_, R>
895where
896 R: PocketIcBaselineRecipe,
897{
898 type Target = CachedPocketIcBaseline<R::Metadata>;
899
900 fn deref(&self) -> &Self::Target {
901 self.slot
902 .get()
903 .expect("leased baseline pool slot must be populated")
904 }
905}
906
907async fn timed_recipe_stage<T, E>(
908 timing: &mut Option<Duration>,
909 stage: BaselinePreparationStage,
910 operation: impl std::future::Future<Output = Result<T, E>>,
911) -> Result<T, BaselinePoolPreparationError<E>> {
912 let started = Instant::now();
913 let result = operation.await;
914 add_timing(timing, started.elapsed());
915 result.map_err(|source| BaselinePoolPreparationError::Recipe { stage, source })
916}
917
918const fn add_timing(total: &mut Option<Duration>, elapsed: Duration) {
919 *total = Some(match *total {
920 None => elapsed,
921 Some(previous) => previous.saturating_add(elapsed),
922 });
923}
924
925async fn drop_baseline_safely<M>(baseline: CachedPocketIcBaseline<M>) {
926 if let Err(error) = baseline.close().await {
927 tracing::warn!("PocketIC baseline deletion failed: {error}");
928 }
929}
930
931impl std::fmt::Display for BaselinePreparationStage {
932 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
933 formatter.write_str(match self {
934 Self::Build => "build",
935 Self::RestoreCanisters => "canister restore",
936 Self::ResetNonSnapshotState => "non-snapshot reset",
937 Self::DriveToReadiness => "readiness",
938 Self::ValidateBuilt => "built-baseline validation",
939 Self::ValidateRestored => "restored-baseline validation",
940 })
941 }
942}
943
944impl<E> std::fmt::Display for BaselinePoolError<E>
945where
946 E: std::fmt::Display,
947{
948 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
949 match self {
950 Self::Closed { .. } => formatter.write_str("baseline pool is shut down"),
951 Self::Preparation { error, .. } => error.fmt(formatter),
952 Self::RecoveryFailed {
953 original, rebuild, ..
954 } => write!(
955 formatter,
956 "baseline preparation failed ({original}); rebuilding the slot also failed: {rebuild}",
957 ),
958 }
959 }
960}
961
962impl<E> std::error::Error for BaselinePoolError<E>
963where
964 E: std::error::Error + 'static,
965{
966 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
967 match self {
968 Self::Closed { .. } => None,
969 Self::Preparation { error, .. } => Some(error),
970 Self::RecoveryFailed { original, .. } => Some(original.as_ref()),
971 }
972 }
973}
974
975#[cfg(test)]
976mod tests;