Skip to main content

packtrack/core/
cache.rs

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    /// Get all the entries for the given url
14    fn get_all(&self, url: &str) -> Vec<&CacheEntry>;
15
16    /// Get the latest cached response.text for the given URL.
17    /// Ignores the age of the entry.
18    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    /// Get the latest cached entry younger than a given age.
26    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    /// Insert a cached response.text for the given URL.
41    /// `mut` because the implementation must store its state in memory.
42    fn insert(&mut self, url: String, text: String);
43
44    /// Save the cache to preserve it between runs
45    /// `Result` so the implementation can do IO.
46    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    /// max entries per url
69    pub max_entries: Option<usize>,
70    /// any entries older than this will not be reused
71    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    // RAII: load from file when instantiating
88    fn load_contents() -> Result<HashMap<String, Vec<CacheEntry>>> {
89        #[cfg(test)]
90        return Ok(HashMap::new()); // don't load from file in tests
91
92        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                // maintain max length
119                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    // Save to file
132    async fn save(&self) -> Result<()> {
133        #[cfg(test)]
134        return Ok(()); // don't write to file in tests
135
136        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        // only the 2 most recent ones should be kept
174        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        // only the 2 most recent ones should be kept
195        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        // we should get the youngest match
243        assert!(
244            cache
245                .get_younger_than("url", Duration::from_secs(10))
246                .unwrap()
247                .text
248                .eq("5s ago")
249        );
250
251        // if no max age, we should get the same result
252        assert!(
253            cache
254                .get("url")
255                .unwrap()
256                .text
257                .eq("5s ago")
258        );
259
260        // if no entries are young enough, we should get None
261        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}