Skip to main content

cached_context/
cache.rs

1//! Cache module - provides file caching with hash-based change detection
2//!
3//! This implementation follows the TypeScript reference:
4//! - First read in a session: returns full content, caches it
5//! - Second read (unchanged): returns "[unchanged, X lines, Y tokens saved]"
6//! - File changed: returns diff showing what changed
7//! - Multi-session: each session tracks independently
8
9use 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
18/// CacheStore provides file caching with SHA-256 hash-based change detection
19pub struct CacheStore {
20    config: CacheConfig,
21    conn: Mutex<Connection>,
22}
23
24impl CacheStore {
25    /// Create a new cache store
26    pub fn new(config: CacheConfig) -> Result<Self, Error> {
27        // Ensure the parent directory exists
28        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    /// Initialize the database schema
41    pub async fn init(&self) -> Result<(), Error> {
42        let conn = self.conn.lock().await;
43
44        // Enable WAL mode for better concurrent read/write performance.
45        // WAL allows readers and writers to operate simultaneously without blocking.
46        conn.execute_batch(
47            "PRAGMA journal_mode=WAL;
48             PRAGMA synchronous=NORMAL;",
49        )?;
50
51        // Create file_versions table - stores all versions of files
52        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        // Create session_reads table - tracks what each session last saw
65        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        // Create stats table - global statistics
77        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        // Create session_stats table - per-session statistics
86        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        // Initialize stats if not exists.
97        // Note: files_tracked is now computed via COUNT(DISTINCT path) in get_stats(),
98        // so we no longer maintain a counter for it.
99        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    /// Compute SHA-256 hash of file content.
109    ///
110    /// Note: The original TypeScript implementation truncates to 16 hex characters
111    /// (`digest("hex").slice(0, 16)`), i.e. 8 bytes. This Rust version keeps the
112    /// full 64 hex characters (32 bytes) for stronger collision resistance, at the
113    /// cost of ~4x more storage per hash in the database.
114    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    /// Estimate tokens from character count.
121    ///
122    /// Uses ~4 characters per token, which matches the average for GPT-4 and Claude
123    /// tokenizers on typical source code. Ceiling division ensures non-empty inputs
124    /// always count as at least 1 token.
125    ///
126    /// The previous formula (chars * 0.75) overstated token counts by ~3x.
127    pub fn estimate_tokens(text: &str) -> u64 {
128        (text.len().div_ceil(4)) as u64
129    }
130
131    /// Increment a global stat counter
132    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    /// Increment a session-specific stat counter
142    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    /// Slice pre-collected lines based on a 1-based range.
157    /// Accepts the already-collected `&[&str]` to avoid re-splitting content.
158    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    /// Read a file with caching
169    ///
170    /// Follows the TypeScript algorithm:
171    /// 1. Check if this session has read this file before (session_reads table)
172    /// 2. If not: return full content, cache it
173    /// 3. If yes and hash matches: return "[unchanged]"
174    /// 4. If yes and hash differs: compute diff, return diff
175    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        // Resolve the file path
183        let full_path = self.resolve_path(path)?;
184
185        // Check if file exists
186        if !full_path.exists() {
187            return Err(Error::FileNotFound(path.to_string()));
188        }
189
190        // --- All file I/O and CPU work happens BEFORE acquiring the lock ---
191        let content = tokio::fs::read_to_string(&full_path).await?;
192        let hash = Self::compute_hash(&content);
193        // Collect lines once; reuse for counting, slicing, and token estimation
194        // instead of calling content.lines().collect() repeatedly in each branch.
195        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        // Pre-calculate partial read parameters for use in all cases
206        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        // Check what this session last saw for this file
211        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        // Check if we have the content for that hash
220        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        // Determine result based on session state
233        let result = match (force, session_last_hash.as_ref()) {
234            (true, _) | (false, None) => {
235                // Case 1: First read in this session OR force=true
236                debug!(
237                    "First read for session {}: {}",
238                    self.config.session_id, path
239                );
240
241                // Store file version if not exists (content-addressed, so safe to insert duplicate)
242                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                // Update session read pointer
249                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                // Handle partial reads for first read.
256                // For non-partial reads, move `content` to avoid a full-string clone.
257                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                // Case 2: File unchanged from last session read
274                debug!("Cache hit for session {}: {}", self.config.session_id, path);
275
276                // Update session read timestamp
277                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                // Calculate tokens saved based on what the user actually requested.
283                // For partial reads, only count tokens for the sliced range (matching TS behavior).
284                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                // Update stats
291                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                // Differentiate the message for partial vs full reads (matching TS behavior)
300                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, // None indicates unchanged (for MCP compatibility)
318                    diff: None,
319                }
320            }
321            (false, Some(_)) => {
322                // Case 3: File changed from last session read
323                debug!(
324                    "Cache miss (changed) for session {}: {}",
325                    self.config.session_id, path
326                );
327
328                if let Some((old_content, _)) = cached_content {
329                    // Old version found - compute diff
330                    let diff_result = compute_diff(&old_content, &content, path);
331
332                    // Check if any changed lines are within the requested range
333                    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                        // Changes are outside the requested range - we can return "unchanged in range"
341                        debug!(
342                            "Changes outside range {}-{} for session {}: {}",
343                            range_start, range_end, self.config.session_id, path
344                        );
345
346                        // Update session read pointer to new hash
347                        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                        // Calculate tokens saved for the partial range (reuse pre-collected lines)
354                        let partial_content = Self::slice_lines(&lines, range_start, range_end);
355                        let tokens = Self::estimate_tokens(&partial_content);
356
357                        // Update stats
358                        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                        // Changes are in range (or full read) - store new version and return diff
379
380                        // Store new version. Borrow &content for the DB insert to avoid cloning.
381                        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                        // Update session read pointer to new hash
388                        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                        // Calculate tokens saved (diff is usually smaller than full content)
395                        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                            // Update stats
401                            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                        // For partial reads with changes in range, return just the requested content.
411                        // Reuse pre-collected lines instead of re-splitting content.
412                        let result_content = if is_partial {
413                            Self::slice_lines(&lines, range_start, range_end)
414                        } else {
415                            // Full read with changes - return diff with header
416                            format!(
417                                "[cached-context: {} lines changed out of {}]\n{}",
418                                diff_result.lines_changed, total_lines, diff_result.diff
419                            )
420                        };
421
422                        // For partial reads with changes in range, mark as not cached
423                        // since we're returning the actual content they requested
424                        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                    // Old version not found in file_versions - return content as non-cached
435                    // (matching TS behavior: fall through to returning content with cached: false)
436                    debug!(
437                        "Old version not found for session {}: {}",
438                        self.config.session_id, path
439                    );
440
441                    // Store new version
442                    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                    // Update session read pointer to new hash
449                    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        // Note: Partial reads are now handled within each case above
474        // Case 1: Sliced in the result construction
475        // Case 2: Returns "unchanged" message (no slicing needed)
476        // Case 3: Sliced when changes are in range, or "unchanged in range" message when outside
477        Ok(result)
478    }
479
480    /// Get cache statistics
481    pub async fn get_stats(&self) -> Result<CacheStats, Error> {
482        let conn = self.conn.lock().await;
483
484        // Count distinct tracked files directly from file_versions (matching TS behavior).
485        // This is always accurate, unlike an incrementing counter that could drift.
486        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        // Get session stats
503        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    /// Clear the cache
519    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        // Re-initialize stats.
528        // Note: files_tracked is computed via COUNT(DISTINCT path), so clearing
529        // file_versions above already resets it to 0.
530        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    /// Resolve a path relative to workdir or use as absolute
540    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        // Check that database file was created
572        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        // Create a test file
580        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); // SHA-256 hex is 64 chars
591        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        // Create a test file
601        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        // First read
609        let result1 = store.read_file("test.txt", None, None, false).await.unwrap();
610        assert!(!result1.cached);
611
612        // Second read - should be cached
613        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); // None indicates unchanged
617        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        // Create a test file
625        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        // First read
633        store.read_file("test.txt", None, None, false).await.unwrap();
634
635        // Modify the file
636        fs::write(&test_file, "line1\nline2 modified\nline3\n").unwrap();
637
638        // Read again - should detect change
639        let result = store.read_file("test.txt", None, None, false).await.unwrap();
640        assert!(result.cached); // cached = true because we have previous content to diff against
641        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        // Session 1
654        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        // Session 1 reads and caches
663        let _ = store1.read_file("test.txt", None, None, false).await.unwrap();
664
665        // Session 2
666        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        // Session 2 first read should NOT be cached
675        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        // Session 2 second read should be cached
679        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        // Create a test file
688        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        // First read with offset and limit
696        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        // Test the token estimation formula: ceil(chars / 4)
708        assert_eq!(CacheStore::estimate_tokens(""), 0);
709        assert_eq!(CacheStore::estimate_tokens("a"), 1);    // ceil(1/4) = 1
710        assert_eq!(CacheStore::estimate_tokens("abcd"), 1); // ceil(4/4) = 1
711        assert_eq!(CacheStore::estimate_tokens("abcde"), 2); // ceil(5/4) = 2
712        assert_eq!(CacheStore::estimate_tokens(&"a".repeat(100)), 25);  // ceil(100/4) = 25
713        assert_eq!(CacheStore::estimate_tokens(&"a".repeat(1000)), 250); // ceil(1000/4) = 250
714    }
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        // Read a file
727        store.read_file("test.txt", None, None, false).await.unwrap();
728
729        // Second read generates token savings
730        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        // Read a file
748        store.read_file("test.txt", None, None, false).await.unwrap();
749
750        // Clear cache
751        store.clear().await.unwrap();
752
753        // Stats should be reset
754        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        // First read
770        let result1 = store.read_file("test.txt", None, None, false).await.unwrap();
771        let hash1 = result1.hash.clone();
772
773        // Second read with force=true - should re-read and return full content
774        let result2 = store.read_file("test.txt", None, None, true).await.unwrap();
775
776        // With force=true, we should get a fresh read
777        assert!(!result2.cached);
778        assert_eq!(hash1, result2.hash); // Hash should match since content didn't change
779    }
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}