1use crate::diff::compute_diff;
10use crate::error::Error;
11use crate::types::{CacheConfig, CacheStats, FileReadResult};
12use rusqlite::{params, Connection};
13use sha2::{Digest, Sha256};
14use std::path::PathBuf;
15use tokio::sync::Mutex;
16use tracing::{debug, info};
17
18pub struct CacheStore {
20 config: CacheConfig,
21 conn: Mutex<Connection>,
22}
23
24impl CacheStore {
25 pub fn new(config: CacheConfig) -> Result<Self, Error> {
27 if let Some(parent) = config.db_path.parent() {
29 std::fs::create_dir_all(parent)?;
30 }
31
32 let conn = Connection::open(&config.db_path)?;
33
34 Ok(Self {
35 config,
36 conn: Mutex::new(conn),
37 })
38 }
39
40 pub async fn init(&self) -> Result<(), Error> {
42 let conn = self.conn.lock().await;
43
44 conn.execute_batch(
47 "PRAGMA journal_mode=WAL;
48 PRAGMA synchronous=NORMAL;",
49 )?;
50
51 conn.execute(
53 "CREATE TABLE IF NOT EXISTS file_versions (
54 path TEXT NOT NULL,
55 hash TEXT NOT NULL,
56 content TEXT NOT NULL,
57 lines INTEGER NOT NULL,
58 created_at INTEGER NOT NULL,
59 PRIMARY KEY (path, hash)
60 )",
61 [],
62 )?;
63
64 conn.execute(
66 "CREATE TABLE IF NOT EXISTS session_reads (
67 session_id TEXT NOT NULL,
68 path TEXT NOT NULL,
69 hash TEXT NOT NULL,
70 read_at INTEGER NOT NULL,
71 PRIMARY KEY (session_id, path)
72 )",
73 [],
74 )?;
75
76 conn.execute(
78 "CREATE TABLE IF NOT EXISTS stats (
79 key TEXT PRIMARY KEY,
80 value INTEGER NOT NULL DEFAULT 0
81 )",
82 [],
83 )?;
84
85 conn.execute(
87 "CREATE TABLE IF NOT EXISTS session_stats (
88 session_id TEXT NOT NULL,
89 key TEXT NOT NULL,
90 value INTEGER NOT NULL DEFAULT 0,
91 PRIMARY KEY (session_id, key)
92 )",
93 [],
94 )?;
95
96 conn.execute(
100 "INSERT OR IGNORE INTO stats (key, value) VALUES ('tokens_saved', 0)",
101 [],
102 )?;
103
104 info!("Cache database initialized at {:?}", self.config.db_path);
105 Ok(())
106 }
107
108 fn compute_hash(content: &str) -> String {
115 let mut hasher = Sha256::new();
116 hasher.update(content.as_bytes());
117 format!("{:x}", hasher.finalize())
118 }
119
120 pub fn estimate_tokens(text: &str) -> u64 {
128 (text.len().div_ceil(4)) as u64
129 }
130
131 fn increment_stat(conn: &Connection, key: &str, amount: i64) -> Result<(), Error> {
133 conn.execute(
134 "INSERT INTO stats (key, value) VALUES (?, ?)
135 ON CONFLICT(key) DO UPDATE SET value = value + ?",
136 params![key, amount, amount],
137 )?;
138 Ok(())
139 }
140
141 fn increment_session_stat(
143 conn: &Connection,
144 session_id: &str,
145 key: &str,
146 amount: i64,
147 ) -> Result<(), Error> {
148 conn.execute(
149 "INSERT INTO session_stats (session_id, key, value) VALUES (?, ?, ?)
150 ON CONFLICT(session_id, key) DO UPDATE SET value = value + ?",
151 params![session_id, key, amount, amount],
152 )?;
153 Ok(())
154 }
155
156 fn slice_lines(lines: &[&str], range_start: usize, range_end: usize) -> String {
159 let start = range_start.saturating_sub(1);
160 let end = range_end.min(lines.len());
161 if start >= lines.len() {
162 String::new()
163 } else {
164 lines[start..end].join("\n")
165 }
166 }
167
168 pub async fn read_file(
176 &self,
177 path: &str,
178 offset: Option<usize>,
179 limit: Option<usize>,
180 force: bool,
181 ) -> Result<FileReadResult, Error> {
182 let full_path = self.resolve_path(path)?;
184
185 if !full_path.exists() {
187 return Err(Error::FileNotFound(path.to_string()));
188 }
189
190 let content = tokio::fs::read_to_string(&full_path).await?;
192 let hash = Self::compute_hash(&content);
193 let lines: Vec<&str> = content.lines().collect();
196 let total_lines = lines.len();
197
198 let conn = self.conn.lock().await;
199
200 let now = std::time::SystemTime::now()
201 .duration_since(std::time::UNIX_EPOCH)
202 .unwrap()
203 .as_secs() as i64;
204
205 let is_partial = offset.is_some() || limit.is_some();
207 let range_start = offset.unwrap_or(1);
208 let range_end = limit.map(|l| range_start + l - 1).unwrap_or(total_lines);
209
210 let session_last_hash: Option<String> = conn
212 .query_row(
213 "SELECT hash FROM session_reads WHERE session_id = ? AND path = ?",
214 params![self.config.session_id, path],
215 |row| row.get(0),
216 )
217 .ok();
218
219 let cached_content: Option<(String, usize)> = if let Some(ref last_hash) = session_last_hash
221 {
222 conn.query_row(
223 "SELECT content, lines FROM file_versions WHERE path = ? AND hash = ?",
224 params![path, last_hash],
225 |row| Ok((row.get(0)?, row.get(1)?)),
226 )
227 .ok()
228 } else {
229 None
230 };
231
232 let result = match (force, session_last_hash.as_ref()) {
234 (true, _) | (false, None) => {
235 debug!(
237 "First read for session {}: {}",
238 self.config.session_id, path
239 );
240
241 conn.execute(
243 "INSERT OR IGNORE INTO file_versions (path, hash, content, lines, created_at) VALUES (?, ?, ?, ?, ?)",
244 params![path, hash, content, total_lines, now],
245 )
246 ?;
247
248 conn.execute(
250 "INSERT OR REPLACE INTO session_reads (session_id, path, hash, read_at) VALUES (?, ?, ?, ?)",
251 params![self.config.session_id, path, hash, now],
252 )
253 ?;
254
255 let result_content = if is_partial {
258 Self::slice_lines(&lines, range_start, range_end)
259 } else {
260 content
261 };
262
263 FileReadResult {
264 cached: false,
265 content: result_content,
266 hash,
267 total_lines,
268 lines_changed: None,
269 diff: None,
270 }
271 }
272 (false, Some(last_hash)) if hash == *last_hash => {
273 debug!("Cache hit for session {}: {}", self.config.session_id, path);
275
276 conn.execute(
278 "UPDATE session_reads SET read_at = ? WHERE session_id = ? AND path = ?",
279 params![now, self.config.session_id, path],
280 )?;
281
282 let tokens = if is_partial {
285 Self::estimate_tokens(&Self::slice_lines(&lines, range_start, range_end))
286 } else {
287 Self::estimate_tokens(&content)
288 };
289
290 Self::increment_stat(&conn, "tokens_saved", tokens as i64)?;
292 Self::increment_session_stat(
293 &conn,
294 &self.config.session_id,
295 "tokens_saved",
296 tokens as i64,
297 )?;
298
299 let label = if is_partial {
301 format!(
302 "[cached-context: unchanged, lines {}-{} of {}, {} tokens saved]",
303 range_start, range_end, total_lines, tokens
304 )
305 } else {
306 format!(
307 "[cached-context: unchanged, {} lines, {} tokens saved]",
308 total_lines, tokens
309 )
310 };
311
312 FileReadResult {
313 cached: true,
314 content: label,
315 hash,
316 total_lines,
317 lines_changed: None, diff: None,
319 }
320 }
321 (false, Some(_)) => {
322 debug!(
324 "Cache miss (changed) for session {}: {}",
325 self.config.session_id, path
326 );
327
328 if let Some((old_content, _)) = cached_content {
329 let diff_result = compute_diff(&old_content, &content, path);
331
332 let changes_in_range = is_partial
334 && diff_result
335 .changed_new_lines
336 .iter()
337 .any(|&line| line >= range_start && line <= range_end);
338
339 if is_partial && !changes_in_range {
340 debug!(
342 "Changes outside range {}-{} for session {}: {}",
343 range_start, range_end, self.config.session_id, path
344 );
345
346 conn.execute(
348 "UPDATE session_reads SET hash = ?, read_at = ? WHERE session_id = ? AND path = ?",
349 params![hash, now, self.config.session_id, path],
350 )
351 ?;
352
353 let partial_content = Self::slice_lines(&lines, range_start, range_end);
355 let tokens = Self::estimate_tokens(&partial_content);
356
357 Self::increment_stat(&conn, "tokens_saved", tokens as i64)?;
359 Self::increment_session_stat(
360 &conn,
361 &self.config.session_id,
362 "tokens_saved",
363 tokens as i64,
364 )?;
365
366 FileReadResult {
367 cached: true,
368 content: format!(
369 "[cached-context: unchanged in lines {}-{}, changes elsewhere in file, {} tokens saved]",
370 range_start, range_end, tokens
371 ),
372 hash,
373 total_lines,
374 lines_changed: Some(0),
375 diff: None,
376 }
377 } else {
378 conn.execute(
382 "INSERT OR IGNORE INTO file_versions (path, hash, content, lines, created_at) VALUES (?, ?, ?, ?, ?)",
383 params![path, &hash, &content, total_lines, now],
384 )
385 ?;
386
387 conn.execute(
389 "UPDATE session_reads SET hash = ?, read_at = ? WHERE session_id = ? AND path = ?",
390 params![hash, now, self.config.session_id, path],
391 )
392 ?;
393
394 let full_tokens = Self::estimate_tokens(&content);
396 let diff_tokens = Self::estimate_tokens(&diff_result.diff);
397 let tokens_saved = full_tokens.saturating_sub(diff_tokens);
398
399 if tokens_saved > 0 {
400 Self::increment_stat(&conn, "tokens_saved", tokens_saved as i64)?;
402 Self::increment_session_stat(
403 &conn,
404 &self.config.session_id,
405 "tokens_saved",
406 tokens_saved as i64,
407 )?;
408 }
409
410 let result_content = if is_partial {
413 Self::slice_lines(&lines, range_start, range_end)
414 } else {
415 format!(
417 "[cached-context: {} lines changed out of {}]\n{}",
418 diff_result.lines_changed, total_lines, diff_result.diff
419 )
420 };
421
422 FileReadResult {
425 cached: !is_partial,
426 content: result_content,
427 hash,
428 total_lines,
429 lines_changed: Some(diff_result.lines_changed),
430 diff: Some(diff_result.diff),
431 }
432 }
433 } else {
434 debug!(
437 "Old version not found for session {}: {}",
438 self.config.session_id, path
439 );
440
441 conn.execute(
443 "INSERT OR IGNORE INTO file_versions (path, hash, content, lines, created_at) VALUES (?, ?, ?, ?, ?)",
444 params![path, &hash, &content, total_lines, now],
445 )
446 ?;
447
448 conn.execute(
450 "UPDATE session_reads SET hash = ?, read_at = ? WHERE session_id = ? AND path = ?",
451 params![hash, now, self.config.session_id, path],
452 )
453 ?;
454
455 let result_content = if is_partial {
456 Self::slice_lines(&lines, range_start, range_end)
457 } else {
458 content
459 };
460
461 FileReadResult {
462 cached: false,
463 content: result_content,
464 hash,
465 total_lines,
466 lines_changed: None,
467 diff: None,
468 }
469 }
470 }
471 };
472
473 Ok(result)
478 }
479
480 pub async fn get_stats(&self) -> Result<CacheStats, Error> {
482 let conn = self.conn.lock().await;
483
484 let files_tracked: i64 = conn
487 .query_row(
488 "SELECT COUNT(DISTINCT path) FROM file_versions",
489 [],
490 |row| row.get(0),
491 )
492 .unwrap_or(0);
493
494 let tokens_saved: i64 = conn
495 .query_row(
496 "SELECT value FROM stats WHERE key = 'tokens_saved'",
497 [],
498 |row| row.get(0),
499 )
500 .unwrap_or(0);
501
502 let session_tokens_saved: i64 = conn
504 .query_row(
505 "SELECT value FROM session_stats WHERE session_id = ? AND key = 'tokens_saved'",
506 params![self.config.session_id],
507 |row| row.get(0),
508 )
509 .unwrap_or(0);
510
511 Ok(CacheStats {
512 files_tracked: files_tracked as usize,
513 tokens_saved: tokens_saved as u64,
514 session_tokens_saved: session_tokens_saved as u64,
515 })
516 }
517
518 pub async fn clear(&self) -> Result<(), Error> {
520 let conn = self.conn.lock().await;
521
522 conn.execute("DELETE FROM file_versions", [])?;
523 conn.execute("DELETE FROM session_reads", [])?;
524 conn.execute("DELETE FROM stats", [])?;
525 conn.execute("DELETE FROM session_stats", [])?;
526
527 conn.execute(
531 "INSERT OR IGNORE INTO stats (key, value) VALUES ('tokens_saved', 0)",
532 [],
533 )?;
534
535 info!("Cache cleared");
536 Ok(())
537 }
538
539 fn resolve_path(&self, path: &str) -> Result<PathBuf, Error> {
541 let p = PathBuf::from(path);
542 if p.is_absolute() {
543 Ok(p)
544 } else {
545 Ok(self.config.workdir.join(p))
546 }
547 }
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553 use std::fs;
554 use tempfile::TempDir;
555
556 fn create_test_config(temp_dir: &TempDir) -> CacheConfig {
557 CacheConfig {
558 db_path: temp_dir.path().join("test_cache.db"),
559 session_id: "test-session".to_string(),
560 workdir: temp_dir.path().to_path_buf(),
561 }
562 }
563
564 #[tokio::test]
565 async fn test_cache_new_and_init() {
566 let temp_dir = TempDir::new().unwrap();
567 let config = create_test_config(&temp_dir);
568 let store = CacheStore::new(config.clone()).unwrap();
569 store.init().await.unwrap();
570
571 assert!(config.db_path.exists());
573 }
574
575 #[tokio::test]
576 async fn test_read_file_first_time() {
577 let temp_dir = TempDir::new().unwrap();
578
579 let test_file = temp_dir.path().join("test.txt");
581 fs::write(&test_file, "line1\nline2\nline3\n").unwrap();
582
583 let config = create_test_config(&temp_dir);
584 let store = CacheStore::new(config).unwrap();
585 store.init().await.unwrap();
586
587 let result = store.read_file("test.txt", None, None, false).await.unwrap();
588
589 assert!(!result.cached);
590 assert!(result.hash.len() == 64); assert_eq!(result.total_lines, 3);
592 assert!(result.diff.is_none());
593 assert!(result.content.contains("line1"));
594 }
595
596 #[tokio::test]
597 async fn test_read_file_unchanged() {
598 let temp_dir = TempDir::new().unwrap();
599
600 let test_file = temp_dir.path().join("test.txt");
602 fs::write(&test_file, "line1\nline2\nline3\n").unwrap();
603
604 let config = create_test_config(&temp_dir);
605 let store = CacheStore::new(config).unwrap();
606 store.init().await.unwrap();
607
608 let result1 = store.read_file("test.txt", None, None, false).await.unwrap();
610 assert!(!result1.cached);
611
612 let result2 = store.read_file("test.txt", None, None, false).await.unwrap();
614 assert!(result2.cached);
615 assert!(result2.content.contains("unchanged"));
616 assert_eq!(result2.lines_changed, None); assert_eq!(result1.hash, result2.hash);
618 }
619
620 #[tokio::test]
621 async fn test_read_file_changed() {
622 let temp_dir = TempDir::new().unwrap();
623
624 let test_file = temp_dir.path().join("test.txt");
626 fs::write(&test_file, "line1\nline2\nline3\n").unwrap();
627
628 let config = create_test_config(&temp_dir);
629 let store = CacheStore::new(config).unwrap();
630 store.init().await.unwrap();
631
632 store.read_file("test.txt", None, None, false).await.unwrap();
634
635 fs::write(&test_file, "line1\nline2 modified\nline3\n").unwrap();
637
638 let result = store.read_file("test.txt", None, None, false).await.unwrap();
640 assert!(result.cached); assert!(result.diff.is_some());
642 assert!(result.lines_changed.unwrap() > 0);
643 }
644
645 #[tokio::test]
646 async fn test_multi_session_isolation() {
647 let temp_dir = TempDir::new().unwrap();
648 let test_file = temp_dir.path().join("test.txt");
649 fs::write(&test_file, "content").unwrap();
650
651 let db_path = temp_dir.path().join("test_cache.db");
652
653 let config1 = CacheConfig {
655 db_path: db_path.clone(),
656 session_id: "session-1".to_string(),
657 workdir: temp_dir.path().to_path_buf(),
658 };
659 let store1 = CacheStore::new(config1).unwrap();
660 store1.init().await.unwrap();
661
662 let _ = store1.read_file("test.txt", None, None, false).await.unwrap();
664
665 let config2 = CacheConfig {
667 db_path,
668 session_id: "session-2".to_string(),
669 workdir: temp_dir.path().to_path_buf(),
670 };
671 let store2 = CacheStore::new(config2).unwrap();
672 store2.init().await.unwrap();
673
674 let result2 = store2.read_file("test.txt", None, None, false).await.unwrap();
676 assert!(!result2.cached, "Session 2 first read should NOT be cached");
677
678 let result2b = store2.read_file("test.txt", None, None, false).await.unwrap();
680 assert!(result2b.cached, "Session 2 second read should be cached");
681 }
682
683 #[tokio::test]
684 async fn test_read_file_partial() {
685 let temp_dir = TempDir::new().unwrap();
686
687 let test_file = temp_dir.path().join("test.txt");
689 fs::write(&test_file, "line1\nline2\nline3\nline4\nline5\n").unwrap();
690
691 let config = create_test_config(&temp_dir);
692 let store = CacheStore::new(config).unwrap();
693 store.init().await.unwrap();
694
695 let result = store
697 .read_file("test.txt", Some(2), Some(2), false)
698 .await
699 .unwrap();
700
701 assert_eq!(result.content, "line2\nline3");
702 assert_eq!(result.total_lines, 5);
703 }
704
705 #[test]
706 fn test_token_estimation() {
707 assert_eq!(CacheStore::estimate_tokens(""), 0);
709 assert_eq!(CacheStore::estimate_tokens("a"), 1); assert_eq!(CacheStore::estimate_tokens("abcd"), 1); assert_eq!(CacheStore::estimate_tokens("abcde"), 2); assert_eq!(CacheStore::estimate_tokens(&"a".repeat(100)), 25); assert_eq!(CacheStore::estimate_tokens(&"a".repeat(1000)), 250); }
715
716 #[tokio::test]
717 async fn test_get_stats() {
718 let temp_dir = TempDir::new().unwrap();
719 let test_file = temp_dir.path().join("test.txt");
720 fs::write(&test_file, "test content").unwrap();
721
722 let config = create_test_config(&temp_dir);
723 let store = CacheStore::new(config).unwrap();
724 store.init().await.unwrap();
725
726 store.read_file("test.txt", None, None, false).await.unwrap();
728
729 store.read_file("test.txt", None, None, false).await.unwrap();
731
732 let stats = store.get_stats().await.unwrap();
733 assert!(stats.files_tracked > 0);
734 assert!(stats.session_tokens_saved > 0);
735 }
736
737 #[tokio::test]
738 async fn test_clear() {
739 let temp_dir = TempDir::new().unwrap();
740 let test_file = temp_dir.path().join("test.txt");
741 fs::write(&test_file, "test content").unwrap();
742
743 let config = create_test_config(&temp_dir);
744 let store = CacheStore::new(config).unwrap();
745 store.init().await.unwrap();
746
747 store.read_file("test.txt", None, None, false).await.unwrap();
749
750 store.clear().await.unwrap();
752
753 let stats = store.get_stats().await.unwrap();
755 assert_eq!(stats.files_tracked, 0);
756 }
757
758 #[tokio::test]
759 async fn test_force_read() {
760 let temp_dir = TempDir::new().unwrap();
761
762 let test_file = temp_dir.path().join("test.txt");
763 fs::write(&test_file, "original content").unwrap();
764
765 let config = create_test_config(&temp_dir);
766 let store = CacheStore::new(config).unwrap();
767 store.init().await.unwrap();
768
769 let result1 = store.read_file("test.txt", None, None, false).await.unwrap();
771 let hash1 = result1.hash.clone();
772
773 let result2 = store.read_file("test.txt", None, None, true).await.unwrap();
775
776 assert!(!result2.cached);
778 assert_eq!(hash1, result2.hash); }
780
781 #[test]
782 fn test_hash_uniqueness() {
783 let content1 = "hello world";
784 let content2 = "hello rust";
785
786 let hash1 = CacheStore::compute_hash(content1);
787 let hash2 = CacheStore::compute_hash(content2);
788
789 assert_ne!(hash1, hash2);
790 assert_eq!(hash1.len(), 64);
791 assert_eq!(hash2.len(), 64);
792 }
793}