templar_curator_primitives/policy/cooldown/
mod.rs

1//! Cooldown tracking for rate-limiting operations.
2//!
3//! This module provides a reusable [`Cooldown`] type for tracking time-based
4//! rate limits. It's used by both [`RefreshPlan`](super::refresh_plan::RefreshPlan)
5//! and [`MarketLock`](super::market_lock::MarketLock) for expiry semantics.
6
7use core::num::NonZeroU64;
8
9use templar_vault_kernel::{DurationNs, TimeGate, TimestampNs};
10
11/// Tracks cooldown state for rate-limited operations.
12///
13/// A cooldown enforces a minimum interval between operations. It tracks
14/// when the last operation occurred and the required interval before
15/// the next operation is allowed.
16#[templar_vault_macros::vault_derive(borsh, serde, postcard)]
17#[derive(Clone, Copy, PartialEq, Eq)]
18pub struct Cooldown {
19    /// Timestamp of the last operation (nanoseconds), if any.
20    last_event_ns: Option<u64>,
21    /// Required interval between operations (nanoseconds), if finite.
22    interval_ns: Option<NonZeroU64>,
23}
24
25impl Cooldown {
26    #[must_use]
27    fn normalized(last_event_ns: Option<u64>, interval_ns: Option<NonZeroU64>) -> Self {
28        Self {
29            last_event_ns,
30            interval_ns,
31        }
32    }
33
34    fn gate(&self) -> TimeGate {
35        if self.is_unlimited() {
36            return TimeGate::ready_now();
37        }
38
39        match (self.last_event_ns, self.interval_ns) {
40            (Some(last), Some(interval)) => {
41                TimeGate::schedule_from(TimestampNs(last), DurationNs(interval.get()))
42            }
43            _ => TimeGate::ready_now(),
44        }
45    }
46
47    #[must_use]
48    pub fn new(interval_ns: NonZeroU64) -> Self {
49        Self::normalized(None, Some(interval_ns))
50    }
51
52    #[must_use]
53    pub fn unlimited() -> Self {
54        Self {
55            last_event_ns: None,
56            interval_ns: None,
57        }
58    }
59
60    #[must_use]
61    pub fn is_unlimited(&self) -> bool {
62        self.interval_ns.is_none()
63    }
64
65    #[must_use]
66    pub fn last_event_ns(&self) -> Option<u64> {
67        self.last_event_ns
68    }
69
70    #[must_use]
71    pub fn interval_ns(&self) -> Option<NonZeroU64> {
72        self.interval_ns
73    }
74
75    /// Check if an operation is allowed at the given timestamp.
76    ///
77    /// Returns `true` if:
78    /// - No cooldown is configured, or
79    /// - No previous operation has occurred, or
80    /// - Sufficient time has elapsed since the last operation
81    ///
82    /// Readiness is inclusive at the exact boundary: `current_ns == ready_at()` is ready.
83    /// Callers are expected to pass a non-decreasing clock source; this type does not
84    /// correct or reject backward-moving timestamps.
85    #[must_use]
86    pub fn is_ready(&self, current_ns: u64) -> bool {
87        self.gate().is_ready(TimestampNs(current_ns))
88    }
89
90    pub fn try_acquire(self, current_ns: u64) -> Result<Self, CooldownError> {
91        match self.ready_at() {
92            Some(ready_at_ns) if current_ns < ready_at_ns => Err(CooldownError::OnCooldown {
93                ready_at_ns,
94                remaining_ns: ready_at_ns - current_ns,
95            }),
96            _ => Ok(self.recorded_at(current_ns)),
97        }
98    }
99
100    /// Check cooldown and return an error if not ready.
101    pub fn check(&self, current_ns: u64) -> Result<(), CooldownError> {
102        self.try_acquire(current_ns).map(|_| ())
103    }
104
105    #[must_use]
106    pub fn recorded_at(self, timestamp_ns: u64) -> Self {
107        Self::normalized(Some(timestamp_ns), self.interval_ns)
108    }
109
110    #[must_use]
111    pub fn with_last_event_ns(self, last_event_ns: Option<u64>) -> Self {
112        Self::normalized(last_event_ns, self.interval_ns)
113    }
114
115    #[must_use]
116    pub fn ready_at(&self) -> Option<u64> {
117        self.gate().ready_at_ns().map(Into::into)
118    }
119
120    #[must_use]
121    pub fn remaining(&self, current_ns: u64) -> u64 {
122        self.gate().remaining(TimestampNs(current_ns)).into()
123    }
124}
125
126/// Errors that can occur during cooldown checks.
127#[templar_vault_macros::vault_derive]
128#[derive(Clone, Copy, PartialEq, Eq)]
129pub enum CooldownError {
130    /// Operation is still on cooldown.
131    OnCooldown { ready_at_ns: u64, remaining_ns: u64 },
132}