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}