Skip to main content

use_cache_store/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5use std::error::Error;
6use std::time::{Duration, SystemTime};
7
8macro_rules! string_newtype {
9    ($(#[$meta:meta])* $name:ident) => {
10        $(#[$meta])*
11        #[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
12        pub struct $name(String);
13
14        impl $name {
15            /// Creates a new string-backed primitive.
16            pub fn new(value: impl Into<String>) -> Self {
17                Self(value.into())
18            }
19
20            /// Returns the stored string value.
21            pub fn as_str(&self) -> &str {
22                &self.0
23            }
24        }
25
26        impl AsRef<str> for $name {
27            fn as_ref(&self) -> &str {
28                self.as_str()
29            }
30        }
31
32        impl From<String> for $name {
33            fn from(value: String) -> Self {
34                Self::new(value)
35            }
36        }
37
38        impl From<&str> for $name {
39            fn from(value: &str) -> Self {
40                Self::new(value)
41            }
42        }
43
44        impl fmt::Display for $name {
45            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46                formatter.write_str(self.as_str())
47            }
48        }
49    };
50}
51
52string_newtype! {
53    /// A cache key.
54    CacheKey
55}
56string_newtype! {
57    /// A cache namespace.
58    CacheNamespace
59}
60string_newtype! {
61    /// A cache value payload.
62    CacheValue
63}
64
65impl CacheKey {
66    /// Creates a cache key builder.
67    pub fn builder() -> CacheKeyBuilder {
68        CacheKeyBuilder::new()
69    }
70}
71
72/// Builder for composed cache keys.
73#[derive(Clone, Debug, Default, Eq, PartialEq)]
74pub struct CacheKeyBuilder {
75    namespace: Option<CacheNamespace>,
76    segments: Vec<String>,
77}
78
79impl CacheKeyBuilder {
80    /// Creates an empty cache key builder.
81    pub const fn new() -> Self {
82        Self {
83            namespace: None,
84            segments: Vec::new(),
85        }
86    }
87
88    /// Sets the namespace prefix.
89    pub fn namespace(mut self, namespace: CacheNamespace) -> Self {
90        self.namespace = Some(namespace);
91        self
92    }
93
94    /// Adds a non-empty segment.
95    pub fn segment(mut self, segment: impl AsRef<str>) -> Self {
96        let value = segment.as_ref();
97        if !value.is_empty() {
98            self.segments.push(value.to_owned());
99        }
100        self
101    }
102
103    /// Builds the cache key using `:` separators.
104    pub fn build(self) -> CacheKey {
105        let mut parts = Vec::new();
106        if let Some(namespace) = self.namespace {
107            parts.push(namespace.to_string());
108        }
109        parts.extend(self.segments);
110        CacheKey::new(parts.join(":"))
111    }
112}
113
114/// Error returned when a TTL value is invalid.
115#[derive(Clone, Copy, Debug, Eq, PartialEq)]
116pub enum InvalidTtlError {
117    /// A TTL must be greater than zero.
118    ZeroDuration,
119}
120
121impl fmt::Display for InvalidTtlError {
122    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
123        match self {
124            Self::ZeroDuration => formatter.write_str("ttl duration must be greater than zero"),
125        }
126    }
127}
128
129impl Error for InvalidTtlError {}
130
131/// A positive time-to-live duration.
132#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
133pub struct Ttl(Duration);
134
135impl Ttl {
136    /// Creates a positive TTL.
137    pub fn new(duration: Duration) -> Result<Self, InvalidTtlError> {
138        if duration.is_zero() {
139            Err(InvalidTtlError::ZeroDuration)
140        } else {
141            Ok(Self(duration))
142        }
143    }
144
145    /// Creates a TTL from seconds.
146    pub fn seconds(seconds: u64) -> Result<Self, InvalidTtlError> {
147        Self::new(Duration::from_secs(seconds))
148    }
149
150    /// Returns the stored duration.
151    pub const fn duration(self) -> Duration {
152        self.0
153    }
154}
155
156/// Cache expiration modeling.
157#[derive(Clone, Copy, Debug, Eq, PartialEq)]
158pub enum Expiration {
159    Never,
160    At(SystemTime),
161    After(Ttl),
162}
163
164/// Common cache eviction labels.
165#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
166pub enum EvictionPolicy {
167    Lru,
168    Lfu,
169    Fifo,
170    Ttl,
171    Manual,
172    #[default]
173    Unknown,
174}
175
176impl EvictionPolicy {
177    /// Returns a stable lowercase label.
178    pub const fn as_str(self) -> &'static str {
179        match self {
180            Self::Lru => "lru",
181            Self::Lfu => "lfu",
182            Self::Fifo => "fifo",
183            Self::Ttl => "ttl",
184            Self::Manual => "manual",
185            Self::Unknown => "unknown",
186        }
187    }
188}
189
190impl fmt::Display for EvictionPolicy {
191    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
192        formatter.write_str(self.as_str())
193    }
194}
195
196/// Cache entry status labels.
197#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
198pub enum CacheStatus {
199    Hit,
200    Miss,
201    Stale,
202    Expired,
203    #[default]
204    Unknown,
205}
206
207impl CacheStatus {
208    /// Returns a stable lowercase label.
209    pub const fn as_str(self) -> &'static str {
210        match self {
211            Self::Hit => "hit",
212            Self::Miss => "miss",
213            Self::Stale => "stale",
214            Self::Expired => "expired",
215            Self::Unknown => "unknown",
216        }
217    }
218}
219
220impl fmt::Display for CacheStatus {
221    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
222        formatter.write_str(self.as_str())
223    }
224}
225
226/// A modeled cache entry.
227#[derive(Clone, Debug, Eq, PartialEq)]
228pub struct CacheEntry {
229    key: CacheKey,
230    value: CacheValue,
231    ttl: Option<Ttl>,
232    status: CacheStatus,
233}
234
235impl CacheEntry {
236    /// Creates a cache entry.
237    pub const fn new(key: CacheKey, value: CacheValue) -> Self {
238        Self {
239            key,
240            value,
241            ttl: None,
242            status: CacheStatus::Unknown,
243        }
244    }
245
246    /// Sets the TTL.
247    pub const fn with_ttl(mut self, ttl: Ttl) -> Self {
248        self.ttl = Some(ttl);
249        self
250    }
251
252    /// Sets the cache status.
253    pub const fn with_status(mut self, status: CacheStatus) -> Self {
254        self.status = status;
255        self
256    }
257
258    /// Returns the key.
259    pub const fn key(&self) -> &CacheKey {
260        &self.key
261    }
262
263    /// Returns the value.
264    pub const fn value(&self) -> &CacheValue {
265        &self.value
266    }
267
268    /// Returns the TTL.
269    pub const fn ttl(&self) -> Option<Ttl> {
270        self.ttl
271    }
272
273    /// Returns the status.
274    pub const fn status(&self) -> CacheStatus {
275        self.status
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::{
282        CacheEntry, CacheKey, CacheNamespace, CacheStatus, CacheValue, EvictionPolicy,
283        InvalidTtlError, Ttl,
284    };
285
286    #[test]
287    fn composes_cache_keys() {
288        let key = CacheKey::builder()
289            .namespace(CacheNamespace::new("reviews"))
290            .segment("location")
291            .segment("fort-wayne")
292            .segment("summary")
293            .build();
294
295        assert_eq!(key.to_string(), "reviews:location:fort-wayne:summary");
296    }
297
298    #[test]
299    fn validates_ttl_and_builds_entries() -> Result<(), InvalidTtlError> {
300        let ttl = Ttl::seconds(60)?;
301        let entry = CacheEntry::new(CacheKey::new("reviews:summary"), CacheValue::new("cached"))
302            .with_ttl(ttl)
303            .with_status(CacheStatus::Hit);
304
305        assert_eq!(entry.ttl(), Some(ttl));
306        assert_eq!(entry.status(), CacheStatus::Hit);
307        assert_eq!(Ttl::seconds(0), Err(InvalidTtlError::ZeroDuration));
308        assert_eq!(EvictionPolicy::Lru.to_string(), "lru");
309
310        Ok(())
311    }
312}