1use std::fs;
15use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18use sha2::{Digest, Sha256};
19
20use crate::error::RssError;
21
22type Result<T> = std::result::Result<T, RssError>;
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct CacheMeta {
27 pub feed_url: String,
28 pub etag: Option<String>,
29 pub last_modified: Option<String>,
30 pub fetched_at: String,
32 pub content_type: Option<String>,
33}
34
35#[derive(Debug, Clone)]
37pub struct CacheEntry {
38 pub meta: CacheMeta,
39 pub body: Vec<u8>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct CacheListItem {
45 pub feed_url: String,
46 pub fetched_at: String,
47 pub size_bytes: u64,
48 pub etag: Option<String>,
49}
50
51#[derive(Debug, Clone)]
53pub struct Cache {
54 root: PathBuf,
55}
56
57impl Cache {
58 pub fn open(dir: Option<PathBuf>) -> Result<Self> {
61 let root = match dir {
62 Some(d) => d,
63 None => directories::ProjectDirs::from("io", "rss-cli", "rss-cli")
64 .map(|p| p.cache_dir().to_path_buf())
65 .ok_or_else(|| RssError::Cache("could not determine OS cache dir".into()))?,
66 };
67 fs::create_dir_all(&root)?;
68 Ok(Self { root })
69 }
70
71 pub fn dir(&self) -> &Path {
73 &self.root
74 }
75
76 fn key(feed_url: &str) -> String {
77 let mut h = Sha256::new();
78 h.update(feed_url.as_bytes());
79 h.finalize().iter().map(|b| format!("{b:02x}")).collect()
80 }
81
82 fn meta_path(&self, feed_url: &str) -> PathBuf {
83 self.root.join(format!("{}.json", Self::key(feed_url)))
84 }
85
86 fn body_path(&self, feed_url: &str) -> PathBuf {
87 self.root.join(format!("{}.body", Self::key(feed_url)))
88 }
89
90 pub fn get(&self, feed_url: &str) -> Result<Option<CacheEntry>> {
92 let meta_path = self.meta_path(feed_url);
93 let body_path = self.body_path(feed_url);
94 if !meta_path.exists() || !body_path.exists() {
95 return Ok(None);
96 }
97 let meta_bytes = fs::read(&meta_path)?;
98 let meta: CacheMeta = serde_json::from_slice(&meta_bytes)
99 .map_err(|e| RssError::Cache(format!("corrupt cache metadata: {e}")))?;
100 let body = fs::read(&body_path)?;
101 Ok(Some(CacheEntry { meta, body }))
102 }
103
104 pub fn put(&self, meta: &CacheMeta, body: &[u8]) -> Result<()> {
106 atomic_write(&self.body_path(&meta.feed_url), body)?;
107 let meta_bytes = serde_json::to_vec_pretty(meta)
108 .map_err(|e| RssError::Cache(format!("serialize cache metadata: {e}")))?;
109 atomic_write(&self.meta_path(&meta.feed_url), &meta_bytes)?;
110 Ok(())
111 }
112
113 pub fn clear(&self) -> Result<usize> {
115 let mut removed = 0;
116 for entry in fs::read_dir(&self.root)? {
117 let path = entry?.path();
118 if path.extension().is_some_and(|e| e == "json") {
119 removed += 1;
120 }
121 if path.is_file() {
122 fs::remove_file(&path)?;
123 }
124 }
125 Ok(removed)
126 }
127
128 pub fn list(&self) -> Result<Vec<CacheListItem>> {
130 let mut out = Vec::new();
131 for entry in fs::read_dir(&self.root)? {
132 let path = entry?.path();
133 if path.extension().is_none_or(|e| e != "json") {
134 continue;
135 }
136 let meta_bytes = match fs::read(&path) {
137 Ok(b) => b,
138 Err(_) => continue,
139 };
140 let Ok(meta) = serde_json::from_slice::<CacheMeta>(&meta_bytes) else {
141 continue;
142 };
143 let size = path
144 .with_extension("body")
145 .metadata()
146 .map(|m| m.len())
147 .unwrap_or(0);
148 out.push(CacheListItem {
149 feed_url: meta.feed_url,
150 fetched_at: meta.fetched_at,
151 size_bytes: size,
152 etag: meta.etag,
153 });
154 }
155 Ok(out)
156 }
157}
158
159fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
161 let dir = path.parent().unwrap_or_else(|| Path::new("."));
162 let tmp = path.with_extension(format!("tmp.{}", std::process::id() as u64 ^ rand_suffix()));
163 fs::write(&tmp, bytes)?;
164 match fs::rename(&tmp, path) {
166 Ok(()) => Ok(()),
167 Err(e) => {
168 let _ = fs::remove_file(&tmp);
169 let _ = dir; Err(RssError::Io(e))
171 }
172 }
173}
174
175fn rand_suffix() -> u64 {
177 use std::time::{SystemTime, UNIX_EPOCH};
178 SystemTime::now()
179 .duration_since(UNIX_EPOCH)
180 .map(|d| d.as_nanos() as u64)
181 .unwrap_or(0)
182}