1use std::time::Duration;
2use std::{collections::HashMap, fs, path::PathBuf};
3
4use crate::tracker::TimeWindow;
5use crate::utils::UtcTime;
6use crate::{Result, utils};
7use async_trait::async_trait;
8use chrono::{TimeDelta, Utc};
9use serde::{Deserialize, Serialize};
10
11#[async_trait]
12pub trait Cache {
13 fn get_all(&self, url: &str) -> Vec<&CacheEntry>;
15
16 fn get(&self, url: &str) -> Option<&CacheEntry> {
19 self.get_all(url)
20 .into_iter()
21 .max_by(|a, b| a.created.cmp(&b.created))
22 .inspect(|entry| log_hit(url, entry))
23 }
24
25 fn get_younger_than(
27 &self,
28 url: &str,
29 max_age: Duration,
30 ) -> Option<&CacheEntry> {
31 let now = Utc::now();
32 let min_created = now - max_age;
33 self.get_all(url)
34 .into_iter()
35 .filter(|entry| entry.created >= min_created)
36 .max_by(|a, b| a.created.cmp(&b.created))
37 .inspect(|entry| log_hit(url, entry))
38 }
39
40 fn insert(&mut self, url: String, text: String);
43
44 async fn save(&self) -> Result<()>;
47}
48#[derive(Serialize, Deserialize, Clone, Debug)]
49pub struct CacheEntry {
50 pub text: String,
51 pub created: UtcTime,
52}
53impl CacheEntry {
54 pub fn age(&self) -> TimeDelta {
55 Utc::now() - self.created
56 }
57}
58
59fn log_hit(url: &str, entry: &CacheEntry) {
60 log::debug!(
61 "Reusing {}s old cache entry for {url}",
62 entry.age().num_seconds()
63 )
64}
65#[derive(Default)]
66pub struct JsonCache {
67 contents: HashMap<String, Vec<CacheEntry>>,
68 pub max_entries: Option<usize>,
70 pub modified: bool,
72}
73impl JsonCache {
74 pub fn new() -> Result<Self> {
75 Ok(Self {
76 contents: Self::load_contents()?,
77 ..Default::default()
78 })
79 }
80 pub fn with_max_entries(max_entries: usize) -> Result<Self> {
81 Ok(Self {
82 contents: Self::load_contents()?,
83 max_entries: Some(max_entries),
84 ..Default::default()
85 })
86 }
87 fn load_contents() -> Result<HashMap<String, Vec<CacheEntry>>> {
89 #[cfg(test)]
90 return Ok(HashMap::new()); let cache_file = Self::get_file()?;
93 let contents = utils::load_json(&cache_file)?;
94 log::info!("Loaded JSON cache from {cache_file:?}");
95 Ok(contents)
96 }
97 fn get_file() -> Result<PathBuf> {
98 Ok(get_cache_dir()?.join("packtrack-cache.json"))
99 }
100}
101#[async_trait]
102impl Cache for JsonCache {
103 fn get_all(&self, url: &str) -> Vec<&CacheEntry> {
104 self.contents
105 .get(url)
106 .map(|v| v.iter().collect())
107 .unwrap_or(vec![])
108 }
109 fn insert(&mut self, url: String, text: String) {
110 let entry = CacheEntry {
111 created: Utc::now(),
112 text: text,
113 };
114 self.contents
115 .entry(url.clone())
116 .and_modify(|e| {
117 e.push(entry.clone());
118 if self
120 .max_entries
121 .map(|max| e.len() > max)
122 .unwrap_or(false)
123 {
124 e.remove(0);
125 }
126 })
127 .or_insert(vec![entry]);
128 log::info!("Inserted new cache entry for {url}");
129 self.modified = true;
130 }
131 async fn save(&self) -> Result<()> {
133 #[cfg(test)]
134 return Ok(()); let cache_file = Self::get_file()?;
137 utils::save_json(&cache_file, &self.contents)?;
138 log::info!("Saved JSON cache to {cache_file:?}");
139 Ok(())
140 }
141}
142
143fn get_cache_dir() -> Result<PathBuf> {
144 let dirs = utils::project_dirs()?;
145 let cache_dir = dirs.cache_dir();
146 Ok(cache_dir.to_owned())
147}
148
149#[cfg(test)]
150mod tests {
151
152 use std::fmt::format;
153
154 use super::*;
155
156 #[test]
157 fn test_insert_with_max_values() -> Result<()> {
158 let mut cache = JsonCache::with_max_entries(2)?;
159 assert_eq!(cache.max_entries, Some(2));
160 cache.insert("url".into(), "0".into());
161 cache.insert("url".into(), "1".into());
162 cache.insert("url".into(), "2".into());
163 cache.insert("url".into(), "3".into());
164 let hits = cache.contents.get("url").unwrap();
165 assert_eq!(hits.len(), 2);
166 let entries: Vec<&str> = cache
167 .contents
168 .get("url")
169 .unwrap()
170 .iter()
171 .map(|e| e.text.as_str())
172 .collect();
173 assert_eq!(entries, vec!["2", "3"]);
175 Ok(())
176 }
177 #[test]
178 fn test_insert_with_no_max_values() {
179 let mut cache = JsonCache::default();
180 assert_eq!(cache.max_entries, None);
181 cache.insert("url".into(), "0".into());
182 cache.insert("url".into(), "1".into());
183 cache.insert("url".into(), "2".into());
184 cache.insert("url".into(), "3".into());
185 let hits = cache.contents.get("url").unwrap();
186 assert_eq!(hits.len(), 4);
187 let entries: Vec<&str> = cache
188 .contents
189 .get("url")
190 .unwrap()
191 .iter()
192 .map(|e| e.text.as_str())
193 .collect();
194 assert_eq!(entries, vec!["0", "1", "2", "3"]);
196 }
197
198 #[test]
199 fn test_get() {
200 let now = Utc::now();
201 let mut cache = JsonCache::default();
202
203 assert!(cache.get("url").is_none());
204
205 cache.insert("url".into(), "text".into());
206 assert!(
207 cache
208 .get("url")
209 .unwrap()
210 .text
211 .eq("text")
212 );
213
214 cache.insert("url".into(), "text2".into());
215 cache.insert("url".into(), "text3".into());
216 assert!(
217 cache
218 .get("url")
219 .unwrap()
220 .text
221 .eq("text3")
222 );
223 }
224 #[test]
225 fn test_get_younger_than() {
226 let now = Utc::now();
227 let contents = HashMap::from([(
228 "url".into(),
229 [20, 5, 10]
230 .iter()
231 .map(|delta| CacheEntry {
232 created: now - Duration::from_secs(*delta),
233 text: format!("{delta}s ago"),
234 })
235 .collect(),
236 )]);
237 let mut cache = JsonCache {
238 contents: contents.clone(),
239 ..Default::default()
240 };
241
242 assert!(
244 cache
245 .get_younger_than("url", Duration::from_secs(10))
246 .unwrap()
247 .text
248 .eq("5s ago")
249 );
250
251 assert!(
253 cache
254 .get("url")
255 .unwrap()
256 .text
257 .eq("5s ago")
258 );
259
260 assert!(
262 cache
263 .get_younger_than("url", Duration::from_secs(3))
264 .is_none()
265 );
266 }
267
268 #[test]
269 fn test_is_modified() {
270 let mut cache = JsonCache::default();
271 assert_eq!(cache.modified, false);
272 cache.insert("url".into(), "foo".into());
273 assert_eq!(cache.modified, true);
274 }
275}