spire_ai/filecache/
mod.rs1pub 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
25pub 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 pub fn new() -> Self {
45 Self {
46 files: HashMap::new(),
47 total_tokens_saved: AtomicUsize::new(0),
48 }
49 }
50
51 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 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 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 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 pub fn clear(&self) {
110 self.files.clear();
111 self.total_tokens_saved.store(0, Ordering::Relaxed);
112 }
113
114 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 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 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 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 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}