返回 CodeWhale
project_context_cache.rs
根目录 / crates / tui / src / project_context_cache.rs
1 //! Process-local cache for project context loading.
2 //!
3 //! The project-context loader sits on prompt/session hot paths and repeatedly
4 //! checks the same workspace, parent, global, constitution, and trust files.
5 //! This cache avoids rereading unchanged context while keeping the signature
6 //! broad enough for the loader's side effects and authority surfaces.
7
8 use std::cell::RefCell;
9 use std::collections::{HashMap, VecDeque};
10 use std::path::{Path, PathBuf};
11
12 use sha2::{Digest, Sha256};
13
14 use crate::project_context::ProjectContext;
15
16 const DEFAULT_CAPACITY: usize = 8;
17
18 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
19 pub(crate) struct CacheKey {
20 workspace: PathBuf,
21 signature: ContentSignature,
22 }
23
24 #[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
25 struct ContentSignature {
26 entries: Vec<ContentEntry>,
27 }
28
29 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
30 struct ContentEntry {
31 path: PathBuf,
32 fingerprint: Option<String>,
33 }
34
35 #[derive(Debug, Default)]
36 struct WorkspaceCache {
37 by_key: HashMap<CacheKey, ProjectContext>,
38 order: VecDeque<CacheKey>,
39 }
40
41 thread_local! {
42 static CACHE: RefCell<WorkspaceCache> = RefCell::new(WorkspaceCache::default());
43 }
44
45 pub(crate) fn lookup(key: &CacheKey) -> Option<ProjectContext> {
46 CACHE.with(|cache| cache.borrow().by_key.get(key).cloned())
47 }
48
49 pub(crate) fn store(key: CacheKey, value: ProjectContext) {
50 CACHE.with(|cache| {
51 let mut cache = cache.borrow_mut();
52 if cache.by_key.insert(key.clone(), value).is_none() {
53 cache.order.push_back(key);
54 }
55 while cache.by_key.len() > DEFAULT_CAPACITY {
56 let Some(oldest) = cache.order.pop_front() else {
57 break;
58 };
59 cache.by_key.remove(&oldest);
60 }
61 });
62 }
63
64 /// Drop every cached entry.
65 ///
66 /// Used by tests, and by `set_foreign_instruction_imports`: changing which
67 /// foreign instruction formats are imported changes what the loader would
68 /// return for an otherwise-unchanged workspace, so the cache cannot survive it.
69 pub(crate) fn clear() {
70 CACHE.with(|cache| {
71 let mut cache = cache.borrow_mut();
72 cache.by_key.clear();
73 cache.order.clear();
74 });
75 }
76
77 #[must_use]
78 pub(crate) fn compute_cache_key(workspace: &Path, home_dir: Option<&Path>) -> CacheKey {
79 let workspace = canonicalize_or_keep(workspace);
80 CacheKey {
81 signature: ContentSignature::for_loader(&workspace, home_dir),
82 workspace,
83 }
84 }
85
86 impl ContentSignature {
87 fn for_loader(workspace: &Path, home_dir: Option<&Path>) -> Self {
88 let mut entries: Vec<ContentEntry> =
89 crate::project_context::project_context_cache_candidate_paths(workspace, home_dir)
90 .into_iter()
91 .map(|path| ContentEntry {
92 fingerprint: file_fingerprint(&path),
93 path,
94 })
95 .collect();
96
97 entries.sort_by(|a, b| a.path.cmp(&b.path));
98 entries.dedup_by(|a, b| a.path == b.path);
99
100 Self { entries }
101 }
102 }
103
104 /// Largest candidate hashed by content. The context loader refuses files
105 /// over 100 KiB whatever they hold, so a larger candidate is fingerprinted by
106 /// length and mtime instead of being read whole on every prompt refresh.
107 const FINGERPRINT_MAX_READ_BYTES: u64 = 1024 * 1024;
108
109 fn file_fingerprint(path: &Path) -> Option<String> {
110 let metadata = std::fs::metadata(path).ok()?;
111 if !metadata.is_file() {
112 return Some("non-file".to_string());
113 }
114 let modified = || {
115 metadata
116 .modified()
117 .ok()
118 .and_then(|mtime| mtime.duration_since(std::time::UNIX_EPOCH).ok())
119 .map(|duration| format!("{}:{}", duration.as_secs(), duration.subsec_nanos()))
120 .unwrap_or_else(|| "unknown".to_string())
121 };
122 if metadata.len() > FINGERPRINT_MAX_READ_BYTES {
123 return Some(format!("oversize:{}:{}", metadata.len(), modified()));
124 }
125
126 let read = std::fs::File::open(path).and_then(|file| {
127 use std::io::Read as _;
128 let mut bytes = Vec::new();
129 file.take(FINGERPRINT_MAX_READ_BYTES + 1)
130 .read_to_end(&mut bytes)
131 .map(|_| bytes)
132 });
133 match read {
134 Ok(bytes) => {
135 let mut hasher = Sha256::new();
136 hasher.update(&bytes);
137 Some(format!("sha256:{}", to_hex(&hasher.finalize())))
138 }
139 Err(error) => Some(format!(
140 "unreadable:{}:{}:{error}",
141 metadata.len(),
142 modified()
143 )),
144 }
145 }
146
147 fn canonicalize_or_keep(path: &Path) -> PathBuf {
148 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
149 }
150
151 fn to_hex(bytes: &[u8]) -> String {
152 let mut out = String::with_capacity(bytes.len() * 2);
153 for byte in bytes {
154 use std::fmt::Write as _;
155 let _ = write!(&mut out, "{byte:02x}");
156 }
157 out
158 }
159
160 #[cfg(test)]
161 mod tests {
162 use super::*;
163 use std::fs;
164 use tempfile::tempdir;
165
166 #[test]
167 fn cache_round_trip() {
168 clear();
169 let key = CacheKey {
170 workspace: PathBuf::from("/tmp/context-cache-round-trip"),
171 signature: ContentSignature::default(),
172 };
173 let ctx = ProjectContext::empty(PathBuf::from("/tmp/context-cache-round-trip"));
174
175 store(key.clone(), ctx.clone());
176
177 let got = lookup(&key).expect("cache hit");
178 assert_eq!(got.project_root, ctx.project_root);
179 }
180
181 #[test]
182 fn store_does_not_grow_unbounded() {
183 clear();
184 for i in 0..(DEFAULT_CAPACITY + 4) {
185 let key = CacheKey {
186 workspace: PathBuf::from(format!("/tmp/workspace-{i}")),
187 signature: ContentSignature::default(),
188 };
189 store(key, ProjectContext::empty(PathBuf::from("/tmp")));
190 }
191
192 let count = CACHE.with(|cache| cache.borrow().by_key.len());
193 assert!(count <= DEFAULT_CAPACITY, "cache held {count} entries");
194 }
195
196 #[test]
197 fn cache_key_canonicalizes_equivalent_workspace_paths() {
198 let workspace = tempdir().expect("workspace");
199 let home = tempdir().expect("home");
200 let plain = compute_cache_key(workspace.path(), Some(home.path()));
201 let dotted = compute_cache_key(&workspace.path().join("."), Some(home.path()));
202
203 assert_eq!(plain.workspace, dotted.workspace);
204 }
205
206 #[test]
207 fn signature_changes_when_agents_md_is_overwritten_same_length() {
208 let workspace = tempdir().expect("workspace");
209 let home = tempdir().expect("home");
210 fs::write(workspace.path().join("AGENTS.md"), "alpha").expect("write alpha");
211 let before = compute_cache_key(workspace.path(), Some(home.path()));
212
213 fs::write(workspace.path().join("AGENTS.md"), "bravo").expect("write bravo");
214 let after = compute_cache_key(workspace.path(), Some(home.path()));
215
216 assert_ne!(before, after);
217 }
218
219 #[test]
220 fn signature_changes_when_constitution_json_changes() {
221 let workspace = tempdir().expect("workspace");
222 let home = tempdir().expect("home");
223 fs::create_dir(workspace.path().join(".git")).expect("mkdir git");
224 fs::create_dir(workspace.path().join(".codewhale")).expect("mkdir codewhale");
225 let constitution = workspace
226 .path()
227 .join(".codewhale")
228 .join("constitution.json");
229 fs::write(&constitution, r#"{"schema_version":1,"authority":["a"]}"#)
230 .expect("write constitution a");
231 let before = compute_cache_key(workspace.path(), Some(home.path()));
232
233 fs::write(&constitution, r#"{"schema_version":1,"authority":["b"]}"#)
234 .expect("write constitution b");
235 let after = compute_cache_key(workspace.path(), Some(home.path()));
236
237 assert_ne!(before, after);
238 }
239
240 #[test]
241 fn signature_changes_when_rules_file_changes() {
242 let workspace = tempdir().expect("workspace");
243 let home = tempdir().expect("home");
244 let rules_dir = workspace.path().join(".codewhale/rules");
245 fs::create_dir_all(&rules_dir).expect("mkdir rules");
246 fs::write(rules_dir.join("rule.md"), "alpha").expect("write alpha");
247
248 let before = compute_cache_key(workspace.path(), Some(home.path()));
249
250 fs::write(rules_dir.join("rule.md"), "bravo").expect("write bravo");
251 let after = compute_cache_key(workspace.path(), Some(home.path()));
252
253 assert_ne!(
254 before, after,
255 "cache key must change when rules file changes"
256 );
257 }
258
259 #[test]
260 fn signature_changes_when_rules_file_is_added_or_removed() {
261 let workspace = tempdir().expect("workspace");
262 let home = tempdir().expect("home");
263 let rules_dir = workspace.path().join(".codewhale/rules");
264 fs::create_dir_all(&rules_dir).expect("mkdir rules");
265
266 // No rules yet
267 let before = compute_cache_key(workspace.path(), Some(home.path()));
268
269 fs::write(rules_dir.join("new.md"), "content").expect("write new.md");
270 let after = compute_cache_key(workspace.path(), Some(home.path()));
271
272 assert_ne!(
273 before, after,
274 "cache key must change when rules file is added"
275 );
276 }
277
278 #[test]
279 fn signature_tracks_foreign_fragment_add_change_and_remove() {
280 let workspace = tempdir().expect("workspace");
281 let home = tempdir().expect("home");
282 let cursor_rules = workspace.path().join(".cursor/rules");
283 fs::create_dir_all(cursor_rules.join("nested")).expect("mkdir cursor rules");
284
285 let before = compute_cache_key(workspace.path(), Some(home.path()));
286
287 // Files the bounded fragment loader cannot select must not churn the
288 // project-context cache.
289 fs::write(cursor_rules.join("settings.json"), "{}").expect("write ignored settings");
290 let ignored = compute_cache_key(workspace.path(), Some(home.path()));
291 assert_eq!(before, ignored, "non-Markdown fragment files are ignored");
292
293 let rule = cursor_rules.join("nested/law.md");
294 fs::write(&rule, "alpha").expect("write cursor rule");
295 let added = compute_cache_key(workspace.path(), Some(home.path()));
296 assert_ne!(ignored, added, "adding a loadable fragment must invalidate");
297
298 fs::write(&rule, "bravo").expect("change cursor rule");
299 let changed = compute_cache_key(workspace.path(), Some(home.path()));
300 assert_ne!(added, changed, "changing a fragment must invalidate");
301
302 fs::remove_file(&rule).expect("remove cursor rule");
303 let removed = compute_cache_key(workspace.path(), Some(home.path()));
304 assert_eq!(
305 ignored, removed,
306 "removing the fragment restores the prior key"
307 );
308 }
309
310 #[cfg(unix)]
311 #[test]
312 fn signature_does_not_follow_symlinked_foreign_fragment_directories() {
313 use std::os::unix::fs::symlink;
314
315 let workspace = tempdir().expect("workspace");
316 let home = tempdir().expect("home");
317 let outside = tempdir().expect("outside");
318 fs::write(outside.path().join("law.md"), "outside law").expect("write outside rule");
319 fs::create_dir_all(workspace.path().join(".cursor")).expect("mkdir cursor");
320
321 let before = compute_cache_key(workspace.path(), Some(home.path()));
322 symlink(outside.path(), workspace.path().join(".cursor/rules"))
323 .expect("symlink outside rules");
324 let after = compute_cache_key(workspace.path(), Some(home.path()));
325
326 assert_eq!(
327 before, after,
328 "cache fingerprinting must not read through a rejected fragment-directory symlink"
329 );
330 }
331 }
332
332 lines RUST