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 trace!(
87 "Writing {} bytes to Ribbit cache: {}/{}/{}",
88 data.len(),
89 region,
90 product,
91 endpoint
92 );
93 tokio::fs::write(&path, data).await?;
94
95 let timestamp = SystemTime::now()
97 .duration_since(UNIX_EPOCH)
98 .unwrap()
99 .as_secs();
100 tokio::fs::write(&meta_path, timestamp.to_string()).await?;
101
102 Ok(())
103 }
104
105 pub async fn read(&self, region: &str, product: &str, endpoint: &str) -> Result<Vec<u8>> {
107 if !self.is_valid(region, product, endpoint).await {
108 return Err(Error::CacheEntryNotFound(format!(
109 "{region}/{product}/{endpoint}"
110 )));
111 }
112
113 let path = self.cache_path(region, product, endpoint);
114 trace!(
115 "Reading from Ribbit cache: {}/{}/{}",
116 region, product, endpoint
117 );
118 Ok(tokio::fs::read(&path).await?)
119 }
120
121 pub async fn clear_expired(&self) -> Result<()> {
123 debug!("Clearing expired entries from Ribbit cache");
124 self.clear_expired_in_dir(&self.base_dir).await
125 }
126
127 fn clear_expired_in_dir<'a>(
129 &'a self,
130 dir: &'a Path,
131 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
132 Box::pin(async move {
133 let mut entries = tokio::fs::read_dir(dir).await?;
134
135 while let Some(entry) = entries.next_entry().await? {
136 let path = entry.path();
137
138 if path.is_dir() {
139 self.clear_expired_in_dir(&path).await?;
140 } else if path.extension().and_then(|s| s.to_str()) == Some("meta") {
141 if let Ok(metadata) = tokio::fs::read_to_string(&path).await {
143 if let Ok(timestamp) = metadata.trim().parse::<u64>() {
144 let now = SystemTime::now()
145 .duration_since(UNIX_EPOCH)
146 .unwrap()
147 .as_secs();
148
149 if (now - timestamp) >= self.default_ttl.as_secs() {
150 let data_path = path.with_extension("");
152 let _ = tokio::fs::remove_file(&data_path).await;
153 let _ = tokio::fs::remove_file(&path).await;
154 trace!("Removed expired cache entry: {:?}", data_path);
155 }
156 }
157 }
158 }
159 }
160
161 Ok(())
162 })
163 }
164
165 pub fn base_dir(&self) -> &PathBuf {
167 &self.base_dir
168 }
169
170 pub fn ttl(&self) -> Duration {
172 self.default_ttl
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[tokio::test]
181 async fn test_ribbit_cache_operations() {
182 let cache = RibbitCache::with_ttl(Duration::from_secs(60))
183 .await
184 .unwrap();
185
186 let region = "us";
187 let product = "wow";
188 let endpoint = "versions";
189 let data = b"test ribbit response";
190
191 cache.write(region, product, endpoint, data).await.unwrap();
193 assert!(cache.is_valid(region, product, endpoint).await);
194
195 let read_data = cache.read(region, product, endpoint).await.unwrap();
197 assert_eq!(read_data, data);
198
199 let _ = tokio::fs::remove_file(cache.cache_path(region, product, endpoint)).await;
201 let _ = tokio::fs::remove_file(cache.metadata_path(region, product, endpoint)).await;
202 }
203
204 #[tokio::test]
205 async fn test_ribbit_cache_expiry() {
206 let cache = RibbitCache::with_ttl(Duration::from_secs(0)).await.unwrap();
208
209 let region = "eu";
210 let product = "wow";
211 let endpoint = "cdns";
212 let data = b"test data";
213
214 cache.write(region, product, endpoint, data).await.unwrap();
215
216 tokio::time::sleep(Duration::from_millis(10)).await;
218 assert!(!cache.is_valid(region, product, endpoint).await);
219
220 assert!(cache.read(region, product, endpoint).await.is_err());
222
223 let _ = tokio::fs::remove_file(cache.cache_path(region, product, endpoint)).await;
225 let _ = tokio::fs::remove_file(cache.metadata_path(region, product, endpoint)).await;
226 }
227}