1use std::path::{Path, PathBuf};
4use std::time::{Duration, SystemTime, UNIX_EPOCH};
5use tracing::{debug, trace};
6
7use crate::{Error, Result, ensure_dir, get_cache_dir};
8
9pub struct RibbitCache {
11 base_dir: PathBuf,
13 default_ttl: Duration,
15}
16
17impl RibbitCache {
18 pub async fn new() -> Result<Self> {
20 Self::with_ttl(Duration::from_secs(300)).await
21 }
22
23 pub async fn with_ttl(ttl: Duration) -> Result<Self> {
25 let base_dir = get_cache_dir()?.join("ribbit");
26 ensure_dir(&base_dir).await?;
27
28 debug!(
29 "Initialized Ribbit cache at: {:?} with TTL: {:?}",
30 base_dir, ttl
31 );
32
33 Ok(Self {
34 base_dir,
35 default_ttl: ttl,
36 })
37 }
38
39 pub fn cache_path(&self, region: &str, product: &str, endpoint: &str) -> PathBuf {
41 self.base_dir.join(region).join(product).join(endpoint)
42 }
43
44 pub fn metadata_path(&self, region: &str, product: &str, endpoint: &str) -> PathBuf {
46 let mut path = self.cache_path(region, product, endpoint);
47 path.set_extension("meta");
48 path
49 }
50
51 pub async fn is_valid(&self, region: &str, product: &str, endpoint: &str) -> bool {
53 let meta_path = self.metadata_path(region, product, endpoint);
54
55 if let Ok(metadata) = tokio::fs::read_to_string(&meta_path).await {
56 if let Ok(timestamp) = metadata.trim().parse::<u64>() {
57 let now = SystemTime::now()
58 .duration_since(UNIX_EPOCH)
59 .unwrap()
60 .as_secs();
61
62 return (now - timestamp) < self.default_ttl.as_secs();
63 }
64 }
65
66 false
67 }
68
69 pub async fn write(
71 &self,
72 region: &str,
73 product: &str,
74 endpoint: &str,
75 data: &[u8],
76 ) -> Result<()> {
77 let path = self.cache_path(region, product, endpoint);
78 let meta_path = self.metadata_path(region, product, endpoint);
79
80 if let Some(parent) = path.parent() {
82 ensure_dir(parent).await?;
83 }
84
85 let temp_path = path.with_file_name(format!(
87 "{}.tmp",
88 path.file_name().unwrap().to_string_lossy()
89 ));
90 let temp_meta_path = meta_path.with_file_name(format!(
91 "{}.tmp",
92 meta_path.file_name().unwrap().to_string_lossy()
93 ));
94
95 let timestamp = SystemTime::now()
97 .duration_since(UNIX_EPOCH)
98 .unwrap()
99 .as_secs();
100
101 trace!(
103 "Writing {} bytes to Ribbit cache: {}/{}/{}",
104 data.len(),
105 region,
106 product,
107 endpoint
108 );
109
110 let write_result = async {
112 tokio::fs::write(&temp_path, data).await?;
113 tokio::fs::write(&temp_meta_path, timestamp.to_string()).await?;
114
115 tokio::fs::rename(&temp_path, &path).await?;
118 tokio::fs::rename(&temp_meta_path, &meta_path).await?;
119
120 Ok::<(), std::io::Error>(())
121 }
122 .await;
123
124 if write_result.is_err() {
126 let _ = tokio::fs::remove_file(&temp_path).await;
127 let _ = tokio::fs::remove_file(&temp_meta_path).await;
128 }
129
130 write_result?;
131
132 Ok(())
133 }
134
135 pub async fn read(&self, region: &str, product: &str, endpoint: &str) -> Result<Vec<u8>> {
137 if !self.is_valid(region, product, endpoint).await {
138 return Err(Error::CacheEntryNotFound(format!(
139 "{region}/{product}/{endpoint}"
140 )));
141 }
142
143 let path = self.cache_path(region, product, endpoint);
144 trace!(
145 "Reading from Ribbit cache: {}/{}/{}",
146 region, product, endpoint
147 );
148 Ok(tokio::fs::read(&path).await?)
149 }
150
151 pub async fn clear_expired(&self) -> Result<()> {
153 debug!("Clearing expired entries from Ribbit cache");
154 self.clear_expired_in_dir(&self.base_dir).await
155 }
156
157 fn clear_expired_in_dir<'a>(
159 &'a self,
160 dir: &'a Path,
161 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
162 Box::pin(async move {
163 let mut entries = tokio::fs::read_dir(dir).await?;
164
165 while let Some(entry) = entries.next_entry().await? {
166 let path = entry.path();
167
168 if path.is_dir() {
169 self.clear_expired_in_dir(&path).await?;
170 } else if path.extension().and_then(|s| s.to_str()) == Some("meta") {
171 if let Ok(metadata) = tokio::fs::read_to_string(&path).await {
173 if let Ok(timestamp) = metadata.trim().parse::<u64>() {
174 let now = SystemTime::now()
175 .duration_since(UNIX_EPOCH)
176 .unwrap()
177 .as_secs();
178
179 if (now - timestamp) >= self.default_ttl.as_secs() {
180 let data_path = path.with_extension("");
182 let _ = tokio::fs::remove_file(&data_path).await;
183 let _ = tokio::fs::remove_file(&path).await;
184 trace!("Removed expired cache entry: {:?}", data_path);
185 }
186 }
187 }
188 }
189 }
190
191 Ok(())
192 })
193 }
194
195 pub fn base_dir(&self) -> &PathBuf {
197 &self.base_dir
198 }
199
200 pub fn ttl(&self) -> Duration {
202 self.default_ttl
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[tokio::test]
211 async fn test_ribbit_cache_operations() {
212 let cache = RibbitCache::with_ttl(Duration::from_secs(60))
213 .await
214 .unwrap();
215
216 let region = "us";
217 let product = "wow";
218 let endpoint = "versions";
219 let data = b"test ribbit response";
220
221 cache.write(region, product, endpoint, data).await.unwrap();
223 assert!(cache.is_valid(region, product, endpoint).await);
224
225 let read_data = cache.read(region, product, endpoint).await.unwrap();
227 assert_eq!(read_data, data);
228
229 let _ = tokio::fs::remove_file(cache.cache_path(region, product, endpoint)).await;
231 let _ = tokio::fs::remove_file(cache.metadata_path(region, product, endpoint)).await;
232 }
233
234 #[tokio::test]
235 async fn test_ribbit_cache_expiry() {
236 let cache = RibbitCache::with_ttl(Duration::from_secs(0)).await.unwrap();
238
239 let region = "eu";
240 let product = "wow";
241 let endpoint = "cdns";
242 let data = b"test data";
243
244 cache.write(region, product, endpoint, data).await.unwrap();
245
246 tokio::time::sleep(Duration::from_millis(10)).await;
248 assert!(!cache.is_valid(region, product, endpoint).await);
249
250 assert!(cache.read(region, product, endpoint).await.is_err());
252
253 let _ = tokio::fs::remove_file(cache.cache_path(region, product, endpoint)).await;
255 let _ = tokio::fs::remove_file(cache.metadata_path(region, product, endpoint)).await;
256 }
257}