1use 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
21pub fn hash_content(content: &str) -> String {
29 use sha2::{Digest, Sha256};
30 let mut h = Sha256::new();
31 h.update(content.as_bytes());
32 format!("{:x}", h.finalize())
33}
34
35pub fn open(path: &Path) -> Result<Connection, String> {
37 let conn = Connection::open(path).map_err(|e| format!("open: {}", e))?;
38 conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")
39 .map_err(|e| format!("pragma: {}", e))?;
40 conn.execute_batch(
41 "CREATE TABLE IF NOT EXISTS content_cache (
42 hash TEXT PRIMARY KEY,
43 original BLOB NOT NULL,
44 stored_at INTEGER NOT NULL,
45 accessed_at INTEGER NOT NULL
46 );
47 CREATE INDEX IF NOT EXISTS idx_accessed ON content_cache(accessed_at);",
48 )
49 .map_err(|e| format!("create: {}", e))?;
50 Ok(conn)
51}
52
53pub fn store(conn: &Connection, content: &str) -> Result<String, String> {
55 let hash = hash_content(content);
56 let now = now();
57 conn.execute(
58 "INSERT OR REPLACE INTO content_cache (hash, original, stored_at, accessed_at) VALUES (?1, ?2, ?3, ?3)",
59 params![hash, content.as_bytes(), now],
60 )
61 .map_err(|e| format!("insert: {}", e))?;
62 Ok(hash)
63}
64
65pub fn retrieve(conn: &Connection, hash: &str) -> Result<Option<String>, String> {
67 let now = now();
68 let mut stmt = conn
69 .prepare_cached("UPDATE content_cache SET accessed_at = ?1 WHERE hash = ?2 RETURNING original")
70 .map_err(|e| format!("prepare: {}", e))?;
71 match stmt.query_row(params![now, hash], |row| row.get::<_, Vec<u8>>(0)) {
72 Ok(blob) => String::from_utf8(blob).map(Some).map_err(|e| format!("utf8: {}", e)),
73 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
74 Err(e) => Err(format!("query: {}", e)),
75 }
76}
77
78pub fn evict(conn: &Connection, ttl_secs: u64, max_entries: usize) -> Result<usize, String> {
80 let now = now();
81 let expired = conn
82 .execute(
83 "DELETE FROM content_cache WHERE accessed_at < ?1",
84 params![now.saturating_sub(ttl_secs)],
85 )
86 .map_err(|e| format!("evict ttl: {}", e))?;
87 let count: i64 = conn
88 .query_row("SELECT COUNT(*) FROM content_cache", [], |r| r.get(0))
89 .unwrap_or(0);
90 let mut trimmed = 0;
91 if (count as usize) > max_entries {
92 let excess = count as usize - max_entries;
93 trimmed = conn
94 .execute(
95 "DELETE FROM content_cache WHERE hash IN (
96 SELECT hash FROM content_cache ORDER BY accessed_at ASC LIMIT ?1
97 )",
98 params![excess],
99 )
100 .map_err(|e| format!("evict max: {}", e))?;
101 }
102 Ok(expired + trimmed)
103}
104
105pub fn stats(conn: &Connection) -> Result<(usize, u64), String> {
108 let (count, bytes): (i64, i64) = conn
109 .query_row(
110 "SELECT COUNT(*), COALESCE(SUM(LENGTH(original)), 0) FROM content_cache",
111 [],
112 |r| Ok((r.get(0)?, r.get(1)?)),
113 )
114 .unwrap_or((0, 0));
115 Ok((count as usize, bytes as u64))
116}
117
118#[inline(always)]
119pub fn default_ttl() -> u64 {
120 DEFAULT_TTL_SECS
121}
122
123#[inline(always)]
124pub fn default_max_entries() -> usize {
125 DEFAULT_MAX_ENTRIES
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use std::env;
132 use std::sync::atomic::{AtomicUsize, Ordering};
133
134 static COUNTER: AtomicUsize = AtomicUsize::new(0);
135
136 fn temp_db() -> Connection {
137 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
138 let path = env::temp_dir().join(format!("reliary_test_{}_{}_{}.db", std::process::id(), now(), n));
139 let _ = std::fs::remove_file(&path);
140 open(&path).unwrap()
141 }
142
143 #[test]
144 fn store_and_retrieve() {
145 let conn = temp_db();
146 let hash = store(&conn, "hello world").unwrap();
147 assert_eq!(retrieve(&conn, &hash).unwrap(), Some("hello world".to_string()));
148 }
149
150 #[test]
151 fn different_content_different_hash() {
152 let conn = temp_db();
153 let h1 = store(&conn, "alpha").unwrap();
154 let h2 = store(&conn, "beta").unwrap();
155 assert_ne!(h1, h2);
156 }
157
158 #[test]
159 fn missing_hash_returns_none() {
160 let conn = temp_db();
161 assert_eq!(retrieve(&conn, "nonexistent").unwrap(), None);
162 }
163
164 #[test]
165 fn evict_by_count() {
166 let conn = temp_db();
167 for i in 0..10 {
168 store(&conn, &format!("entry_{}", i)).unwrap();
169 }
170 let removed = evict(&conn, u64::MAX, 5).unwrap();
171 assert_eq!(removed, 5);
172 let (count, _) = stats(&conn).unwrap();
173 assert_eq!(count, 5);
174 }
175
176 #[test]
177 fn stats_reports_count_and_bytes() {
178 let conn = temp_db();
179 store(&conn, "one").unwrap();
180 store(&conn, "two").unwrap();
181 let (count, bytes) = stats(&conn).unwrap();
182 assert_eq!(count, 2);
183 assert!(bytes >= 6, "expected at least 6 bytes, got {}", bytes);
184 }
185}