Skip to main content

spire_ai/filecache/
mod.rs

1//! File cache — tracks file content changes via hashing.
2//!
3//! Returns diffs instead of full content on subsequent reads,
4//! reducing token consumption for AI agents.
5
6pub mod diff;
7pub mod types;
8
9use std::sync::atomic::{AtomicUsize, Ordering};
10
11use kovan_map::HashMap;
12
13use crate::error::Result;
14pub use types::{CacheStats, ReadResult};
15
16#[derive(Clone)]
17#[allow(dead_code)]
18struct CachedFile {
19    content: String,
20    content_hash: u64,
21    lines: usize,
22    tokens_estimated: usize,
23}
24
25/// A cache that tracks file content and returns diffs on re-reads.
26///
27/// Uses kovan-map's lock-free HashMap — safe for concurrent access
28/// from multiple async tasks.
29pub struct FileCache {
30    files: HashMap<u64, CachedFile>,
31    total_tokens_saved: AtomicUsize,
32}
33
34fn hash(s: &str) -> u64 {
35    ahash::RandomState::with_seeds(0, 0, 0, 0).hash_one(s)
36}
37
38fn estimate_tokens(s: &str) -> usize {
39    (s.len() as f64 * 0.75).ceil() as usize
40}
41
42impl FileCache {
43    /// Create a new file cache.
44    pub fn new() -> Self {
45        Self {
46            files: HashMap::new(),
47            total_tokens_saved: AtomicUsize::new(0),
48        }
49    }
50
51    /// Read a file, returning a cache-aware result.
52    ///
53    /// - First read: returns full content (`Fresh`).
54    /// - Subsequent read, unchanged: returns `Unchanged`.
55    /// - Subsequent read, modified: returns a unified diff (`Modified`).
56    pub fn read_file(&self, path: &str) -> Result<ReadResult> {
57        let content = std::fs::read_to_string(path)?;
58        self.process(path, content)
59    }
60
61    /// Read a range of lines from a file.
62    ///
63    /// `offset` is 0-based line index, `limit` is number of lines.
64    /// Caching still operates on the full file content.
65    pub fn read_file_range(&self, path: &str, offset: usize, limit: usize) -> Result<ReadResult> {
66        let full_content = std::fs::read_to_string(path)?;
67        let sliced: String = full_content
68            .lines()
69            .skip(offset)
70            .take(limit)
71            .collect::<Vec<_>>()
72            .join("\n");
73
74        let lines = sliced.lines().count();
75        let tokens = estimate_tokens(&sliced);
76
77        // Still update the full-file cache
78        let path_hash = hash(path);
79        let content_hash = hash(&full_content);
80        let full_lines = full_content.lines().count();
81        let full_tokens = estimate_tokens(&full_content);
82
83        self.files.insert(
84            path_hash,
85            CachedFile {
86                content: full_content,
87                content_hash,
88                lines: full_lines,
89                tokens_estimated: full_tokens,
90            },
91        );
92
93        Ok(ReadResult::Fresh {
94            content: sliced,
95            lines,
96            tokens_estimated: tokens,
97        })
98    }
99
100    /// Get cache statistics.
101    pub fn stats(&self) -> CacheStats {
102        CacheStats {
103            files_tracked: self.files.len(),
104            tokens_saved: self.total_tokens_saved.load(Ordering::Relaxed),
105        }
106    }
107
108    /// Clear the entire cache.
109    pub fn clear(&self) {
110        self.files.clear();
111        self.total_tokens_saved.store(0, Ordering::Relaxed);
112    }
113
114    /// Invalidate a single file entry.
115    pub fn invalidate(&self, path: &str) {
116        let path_hash = hash(path);
117        self.files.remove(&path_hash);
118    }
119
120    fn process(&self, path: &str, content: String) -> Result<ReadResult> {
121        let path_hash = hash(path);
122        let content_hash = hash(&content);
123        let lines = content.lines().count();
124        let tokens = estimate_tokens(&content);
125
126        if let Some(cached) = self.files.get(&path_hash) {
127            if cached.content_hash == content_hash {
128                // Unchanged
129                self.total_tokens_saved.fetch_add(tokens, Ordering::Relaxed);
130                return Ok(ReadResult::Unchanged {
131                    path: path.to_string(),
132                    lines: cached.lines,
133                    tokens_saved: tokens,
134                });
135            }
136
137            // Modified — produce diff
138            let (diff_text, lines_changed) = diff::unified_diff(path, &cached.content, &content);
139            let diff_tokens = estimate_tokens(&diff_text);
140            let saved = tokens.saturating_sub(diff_tokens);
141            self.total_tokens_saved.fetch_add(saved, Ordering::Relaxed);
142
143            // Update cache
144            self.files.insert(
145                path_hash,
146                CachedFile {
147                    content,
148                    content_hash,
149                    lines,
150                    tokens_estimated: tokens,
151                },
152            );
153
154            return Ok(ReadResult::Modified {
155                diff: diff_text,
156                lines_changed,
157                tokens_saved: saved,
158            });
159        }
160
161        // Fresh — store and return full content
162        self.files.insert(
163            path_hash,
164            CachedFile {
165                content: content.clone(),
166                content_hash,
167                lines,
168                tokens_estimated: tokens,
169            },
170        );
171
172        Ok(ReadResult::Fresh {
173            content,
174            lines,
175            tokens_estimated: tokens,
176        })
177    }
178}
179
180impl Default for FileCache {
181    fn default() -> Self {
182        Self::new()
183    }
184}