Skip to main content

searchfox_lib/
cache.rs

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}