1use rusqlite::{params, Connection};
2use std::path::PathBuf;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5pub const FRESH_SECS: u64 = 3600;
6const PRUNE_SECS: u64 = 7 * 24 * 3600;
7
8pub struct CacheEntry {
9 pub content: String,
10 pub etag: Option<String>,
11 pub last_modified: Option<String>,
12 pub cached_at: u64,
13}
14
15impl CacheEntry {
16 pub fn is_fresh(&self) -> bool {
17 now().saturating_sub(self.cached_at) < FRESH_SECS
18 }
19}
20
21fn now() -> u64 {
22 SystemTime::now()
23 .duration_since(UNIX_EPOCH)
24 .unwrap_or_default()
25 .as_secs()
26}
27
28pub fn cache_path() -> Option<PathBuf> {
29 let base = if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
30 PathBuf::from(xdg)
31 } else {
32 PathBuf::from(std::env::var("HOME").ok()?).join(".cache")
33 };
34 Some(base.join("searchfox-cli").join("cache.db"))
35}
36
37pub fn open() -> Option<Connection> {
38 let path = cache_path()?;
39 std::fs::create_dir_all(path.parent()?).ok()?;
40 let conn = Connection::open(&path).ok()?;
41 init(&conn)?;
42 Some(conn)
43}
44
45pub fn get(conn: &Connection, url: &str) -> Option<CacheEntry> {
46 conn.query_row(
47 "SELECT content, etag, last_modified, cached_at FROM cache WHERE url = ?1",
48 params![url],
49 |row| {
50 Ok(CacheEntry {
51 content: row.get(0)?,
52 etag: row.get(1)?,
53 last_modified: row.get(2)?,
54 cached_at: row.get::<_, i64>(3)? as u64,
55 })
56 },
57 )
58 .ok()
59}
60
61pub fn set(
62 conn: &Connection,
63 url: &str,
64 content: &str,
65 etag: Option<&str>,
66 last_modified: Option<&str>,
67) {
68 let _ = conn.execute(
69 "INSERT OR REPLACE INTO cache (url, content, etag, last_modified, cached_at)
70 VALUES (?1, ?2, ?3, ?4, ?5)",
71 params![url, content, etag, last_modified, now() as i64],
72 );
73}
74
75fn init(conn: &Connection) -> Option<()> {
76 conn.execute_batch(
77 "CREATE TABLE IF NOT EXISTS cache (
78 url TEXT PRIMARY KEY,
79 content TEXT NOT NULL,
80 etag TEXT,
81 last_modified TEXT,
82 cached_at INTEGER NOT NULL
83 );",
84 )
85 .ok()
86}
87
88pub fn open_in_memory() -> anyhow::Result<Connection> {
89 let conn = Connection::open_in_memory()?;
90 init(&conn).ok_or_else(|| anyhow::anyhow!("failed to init in-memory cache"))?;
91 Ok(conn)
92}
93
94#[cfg(test)]
95pub fn set_with_timestamp(
96 conn: &Connection,
97 url: &str,
98 content: &str,
99 etag: Option<&str>,
100 last_modified: Option<&str>,
101 cached_at: i64,
102) {
103 let _ = conn.execute(
104 "INSERT OR REPLACE INTO cache (url, content, etag, last_modified, cached_at)
105 VALUES (?1, ?2, ?3, ?4, ?5)",
106 params![url, content, etag, last_modified, cached_at],
107 );
108}
109
110pub fn prune(conn: &Connection) {
111 let cutoff = (now() - PRUNE_SECS) as i64;
112 let _ = conn.execute("DELETE FROM cache WHERE cached_at < ?1", params![cutoff]);
113}
114
115pub fn clear() -> std::io::Result<bool> {
116 let Some(path) = cache_path() else {
117 return Ok(false);
118 };
119
120 match std::fs::remove_file(path) {
121 Ok(()) => Ok(true),
122 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
123 Err(e) => Err(e),
124 }
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130 use std::path::PathBuf;
131 use std::sync::{Mutex, OnceLock};
132
133 static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
134
135 fn db() -> Connection {
136 open_in_memory().unwrap()
137 }
138
139 fn env_lock() -> std::sync::MutexGuard<'static, ()> {
140 ENV_LOCK
141 .get_or_init(|| Mutex::new(()))
142 .lock()
143 .expect("env lock poisoned")
144 }
145
146 fn temp_cache_home() -> PathBuf {
147 std::env::temp_dir().join(format!(
148 "searchfox-cli-cache-test-{}-{}",
149 std::process::id(),
150 now()
151 ))
152 }
153
154 #[test]
155 fn miss_returns_none() {
156 let conn = db();
157 assert!(get(&conn, "https://example.com/file").is_none());
158 }
159
160 #[test]
161 fn set_get_roundtrip() {
162 let conn = db();
163 set(
164 &conn,
165 "https://example.com/file",
166 "hello",
167 Some("\"abc\""),
168 Some("Thu, 01 Jan 2026 00:00:00 GMT"),
169 );
170 let entry = get(&conn, "https://example.com/file").unwrap();
171 assert_eq!(entry.content, "hello");
172 assert_eq!(entry.etag.as_deref(), Some("\"abc\""));
173 assert_eq!(
174 entry.last_modified.as_deref(),
175 Some("Thu, 01 Jan 2026 00:00:00 GMT")
176 );
177 }
178
179 #[test]
180 fn set_without_etag() {
181 let conn = db();
182 set(&conn, "https://example.com/file", "content", None, None);
183 let entry = get(&conn, "https://example.com/file").unwrap();
184 assert_eq!(entry.content, "content");
185 assert!(entry.etag.is_none());
186 assert!(entry.last_modified.is_none());
187 }
188
189 #[test]
190 fn fresh_entry_is_fresh() {
191 let conn = db();
192 set(&conn, "https://example.com/file", "x", None, None);
193 assert!(get(&conn, "https://example.com/file").unwrap().is_fresh());
194 }
195
196 #[test]
197 fn old_entry_is_not_fresh() {
198 let conn = db();
199 set_with_timestamp(&conn, "https://example.com/file", "x", None, None, 0);
200 assert!(!get(&conn, "https://example.com/file").unwrap().is_fresh());
201 }
202
203 #[test]
204 fn set_replaces_existing_entry() {
205 let conn = db();
206 set(
207 &conn,
208 "https://example.com/file",
209 "v1",
210 Some("\"etag1\""),
211 None,
212 );
213 set(
214 &conn,
215 "https://example.com/file",
216 "v2",
217 Some("\"etag2\""),
218 None,
219 );
220 let entry = get(&conn, "https://example.com/file").unwrap();
221 assert_eq!(entry.content, "v2");
222 assert_eq!(entry.etag.as_deref(), Some("\"etag2\""));
223 }
224
225 #[test]
226 fn prune_removes_old_keeps_fresh() {
227 let conn = db();
228 set_with_timestamp(&conn, "https://example.com/old", "old", None, None, 0);
229 set(&conn, "https://example.com/new", "new", None, None);
230 prune(&conn);
231 assert!(get(&conn, "https://example.com/old").is_none());
232 assert!(get(&conn, "https://example.com/new").is_some());
233 }
234
235 #[test]
236 fn different_urls_are_independent() {
237 let conn = db();
238 set(&conn, "https://example.com/a", "aaa", Some("\"e1\""), None);
239 set(&conn, "https://example.com/b", "bbb", Some("\"e2\""), None);
240 assert_eq!(get(&conn, "https://example.com/a").unwrap().content, "aaa");
241 assert_eq!(get(&conn, "https://example.com/b").unwrap().content, "bbb");
242 }
243
244 #[test]
245 fn clear_removes_database_file() {
246 let _guard = env_lock();
247 let dir = temp_cache_home();
248 let previous = std::env::var_os("XDG_CACHE_HOME");
249 std::env::set_var("XDG_CACHE_HOME", &dir);
250
251 {
252 let path = cache_path().expect("cache path");
253 std::fs::create_dir_all(path.parent().expect("cache dir")).unwrap();
254 std::fs::write(&path, b"cache").unwrap();
255
256 assert!(path.exists());
257 assert!(clear().unwrap());
258 assert!(!path.exists());
259 assert!(!clear().unwrap());
260 }
261
262 if let Some(value) = previous {
263 std::env::set_var("XDG_CACHE_HOME", value);
264 } else {
265 std::env::remove_var("XDG_CACHE_HOME");
266 }
267 let _ = std::fs::remove_dir_all(&dir);
268 }
269}