Skip to main content

crossref_lib/
cache.rs

1use std::path::PathBuf;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::config::Config;
7use crate::error::{CrossrefError, Result};
8
9/// A single cached entry wrapping a serializable value with an expiry timestamp.
10#[derive(Debug, Serialize, Deserialize)]
11struct CacheEntry<T> {
12    value: T,
13    cached_at: DateTime<Utc>,
14}
15
16/// Disk-backed JSON cache for API responses.
17pub struct DiskCache {
18    dir: PathBuf,
19    ttl_days: u32,
20}
21
22impl DiskCache {
23    /// Construct a `DiskCache` from the resolved [`Config`].
24    pub fn from_config(config: &Config) -> Result<Self> {
25        let dir = if let Some(ref custom) = config.cache_dir {
26            PathBuf::from(custom)
27        } else {
28            dirs::cache_dir()
29                .ok_or_else(|| CrossrefError::Cache("cannot determine cache directory".to_string()))?
30                .join("crossref-rs")
31        };
32        std::fs::create_dir_all(&dir)?;
33        Ok(Self { dir, ttl_days: config.cache_ttl_days })
34    }
35
36    /// Sanitise a cache key into a safe filesystem filename.
37    fn key_to_path(&self, key: &str) -> PathBuf {
38        let safe: String = key
39            .chars()
40            .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
41            .collect();
42        self.dir.join(format!("{safe}.json"))
43    }
44
45    /// Retrieve a cached value for `key` if it exists and has not expired.
46    pub fn get<T: for<'de> Deserialize<'de>>(&self, key: &str) -> Result<Option<T>> {
47        if self.ttl_days == 0 {
48            return Ok(None);
49        }
50        let path = self.key_to_path(key);
51        if !path.exists() {
52            return Ok(None);
53        }
54        let raw = std::fs::read_to_string(&path)?;
55        let entry: CacheEntry<T> = serde_json::from_str(&raw)?;
56
57        let age_days = Utc::now()
58            .signed_duration_since(entry.cached_at)
59            .num_days();
60        if age_days > self.ttl_days as i64 {
61            let _ = std::fs::remove_file(&path);
62            return Ok(None);
63        }
64        Ok(Some(entry.value))
65    }
66
67    /// Store `value` in the cache under `key`.
68    pub fn set<T: Serialize>(&self, key: &str, value: &T) -> Result<()> {
69        if self.ttl_days == 0 {
70            return Ok(());
71        }
72        let entry = CacheEntry { value, cached_at: Utc::now() };
73        let path = self.key_to_path(key);
74        let raw = serde_json::to_string(&entry)?;
75        std::fs::write(path, raw)?;
76        Ok(())
77    }
78
79    /// Remove all expired cache entries.
80    pub fn clear_expired(&self) -> Result<()> {
81        for entry in walkdir::WalkDir::new(&self.dir)
82            .min_depth(1)
83            .max_depth(1)
84            .into_iter()
85            .filter_map(|e| e.ok())
86            .filter(|e| e.file_type().is_file())
87        {
88            // Re-use get() logic: deserialise into raw JSON Value, check timestamp
89            let path = entry.path();
90            if let Ok(raw) = std::fs::read_to_string(path) {
91                if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
92                    if let Some(cached_at_str) = v.get("cached_at").and_then(|v| v.as_str()) {
93                        if let Ok(cached_at) = cached_at_str.parse::<DateTime<Utc>>() {
94                            let age_days = Utc::now()
95                                .signed_duration_since(cached_at)
96                                .num_days();
97                            if age_days > self.ttl_days as i64 {
98                                let _ = std::fs::remove_file(path);
99                            }
100                        }
101                    }
102                }
103            }
104        }
105        Ok(())
106    }
107
108    /// Delete every file in the cache directory.
109    pub fn clear_all(&self) -> Result<()> {
110        for entry in walkdir::WalkDir::new(&self.dir)
111            .min_depth(1)
112            .max_depth(1)
113            .into_iter()
114            .filter_map(|e| e.ok())
115            .filter(|e| e.file_type().is_file())
116        {
117            std::fs::remove_file(entry.path())?;
118        }
119        Ok(())
120    }
121}