Skip to main content

arch_toolkit/news/
cache.rs

1//! Generic cache boundary for news feed payloads.
2
3use std::collections::BTreeMap;
4use std::sync::Mutex;
5
6use crate::error::{ArchToolkitError, Result};
7
8/// What: Define caller-owned storage for raw news and advisory feed payloads.
9///
10/// Inputs:
11/// - `key`: Stable feed key supplied by the news fetch helper.
12/// - `value`: Raw successful feed payload to store.
13///
14/// Output:
15/// - Cached payloads on hits and explicit errors from a caller's storage backend.
16///
17/// Details:
18/// - The boundary is intentionally independent from the AUR cache and makes no
19///   assumptions about persistence, expiration, encryption, or eviction.
20/// - Callers own freshness policy; a cache implementation can decline a stale
21///   entry by returning `Ok(None)`.
22pub trait FeedCache: Send + Sync {
23    /// What: Look up a raw feed payload by a stable key.
24    ///
25    /// Inputs:
26    /// - `key`: Namespaced key generated from the feed kind and URL.
27    ///
28    /// Output:
29    /// - `Ok(Some(payload))` for a cache hit, `Ok(None)` for a miss, or an
30    ///   error when the backing store cannot be read.
31    ///
32    /// Details:
33    /// - Implementations should enforce their own expiration policy before
34    ///   returning a payload.
35    ///
36    /// # Errors
37    ///
38    /// Returns a backing-store error when the requested payload cannot be read.
39    fn get(&self, key: &str) -> Result<Option<String>>;
40
41    /// What: Store a raw feed payload under a stable key.
42    ///
43    /// Inputs:
44    /// - `key`: Namespaced key generated from the feed kind and URL.
45    /// - `value`: Successful bounded response body to store.
46    ///
47    /// Output:
48    /// - `Ok(())` when the value is stored, otherwise a backing-store error.
49    ///
50    /// Details:
51    /// - Feed fetches surface write errors explicitly so callers do not mistake
52    ///   an unavailable requested cache for a successful durable cache write.
53    ///
54    /// # Errors
55    ///
56    /// Returns a backing-store error when the payload cannot be stored.
57    fn put(&self, key: &str, value: &str) -> Result<()>;
58}
59
60/// What: Provide a small deterministic in-memory implementation of [`FeedCache`].
61///
62/// Inputs:
63/// - Constructed with [`InMemoryFeedCache::new`] and a maximum entry count.
64///
65/// Output:
66/// - A thread-safe cache suitable for short-lived applications and tests.
67///
68/// Details:
69/// - This implementation has no time-to-live policy; use a caller-provided
70///   cache when persistence or expiration is required.
71/// - On capacity pressure it removes the lexically first key, keeping eviction
72///   deterministic without adding an AUR-coupled cache dependency.
73#[derive(Debug)]
74pub struct InMemoryFeedCache {
75    /// Maximum number of payloads retained by this cache.
76    capacity: usize,
77    /// Payloads ordered by key for deterministic eviction.
78    entries: Mutex<BTreeMap<String, String>>,
79}
80
81impl InMemoryFeedCache {
82    /// What: Create an empty in-memory feed cache with an explicit capacity.
83    ///
84    /// Inputs:
85    /// - `capacity`: Maximum number of feed payloads that may be retained.
86    ///
87    /// Output:
88    /// - A ready-to-use cache, or `InvalidInput` when capacity is zero.
89    ///
90    /// Details:
91    /// - The explicit non-zero bound prevents unbounded cache construction and
92    ///   keeps memory ownership visible to callers.
93    ///
94    /// # Errors
95    ///
96    /// Returns `ArchToolkitError::InvalidInput` when `capacity` is zero.
97    pub fn new(capacity: usize) -> Result<Self> {
98        if capacity == 0 {
99            return Err(ArchToolkitError::InvalidInput(
100                "feed cache capacity must be greater than zero".to_string(),
101            ));
102        }
103        Ok(Self {
104            capacity,
105            entries: Mutex::new(BTreeMap::new()),
106        })
107    }
108}
109
110impl FeedCache for InMemoryFeedCache {
111    /// What: Return a cloned payload for the requested feed key.
112    ///
113    /// Inputs:
114    /// - `key`: Feed cache key to look up.
115    ///
116    /// Output:
117    /// - The stored payload when present, otherwise `None`.
118    ///
119    /// Details:
120    /// - Recovers the in-memory cache after a poisoned mutex because payloads
121    ///   are independent values and the map remains usable.
122    fn get(&self, key: &str) -> Result<Option<String>> {
123        let entries = self
124            .entries
125            .lock()
126            .unwrap_or_else(std::sync::PoisonError::into_inner);
127        Ok(entries.get(key).cloned())
128    }
129
130    /// What: Store a payload and evict deterministically when full.
131    ///
132    /// Inputs:
133    /// - `key`: Feed cache key to write.
134    /// - `value`: Raw successful feed payload.
135    ///
136    /// Output:
137    /// - `Ok(())` after the in-memory map is updated.
138    ///
139    /// Details:
140    /// - Updating an existing key does not evict another entry.
141    /// - A new key evicts the lexically first existing key only at capacity.
142    fn put(&self, key: &str, value: &str) -> Result<()> {
143        let mut entries = self
144            .entries
145            .lock()
146            .unwrap_or_else(std::sync::PoisonError::into_inner);
147        if !entries.contains_key(key) && entries.len() == self.capacity {
148            let _ = entries.pop_first();
149        }
150        entries.insert(key.to_string(), value.to_string());
151        drop(entries);
152        Ok(())
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::{FeedCache, InMemoryFeedCache};
159
160    #[test]
161    /// What: Verify bounded cache storage and deterministic eviction.
162    ///
163    /// Inputs:
164    /// - A capacity-one cache and two lexically ordered feed keys.
165    ///
166    /// Output:
167    /// - The first key is evicted and the newer key remains accessible.
168    ///
169    /// Details:
170    /// - Demonstrates that this generic cache has no dependency on AUR cache
171    ///   configuration or types.
172    fn cache_evicts_deterministically() {
173        let cache = InMemoryFeedCache::new(1).expect("valid cache capacity");
174        cache.put("arch-news:a", "first").expect("store first");
175        cache.put("arch-news:b", "second").expect("store second");
176
177        assert_eq!(cache.get("arch-news:a").expect("read first"), None);
178        assert_eq!(
179            cache.get("arch-news:b").expect("read second"),
180            Some("second".to_string())
181        );
182    }
183
184    #[test]
185    /// What: Verify zero-capacity caches are rejected explicitly.
186    ///
187    /// Inputs:
188    /// - A zero entry capacity.
189    ///
190    /// Output:
191    /// - An invalid-input error rather than an unbounded or unusable cache.
192    ///
193    /// Details:
194    /// - Keeps the cache capacity bound enforceable at construction time.
195    fn cache_rejects_zero_capacity() {
196        assert!(InMemoryFeedCache::new(0).is_err());
197    }
198}