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 {
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
31pub 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
49pub 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
61pub 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
74pub 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
101pub 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}