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#[derive(Debug, Serialize, Deserialize)]
11struct CacheEntry<T> {
12 value: T,
13 cached_at: DateTime<Utc>,
14}
15
16pub struct DiskCache {
18 dir: PathBuf,
19 ttl_days: u32,
20}
21
22impl DiskCache {
23 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 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 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 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 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 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 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}