1use std::collections::HashMap;
7use std::path::Path;
8
9use rusqlite::{Connection, params};
10
11use crate::error::{Result, SedimentError};
12
13#[derive(Debug, Clone)]
15pub struct AccessRecord {
16 pub access_count: u32,
17 pub last_accessed_at: i64,
18 pub created_at: i64,
19}
20
21pub struct AccessTracker {
23 conn: Connection,
24}
25
26impl AccessTracker {
27 pub fn open(path: &Path) -> Result<Self> {
29 let conn = Connection::open(path).map_err(|e| {
30 SedimentError::Database(format!("Failed to open access database: {}", e))
31 })?;
32
33 if let Err(e) = conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;") {
34 tracing::warn!("Failed to set SQLite PRAGMAs (access): {}", e);
35 }
36
37 conn.execute_batch(
38 "CREATE TABLE IF NOT EXISTS access_log (
39 item_id TEXT PRIMARY KEY,
40 access_count INTEGER NOT NULL DEFAULT 0,
41 last_accessed_at INTEGER NOT NULL,
42 created_at INTEGER NOT NULL
43 );",
44 )
45 .map_err(|e| {
46 SedimentError::Database(format!("Failed to create access_log table: {}", e))
47 })?;
48
49 if let Err(e) = conn.execute_batch(
51 "ALTER TABLE access_log ADD COLUMN validation_count INTEGER NOT NULL DEFAULT 0;",
52 ) {
53 let msg = e.to_string();
54 if !msg.contains("duplicate column") {
55 tracing::warn!("access_log migration unexpected error: {}", msg);
56 }
57 }
58
59 Ok(Self { conn })
60 }
61
62 pub fn record_access(&self, item_id: &str, created_at: i64) -> Result<()> {
64 let now = chrono::Utc::now().timestamp();
65 self.conn
66 .execute(
67 "INSERT INTO access_log (item_id, access_count, last_accessed_at, created_at)
68 VALUES (?1, 1, ?2, ?3)
69 ON CONFLICT(item_id) DO UPDATE SET
70 access_count = access_count + 1,
71 last_accessed_at = ?2",
72 params![item_id, now, created_at],
73 )
74 .map_err(|e| SedimentError::Database(format!("Failed to record access: {}", e)))?;
75 Ok(())
76 }
77
78 pub fn record_validation(&self, item_id: &str, created_at: i64) -> Result<()> {
80 let now = chrono::Utc::now().timestamp();
81 self.conn
82 .execute(
83 "INSERT INTO access_log (item_id, access_count, last_accessed_at, created_at, validation_count)
84 VALUES (?1, 0, ?2, ?3, 1)
85 ON CONFLICT(item_id) DO UPDATE SET
86 validation_count = validation_count + 1,
87 last_accessed_at = ?2",
88 params![item_id, now, created_at],
89 )
90 .map_err(|e| {
91 SedimentError::Database(format!("Failed to record validation: {}", e))
92 })?;
93 Ok(())
94 }
95
96 pub fn get_validation_count(&self, item_id: &str) -> Result<u32> {
98 let count: u32 = self
99 .conn
100 .query_row(
101 "SELECT COALESCE(validation_count, 0) FROM access_log WHERE item_id = ?1",
102 params![item_id],
103 |row| row.get(0),
104 )
105 .unwrap_or(0);
106 Ok(count)
107 }
108
109 pub fn get_accesses(&self, item_ids: &[&str]) -> Result<HashMap<String, AccessRecord>> {
111 if item_ids.is_empty() {
112 return Ok(HashMap::new());
113 }
114
115 let placeholders: Vec<String> = item_ids
116 .iter()
117 .enumerate()
118 .map(|(i, _)| format!("?{}", i + 1))
119 .collect();
120 let sql = format!(
121 "SELECT item_id, access_count, last_accessed_at, created_at FROM access_log WHERE item_id IN ({})",
122 placeholders.join(", ")
123 );
124
125 let mut stmt = self
126 .conn
127 .prepare(&sql)
128 .map_err(|e| SedimentError::Database(format!("Failed to prepare query: {}", e)))?;
129
130 let params: Vec<&dyn rusqlite::types::ToSql> = item_ids
131 .iter()
132 .map(|id| id as &dyn rusqlite::types::ToSql)
133 .collect();
134
135 let rows = stmt
136 .query_map(params.as_slice(), |row| {
137 Ok((
138 row.get::<_, String>(0)?,
139 AccessRecord {
140 access_count: row.get::<_, u32>(1)?,
141 last_accessed_at: row.get::<_, i64>(2)?,
142 created_at: row.get::<_, i64>(3)?,
143 },
144 ))
145 })
146 .map_err(|e| SedimentError::Database(format!("Failed to query accesses: {}", e)))?;
147
148 let mut map = HashMap::new();
149 for row in rows {
150 let (id, record) = row.map_err(|e| {
151 SedimentError::Database(format!("Failed to read access record: {}", e))
152 })?;
153 map.insert(id, record);
154 }
155
156 Ok(map)
157 }
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163 use tempfile::NamedTempFile;
164
165 #[test]
166 fn test_open_creates_table() {
167 let tmp = NamedTempFile::new().unwrap();
168 let tracker = AccessTracker::open(tmp.path()).unwrap();
169 drop(tracker);
171 let _tracker2 = AccessTracker::open(tmp.path()).unwrap();
172 }
173
174 #[test]
175 fn test_record_and_get_access() {
176 let tmp = NamedTempFile::new().unwrap();
177 let tracker = AccessTracker::open(tmp.path()).unwrap();
178
179 let created = 1700000000i64;
180 tracker.record_access("item1", created).unwrap();
181 tracker.record_access("item1", created).unwrap();
182 tracker.record_access("item2", created).unwrap();
183
184 let records = tracker.get_accesses(&["item1", "item2", "item3"]).unwrap();
185
186 assert_eq!(records.len(), 2);
187 assert_eq!(records["item1"].access_count, 2);
188 assert_eq!(records["item1"].created_at, created);
189 assert_eq!(records["item2"].access_count, 1);
190 assert!(!records.contains_key("item3"));
191 }
192
193 #[test]
194 fn test_get_accesses_empty() {
195 let tmp = NamedTempFile::new().unwrap();
196 let tracker = AccessTracker::open(tmp.path()).unwrap();
197 let records = tracker.get_accesses(&[]).unwrap();
198 assert!(records.is_empty());
199 }
200
201 #[test]
202 fn test_record_validation_on_new_item() {
203 let tmp = NamedTempFile::new().unwrap();
205 let tracker = AccessTracker::open(tmp.path()).unwrap();
206
207 let created = 1700000000i64;
208 tracker.record_validation("new-item", created).unwrap();
210 tracker.record_validation("new-item", created).unwrap();
211
212 let count = tracker.get_validation_count("new-item").unwrap();
213 assert_eq!(
214 count, 2,
215 "Validation count should be 2 after two record_validation calls"
216 );
217
218 let count = tracker.get_validation_count("other-item").unwrap();
220 assert_eq!(count, 0);
221 }
222}