1use anyhow::Result;
2use chrono::{DateTime, Utc};
3use rusqlite::{params, Connection};
4use std::path::PathBuf;
5use std::sync::{Arc, Mutex};
6
7#[derive(Debug, Clone)]
8pub struct ChatEntry {
9 pub chat_id: String,
10 pub model: String,
11 pub question: String,
12 pub response: String,
13 pub timestamp: DateTime<Utc>,
14 pub input_tokens: Option<i32>,
15 pub output_tokens: Option<i32>,
16}
17
18#[derive(Debug)]
19pub struct DatabaseStats {
20 pub total_entries: usize,
21 pub unique_sessions: usize,
22 pub file_size_bytes: u64,
23 pub date_range: Option<(DateTime<Utc>, DateTime<Utc>)>,
24 pub model_usage: Vec<(String, i64)>,
25}
26
27pub struct ConnectionPool {
29 connections: Arc<Mutex<Vec<Connection>>>,
30 max_connections: usize,
31 db_path: PathBuf,
32}
33
34impl ConnectionPool {
35 pub fn new(db_path: PathBuf, max_connections: usize) -> Result<Self> {
36 let mut connections = Vec::with_capacity(max_connections);
37
38 for _ in 0..std::cmp::min(2, max_connections) {
40 let conn = Connection::open(&db_path)?;
41 Self::configure_connection(&conn)?;
42 connections.push(conn);
43 }
44
45 Ok(Self {
46 connections: Arc::new(Mutex::new(connections)),
47 max_connections,
48 db_path,
49 })
50 }
51
52 fn configure_connection(conn: &Connection) -> Result<()> {
53 conn.pragma_update(None, "journal_mode", "WAL")?;
55 conn.pragma_update(None, "cache_size", 10000)?;
57 conn.pragma_update(None, "foreign_keys", true)?;
59 conn.pragma_update(None, "synchronous", "NORMAL")?;
61 Ok(())
62 }
63
64 pub fn get_connection(&self) -> Result<PooledConnection> {
65 let mut connections = self.connections.lock().unwrap();
66
67 if let Some(conn) = connections.pop() {
68 Ok(PooledConnection {
69 conn: Some(conn),
70 pool: self.connections.clone(),
71 })
72 } else if connections.len() < self.max_connections {
73 let conn = Connection::open(&self.db_path)?;
75 Self::configure_connection(&conn)?;
76 Ok(PooledConnection {
77 conn: Some(conn),
78 pool: self.connections.clone(),
79 })
80 } else {
81 let conn = Connection::open(&self.db_path)?;
85 Self::configure_connection(&conn)?;
86 Ok(PooledConnection {
87 conn: Some(conn),
88 pool: self.connections.clone(),
89 })
90 }
91 }
92}
93
94pub struct PooledConnection {
96 conn: Option<Connection>,
97 pool: Arc<Mutex<Vec<Connection>>>,
98}
99
100impl PooledConnection {
101 pub fn execute(
102 &self,
103 sql: &str,
104 params: impl rusqlite::Params,
105 ) -> Result<usize, rusqlite::Error> {
106 self.conn.as_ref().unwrap().execute(sql, params)
107 }
108
109 pub fn query_row<T, P, F>(&self, sql: &str, params: P, f: F) -> Result<T, rusqlite::Error>
110 where
111 P: rusqlite::Params,
112 F: FnOnce(&rusqlite::Row<'_>) -> Result<T, rusqlite::Error>,
113 {
114 self.conn.as_ref().unwrap().query_row(sql, params, f)
115 }
116}
117
118impl Drop for PooledConnection {
119 fn drop(&mut self) {
120 if let Some(conn) = self.conn.take() {
121 let mut connections = self.pool.lock().unwrap();
122 connections.push(conn);
123 }
124 }
125}
126
127pub struct Database {
129 pool: ConnectionPool,
130}
131
132impl Database {
133 pub fn new() -> Result<Self> {
134 let db_path = Self::database_path()?;
135 let pool = ConnectionPool::new(db_path, 5)?; let conn = pool.get_connection()?;
139 Self::initialize_schema(&conn)?;
140
141 Ok(Database { pool })
142 }
143
144 fn initialize_schema(conn: &PooledConnection) -> Result<()> {
145 conn.execute(
147 "CREATE TABLE IF NOT EXISTS chat_logs (
148 id INTEGER PRIMARY KEY AUTOINCREMENT,
149 chat_id TEXT NOT NULL,
150 model TEXT NOT NULL,
151 question TEXT NOT NULL,
152 response TEXT NOT NULL,
153 timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
154 input_tokens INTEGER,
155 output_tokens INTEGER
156 )",
157 [],
158 )?;
159
160 let _ = conn.execute("ALTER TABLE chat_logs ADD COLUMN input_tokens INTEGER", []);
162 let _ = conn.execute("ALTER TABLE chat_logs ADD COLUMN output_tokens INTEGER", []);
163
164 conn.execute(
166 "CREATE TABLE IF NOT EXISTS session_state (
167 key TEXT PRIMARY KEY,
168 value TEXT NOT NULL
169 )",
170 [],
171 )?;
172
173 conn.execute(
175 "CREATE INDEX IF NOT EXISTS idx_chat_logs_chat_id ON chat_logs(chat_id)",
176 [],
177 )?;
178
179 conn.execute(
180 "CREATE INDEX IF NOT EXISTS idx_chat_logs_timestamp ON chat_logs(timestamp DESC)",
181 [],
182 )?;
183
184 conn.execute(
186 "CREATE INDEX IF NOT EXISTS idx_chat_logs_model ON chat_logs(model)",
187 [],
188 )?;
189
190 Ok(())
191 }
192
193 pub fn save_chat_entry_with_tokens(
194 &self,
195 chat_id: &str,
196 model: &str,
197 question: &str,
198 response: &str,
199 input_tokens: Option<i32>,
200 output_tokens: Option<i32>,
201 ) -> Result<()> {
202 let conn = self.pool.get_connection()?;
203
204 conn.execute(
205 "INSERT INTO chat_logs (chat_id, model, question, response, timestamp, input_tokens, output_tokens)
206 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
207 params![chat_id, model, question, response, Utc::now(), input_tokens, output_tokens]
208 )?;
209 Ok(())
210 }
211
212 pub fn get_chat_history(&self, chat_id: &str) -> Result<Vec<ChatEntry>> {
213 let conn = self.pool.get_connection()?;
214
215 let mut stmt = conn.conn.as_ref().unwrap().prepare(
216 "SELECT id, chat_id, model, question, response, timestamp, input_tokens, output_tokens
217 FROM chat_logs
218 WHERE chat_id = ?1
219 ORDER BY timestamp ASC",
220 )?;
221
222 let rows = stmt.query_map([chat_id], |row| {
223 Ok(ChatEntry {
224 chat_id: row.get(1)?,
225 model: row.get(2)?,
226 question: row.get(3)?,
227 response: row.get(4)?,
228 timestamp: row.get(5)?,
229 input_tokens: row.get(6).ok(),
230 output_tokens: row.get(7).ok(),
231 })
232 })?;
233
234 let mut entries = Vec::new();
235 for row in rows {
236 entries.push(row?);
237 }
238
239 Ok(entries)
240 }
241
242 pub fn get_all_logs(&self) -> Result<Vec<ChatEntry>> {
244 self.get_recent_logs(None)
245 }
246
247 pub fn get_recent_logs(&self, limit: Option<usize>) -> Result<Vec<ChatEntry>> {
248 let conn = self.pool.get_connection()?;
249
250 let sql = if let Some(limit) = limit {
251 format!(
252 "SELECT id, chat_id, model, question, response, timestamp, input_tokens, output_tokens
253 FROM chat_logs
254 ORDER BY timestamp DESC
255 LIMIT {}",
256 limit
257 )
258 } else {
259 "SELECT id, chat_id, model, question, response, timestamp, input_tokens, output_tokens
260 FROM chat_logs
261 ORDER BY timestamp DESC"
262 .to_string()
263 };
264
265 let mut stmt = conn.conn.as_ref().unwrap().prepare(&sql)?;
266
267 let rows = stmt.query_map([], |row| {
268 Ok(ChatEntry {
269 chat_id: row.get(1)?,
270 model: row.get(2)?,
271 question: row.get(3)?,
272 response: row.get(4)?,
273 timestamp: row.get(5)?,
274 input_tokens: row.get(6).ok(),
275 output_tokens: row.get(7).ok(),
276 })
277 })?;
278
279 let mut entries = Vec::new();
280 for row in rows {
281 entries.push(row?);
282 }
283
284 Ok(entries)
285 }
286
287 pub fn set_current_session_id(&self, session_id: &str) -> Result<()> {
288 let conn = self.pool.get_connection()?;
289
290 conn.execute(
291 "INSERT OR REPLACE INTO session_state (key, value) VALUES ('current_session', ?1)",
292 [session_id],
293 )?;
294 Ok(())
295 }
296
297 pub fn get_current_session_id(&self) -> Result<Option<String>> {
298 let conn = self.pool.get_connection()?;
299
300 let mut stmt = conn
301 .conn
302 .as_ref()
303 .unwrap()
304 .prepare("SELECT value FROM session_state WHERE key = 'current_session'")?;
305
306 let mut rows = stmt.query_map([], |row| Ok(row.get::<_, String>(0)?))?;
307
308 if let Some(row) = rows.next() {
309 Ok(Some(row?))
310 } else {
311 Ok(None)
312 }
313 }
314
315 pub fn purge_all_logs(&self) -> Result<()> {
316 let conn = self.pool.get_connection()?;
317
318 conn.execute("BEGIN TRANSACTION", [])?;
320
321 match (|| -> Result<()> {
322 conn.execute("DELETE FROM chat_logs", [])?;
323 conn.execute("DELETE FROM session_state", [])?;
324 Ok(())
325 })() {
326 Ok(_) => {
327 conn.execute("COMMIT", [])?;
328 Ok(())
329 }
330 Err(e) => {
331 conn.execute("ROLLBACK", [])?;
332 Err(e)
333 }
334 }
335 }
336
337 pub fn purge_logs_by_age(&self, days: u32) -> Result<usize> {
339 let conn = self.pool.get_connection()?;
340
341 let cutoff_date = chrono::Utc::now() - chrono::Duration::days(days as i64);
342
343 let deleted_count =
344 conn.execute("DELETE FROM chat_logs WHERE timestamp < ?1", [cutoff_date])?;
345
346 Ok(deleted_count)
347 }
348
349 pub fn purge_logs_keep_recent(&self, keep_count: usize) -> Result<usize> {
351 let conn = self.pool.get_connection()?;
352
353 let total_count: i64 =
355 conn.query_row("SELECT COUNT(*) FROM chat_logs", [], |row| row.get(0))?;
356
357 if total_count <= keep_count as i64 {
358 return Ok(0); }
360
361 let to_delete = total_count - keep_count as i64;
362
363 let deleted_count = conn.execute(
364 "DELETE FROM chat_logs WHERE id IN (
365 SELECT id FROM chat_logs
366 ORDER BY timestamp ASC
367 LIMIT ?1
368 )",
369 [to_delete],
370 )?;
371
372 Ok(deleted_count)
373 }
374
375 pub fn purge_logs_by_size(&self, max_size_mb: u64) -> Result<usize> {
377 let db_path = Self::database_path()?;
378 let current_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
379
380 let max_size_bytes = max_size_mb * 1024 * 1024;
381
382 if current_size <= max_size_bytes {
383 return Ok(0); }
385
386 let conn = self.pool.get_connection()?;
388 let total_count: i64 =
389 conn.query_row("SELECT COUNT(*) FROM chat_logs", [], |row| row.get(0))?;
390
391 let to_delete = (total_count as f64 * 0.25) as i64;
392
393 if to_delete > 0 {
394 let deleted_count = conn.execute(
395 "DELETE FROM chat_logs WHERE id IN (
396 SELECT id FROM chat_logs
397 ORDER BY timestamp ASC
398 LIMIT ?1
399 )",
400 [to_delete],
401 )?;
402
403 conn.execute("VACUUM", [])?;
405
406 Ok(deleted_count)
407 } else {
408 Ok(0)
409 }
410 }
411
412 pub fn smart_purge(
414 &self,
415 max_age_days: Option<u32>,
416 max_entries: Option<usize>,
417 max_size_mb: Option<u64>,
418 ) -> Result<usize> {
419 let mut total_deleted = 0;
420
421 if let Some(days) = max_age_days {
423 total_deleted += self.purge_logs_by_age(days)?;
424 }
425
426 if let Some(max_count) = max_entries {
428 total_deleted += self.purge_logs_keep_recent(max_count)?;
429 }
430
431 if let Some(max_mb) = max_size_mb {
433 total_deleted += self.purge_logs_by_size(max_mb)?;
434 }
435
436 Ok(total_deleted)
437 }
438
439 pub fn clear_session(&self, session_id: &str) -> Result<()> {
440 let conn = self.pool.get_connection()?;
441
442 conn.execute("DELETE FROM chat_logs WHERE chat_id = ?1", [session_id])?;
443 Ok(())
444 }
445
446 pub fn get_stats(&self) -> Result<DatabaseStats> {
447 let conn = self.pool.get_connection()?;
448
449 let total_entries: i64 =
451 conn.query_row("SELECT COUNT(*) FROM chat_logs", [], |row| row.get(0))?;
452
453 let unique_sessions: i64 =
454 conn.query_row("SELECT COUNT(DISTINCT chat_id) FROM chat_logs", [], |row| {
455 row.get(0)
456 })?;
457
458 let db_path = Self::database_path()?;
460 let file_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
461
462 let date_range = if total_entries > 0 {
464 let (earliest, latest): (Option<DateTime<Utc>>, Option<DateTime<Utc>>) = conn
465 .query_row(
466 "SELECT MIN(timestamp), MAX(timestamp) FROM chat_logs",
467 [],
468 |row| Ok((row.get(0).ok(), row.get(1).ok())),
469 )?;
470
471 match (earliest, latest) {
472 (Some(e), Some(l)) => Some((e, l)),
473 _ => None,
474 }
475 } else {
476 None
477 };
478
479 let mut stmt = conn.conn.as_ref().unwrap().prepare(
481 "SELECT model, COUNT(*) as count FROM chat_logs GROUP BY model ORDER BY count DESC",
482 )?;
483
484 let model_stats = stmt
485 .query_map([], |row| {
486 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
487 })?
488 .collect::<Result<Vec<_>, _>>()?;
489
490 Ok(DatabaseStats {
491 total_entries: total_entries as usize,
492 unique_sessions: unique_sessions as usize,
493 file_size_bytes: file_size,
494 date_range,
495 model_usage: model_stats,
496 })
497 }
498
499 fn database_path() -> Result<PathBuf> {
500 let data_dir = dirs::data_local_dir()
505 .ok_or_else(|| anyhow::anyhow!("Could not find data directory"))?
506 .join("lc");
507 std::fs::create_dir_all(&data_dir)?;
508 Ok(data_dir.join("logs.db"))
509 }
510}
511
512#[cfg(test)]
515mod tests {
516 use super::*;
517 use tempfile::tempdir;
518
519 #[test]
520 fn test_connection_pool() {
521 let temp_dir = tempdir().unwrap();
522 let db_path = temp_dir.path().join("test.db");
523
524 let pool = ConnectionPool::new(db_path, 3).unwrap();
525
526 let conn1 = pool.get_connection().unwrap();
528 let conn2 = pool.get_connection().unwrap();
529 let conn3 = pool.get_connection().unwrap();
530
531 assert!(conn1.query_row("SELECT 1", [], |_| Ok(())).is_ok());
533 assert!(conn2.query_row("SELECT 1", [], |_| Ok(())).is_ok());
534 assert!(conn3.query_row("SELECT 1", [], |_| Ok(())).is_ok());
535 }
536
537 #[test]
538 fn test_optimized_database() {
539 let temp_dir = tempdir().unwrap();
540 std::env::set_var("HOME", temp_dir.path());
541
542 let db = Database::new().unwrap();
543
544 db.save_chat_entry_with_tokens(
546 "test_session",
547 "test_model",
548 "test question",
549 "test response",
550 Some(100),
551 Some(50),
552 )
553 .unwrap();
554
555 let history = db.get_chat_history("test_session").unwrap();
556 assert_eq!(history.len(), 1);
557 assert_eq!(history[0].question, "test question");
558 assert_eq!(history[0].input_tokens, Some(100));
559 assert_eq!(history[0].output_tokens, Some(50));
560 }
561}