Skip to main content

wayfinder_core/cache/
store.rs

1use rusqlite::{Connection, params};
2use std::path::Path;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use crate::error::{Error, Result};
6
7/// SQLite-backed document cache.
8pub struct CacheStore {
9    conn: Connection,
10    ttl_secs: i64,
11}
12
13impl CacheStore {
14    pub fn open(path: &Path) -> Result<Self> {
15        let conn = Connection::open(path)?;
16        conn.execute_batch(
17            "CREATE TABLE IF NOT EXISTS documents (
18                id TEXT PRIMARY KEY,
19                category TEXT NOT NULL,
20                name TEXT NOT NULL,
21                data TEXT NOT NULL,
22                fetched_at INTEGER NOT NULL
23            );
24            CREATE INDEX IF NOT EXISTS idx_cat_name ON documents(category, name);",
25        )?;
26        Ok(Self {
27            conn,
28            ttl_secs: 86400 * 7,
29        })
30    }
31
32    pub fn set_ttl(&mut self, secs: i64) {
33        self.ttl_secs = secs;
34    }
35
36    pub fn get(&self, id: &str) -> Result<Option<String>> {
37        let cutoff = now_epoch() - self.ttl_secs;
38        let mut stmt = self
39            .conn
40            .prepare("SELECT data FROM documents WHERE id = ?1 AND fetched_at > ?2")?;
41        match stmt.query_row(params![id, cutoff], |row| row.get::<_, String>(0)) {
42            Ok(data) => Ok(Some(data)),
43            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
44            Err(e) => Err(Error::Cache(e)),
45        }
46    }
47
48    pub fn get_by_category(&self, category: &str) -> Result<Vec<String>> {
49        let cutoff = now_epoch() - self.ttl_secs;
50        let mut stmt = self
51            .conn
52            .prepare("SELECT data FROM documents WHERE category = ?1 AND fetched_at > ?2")?;
53        let rows = stmt
54            .query_map(params![category, cutoff], |row| row.get::<_, String>(0))?
55            .filter_map(|r| r.ok())
56            .collect();
57        Ok(rows)
58    }
59
60    pub fn put(&self, id: &str, category: &str, name: &str, data: &str) -> Result<()> {
61        self.conn.execute(
62            "INSERT OR REPLACE INTO documents (id, category, name, data, fetched_at)
63             VALUES (?1, ?2, ?3, ?4, ?5)",
64            params![id, category, name, data, now_epoch()],
65        )?;
66        Ok(())
67    }
68
69    pub fn bulk_put(&mut self, docs: &[(String, String, String, String)]) -> Result<usize> {
70        let tx = self.conn.transaction()?;
71        let mut count = 0;
72        for (id, category, name, data) in docs {
73            tx.execute(
74                "INSERT OR REPLACE INTO documents (id, category, name, data, fetched_at)
75                 VALUES (?1, ?2, ?3, ?4, ?5)",
76                params![id, category, name, data, now_epoch()],
77            )?;
78            count += 1;
79        }
80        tx.commit()?;
81        Ok(count)
82    }
83
84    /// Look up documents by name (case-insensitive), optionally scoped to a category.
85    pub fn get_by_name(&self, name: &str, category: Option<&str>) -> Result<Vec<String>> {
86        let cutoff = now_epoch() - self.ttl_secs;
87        let (sql, params_vec): (&str, Vec<Box<dyn rusqlite::types::ToSql>>) = match category {
88            Some(cat) => (
89                "SELECT data FROM documents WHERE name = ?1 COLLATE NOCASE AND category = ?2 AND fetched_at > ?3",
90                vec![
91                    Box::new(name.to_string()),
92                    Box::new(cat.to_string()),
93                    Box::new(cutoff),
94                ],
95            ),
96            None => (
97                "SELECT data FROM documents WHERE name = ?1 COLLATE NOCASE AND fetched_at > ?2",
98                vec![Box::new(name.to_string()), Box::new(cutoff)],
99            ),
100        };
101        let mut stmt = self.conn.prepare(sql)?;
102        let params_refs: Vec<&dyn rusqlite::types::ToSql> =
103            params_vec.iter().map(|p| p.as_ref()).collect();
104        let rows = stmt
105            .query_map(params_refs.as_slice(), |row| row.get::<_, String>(0))?
106            .filter_map(|r| r.ok())
107            .collect();
108        Ok(rows)
109    }
110
111    /// Delete expired documents from the cache.
112    pub fn purge_expired(&self) -> Result<usize> {
113        let cutoff = now_epoch() - self.ttl_secs;
114        let deleted = self.conn.execute(
115            "DELETE FROM documents WHERE fetched_at <= ?1",
116            params![cutoff],
117        )?;
118        Ok(deleted)
119    }
120
121    pub fn status(&self) -> Result<Vec<(String, i64)>> {
122        let mut stmt = self.conn.prepare(
123            "SELECT category, COUNT(*) FROM documents GROUP BY category ORDER BY category",
124        )?;
125        let rows = stmt
126            .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
127            .filter_map(|r| r.ok())
128            .collect();
129        Ok(rows)
130    }
131}
132
133fn now_epoch() -> i64 {
134    SystemTime::now()
135        .duration_since(UNIX_EPOCH)
136        .expect("system clock before UNIX epoch")
137        .as_secs() as i64
138}