Skip to main content

reliary_core/
content_cache.rs

1//! Content cache — stores original content by hash, retrievable on demand.
2//! Enables aggressive compression of tool results and reasoning: if the LLM
3//! needs originals, it calls `reliary retrieve <hash>` to get them back.
4//! Storage: SQLite at .reliary/cache.sqlite in the project root.
5
6use rusqlite::{Connection, params};
7use std::path::Path;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10const DEFAULT_TTL_SECS: u64 = 3600;
11const DEFAULT_MAX_ENTRIES: usize = 500;
12
13#[inline(always)]
14pub fn now() -> u64 {
15    SystemTime::now()
16        .duration_since(UNIX_EPOCH)
17        .map(|d| d.as_secs())
18        .unwrap_or(0)
19}
20
21/// Hash content for storage key. Returns 16-char hex of 64-bit SipHash.
22/// D26: Keep DefaultHasher for now — speed is adequate for cache key use.
23pub fn hash_content(content: &str) -> String {
24    use std::collections::hash_map::DefaultHasher;
25    use std::hash::{Hash, Hasher};
26    let mut h = DefaultHasher::new();
27    content.hash(&mut h);
28    format!("{:016x}", h.finish())
29}
30
31/// Open or create the cache database at the given path.
32pub fn open(path: &Path) -> Result<Connection, String> {
33    let conn = Connection::open(path).map_err(|e| format!("open: {}", e))?;
34    conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")
35        .map_err(|e| format!("pragma: {}", e))?;
36    conn.execute_batch(
37        "CREATE TABLE IF NOT EXISTS content_cache (
38            hash TEXT PRIMARY KEY,
39            original BLOB NOT NULL,
40            stored_at INTEGER NOT NULL,
41            accessed_at INTEGER NOT NULL
42        );
43        CREATE INDEX IF NOT EXISTS idx_accessed ON content_cache(accessed_at);",
44    )
45    .map_err(|e| format!("create: {}", e))?;
46    Ok(conn)
47}
48
49/// Store original content, return hash.
50pub fn store(conn: &Connection, content: &str) -> Result<String, String> {
51    let hash = hash_content(content);
52    let now = now();
53    conn.execute(
54        "INSERT OR REPLACE INTO content_cache (hash, original, stored_at, accessed_at) VALUES (?1, ?2, ?3, ?3)",
55        params![hash, content.as_bytes(), now],
56    )
57    .map_err(|e| format!("insert: {}", e))?;
58    Ok(hash)
59}
60
61/// Retrieve original content by hash. Updates accessed_at in one round trip.
62pub fn retrieve(conn: &Connection, hash: &str) -> Result<Option<String>, String> {
63    let now = now();
64    let mut stmt = conn
65        .prepare_cached("UPDATE content_cache SET accessed_at = ?1 WHERE hash = ?2 RETURNING original")
66        .map_err(|e| format!("prepare: {}", e))?;
67    match stmt.query_row(params![now, hash], |row| row.get::<_, Vec<u8>>(0)) {
68        Ok(blob) => String::from_utf8(blob).map(Some).map_err(|e| format!("utf8: {}", e)),
69        Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
70        Err(e) => Err(format!("query: {}", e)),
71    }
72}
73
74/// Evict entries older than TTL or beyond max_entries count.
75pub fn evict(conn: &Connection, ttl_secs: u64, max_entries: usize) -> Result<usize, String> {
76    let now = now();
77    let expired = conn
78        .execute(
79            "DELETE FROM content_cache WHERE accessed_at < ?1",
80            params![now.saturating_sub(ttl_secs)],
81        )
82        .map_err(|e| format!("evict ttl: {}", e))?;
83    let count: i64 = conn
84        .query_row("SELECT COUNT(*) FROM content_cache", [], |r| r.get(0))
85        .unwrap_or(0);
86    let mut trimmed = 0;
87    if (count as usize) > max_entries {
88        let excess = count as usize - max_entries;
89        trimmed = conn
90            .execute(
91                "DELETE FROM content_cache WHERE hash IN (
92                    SELECT hash FROM content_cache ORDER BY accessed_at ASC LIMIT ?1
93                )",
94                params![excess],
95            )
96            .map_err(|e| format!("evict max: {}", e))?;
97    }
98    Ok(expired + trimmed)
99}
100
101/// Stats for the `reliary stats` command.
102/// D29: Combined into single query.
103pub fn stats(conn: &Connection) -> Result<(usize, u64), String> {
104    let (count, bytes): (i64, i64) = conn
105        .query_row(
106            "SELECT COUNT(*), COALESCE(SUM(LENGTH(original)), 0) FROM content_cache",
107            [],
108            |r| Ok((r.get(0)?, r.get(1)?)),
109        )
110        .unwrap_or((0, 0));
111    Ok((count as usize, bytes as u64))
112}
113
114#[inline(always)]
115pub fn default_ttl() -> u64 {
116    DEFAULT_TTL_SECS
117}
118
119#[inline(always)]
120pub fn default_max_entries() -> usize {
121    DEFAULT_MAX_ENTRIES
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use std::env;
128    use std::sync::atomic::{AtomicUsize, Ordering};
129
130    static COUNTER: AtomicUsize = AtomicUsize::new(0);
131
132    fn temp_db() -> Connection {
133        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
134        let path = env::temp_dir().join(format!("reliary_test_{}_{}_{}.db", std::process::id(), now(), n));
135        let _ = std::fs::remove_file(&path);
136        open(&path).unwrap()
137    }
138
139    #[test]
140    fn store_and_retrieve() {
141        let conn = temp_db();
142        let hash = store(&conn, "hello world").unwrap();
143        assert_eq!(retrieve(&conn, &hash).unwrap(), Some("hello world".to_string()));
144    }
145
146    #[test]
147    fn different_content_different_hash() {
148        let conn = temp_db();
149        let h1 = store(&conn, "alpha").unwrap();
150        let h2 = store(&conn, "beta").unwrap();
151        assert_ne!(h1, h2);
152    }
153
154    #[test]
155    fn missing_hash_returns_none() {
156        let conn = temp_db();
157        assert_eq!(retrieve(&conn, "nonexistent").unwrap(), None);
158    }
159
160    #[test]
161    fn evict_by_count() {
162        let conn = temp_db();
163        for i in 0..10 {
164            store(&conn, &format!("entry_{}", i)).unwrap();
165        }
166        let removed = evict(&conn, u64::MAX, 5).unwrap();
167        assert_eq!(removed, 5);
168        let (count, _) = stats(&conn).unwrap();
169        assert_eq!(count, 5);
170    }
171
172    #[test]
173    fn stats_reports_count_and_bytes() {
174        let conn = temp_db();
175        store(&conn, "one").unwrap();
176        store(&conn, "two").unwrap();
177        let (count, bytes) = stats(&conn).unwrap();
178        assert_eq!(count, 2);
179        assert!(bytes >= 6, "expected at least 6 bytes, got {}", bytes);
180    }
181}