返回 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 fn file_fingerprint(path: &Path) -> Option<String> {
105 let metadata = std::fs::metadata(path).ok()?;
106 if !metadata.is_file() {
107 return Some("non-file".to_string());
108 }
109
110 match std::fs::read(path) {
111 Ok(bytes) => {
112 let mut hasher = Sha256::new();
113 hasher.update(&bytes);
114 Some(format!("sha256:{}", to_hex(&hasher.finalize())))
115 }
116 Err(error) => {
117 let modified = metadata
118 .modified()
119 .ok()
120 .and_then(|mtime| mtime.duration_since(std::time::UNIX_EPOCH).ok())
121 .map(|duration| format!("{}:{}", duration.as_secs(), duration.subsec_nanos()))
122 .unwrap_or_else(|| "unknown".to_string());
123 Some(format!(
124 "unreadable:{}:{}:{error}",
125 metadata.len(),
126 modified
127 ))
128 }
129 }
130 }
131
132 fn canonicalize_or_keep(path: &Path) -> PathBuf {
133 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
134 }
135
136 fn to_hex(bytes: &[u8]) -> String {
137 let mut out = String::with_capacity(bytes.len() * 2);
138 for byte in bytes {
139 use std::fmt::Write as _;
140 let _ = write!(&mut out, "{byte:02x}");
141 }
142 out
143 }
144
145 #[cfg(test)]
146 mod tests {
147 use super::*;
148 use std::fs;
149 use tempfile::tempdir;
150
151 #[test]
152 fn cache_round_trip() {
153 clear();
154 let key = CacheKey {
155 workspace: PathBuf::from("/tmp/context-cache-round-trip"),
156 signature: ContentSignature::default(),
157 };
158 let ctx = ProjectContext::empty(PathBuf::from("/tmp/context-cache-round-trip"));
159
160 store(key.clone(), ctx.clone());
161
162 let got = lookup(&key).expect("cache hit");
163 assert_eq!(got.project_root, ctx.project_root);
164 }
165
166 #[test]
167 fn store_does_not_grow_unbounded() {
168 clear();
169 for i in 0..(DEFAULT_CAPACITY + 4) {
170 let key = CacheKey {
171 workspace: PathBuf::from(format!("/tmp/workspace-{i}")),
172 signature: ContentSignature::default(),
173 };
174 store(key, ProjectContext::empty(PathBuf::from("/tmp")));
175 }
176
177 let count = CACHE.with(|cache| cache.borrow().by_key.len());
178 assert!(count <= DEFAULT_CAPACITY, "cache held {count} entries");
179 }
180
181 #[test]
182 fn cache_key_canonicalizes_equivalent_workspace_paths() {
183 let workspace = tempdir().expect("workspace");
184 let home = tempdir().expect("home");
185 let plain = compute_cache_key(workspace.path(), Some(home.path()));
186 let dotted = compute_cache_key(&workspace.path().join("."), Some(home.path()));
187
188 assert_eq!(plain.workspace, dotted.workspace);
189 }
190
191 #[test]
192 fn signature_changes_when_agents_md_is_overwritten_same_length() {
193 let workspace = tempdir().expect("workspace");
194 let home = tempdir().expect("home");
195 fs::write(workspace.path().join("AGENTS.md"), "alpha").expect("write alpha");
196 let before = compute_cache_key(workspace.path(), Some(home.path()));
197
198 fs::write(workspace.path().join("AGENTS.md"), "bravo").expect("write bravo");
199 let after = compute_cache_key(workspace.path(), Some(home.path()));
200
201 assert_ne!(before, after);
202 }
203
204 #[test]
205 fn signature_changes_when_constitution_json_changes() {
206 let workspace = tempdir().expect("workspace");
207 let home = tempdir().expect("home");
208 fs::create_dir(workspace.path().join(".git")).expect("mkdir git");
209 fs::create_dir(workspace.path().join(".codewhale")).expect("mkdir codewhale");
210 let constitution = workspace
211 .path()
212 .join(".codewhale")
213 .join("constitution.json");
214 fs::write(&constitution, r#"{"schema_version":1,"authority":["a"]}"#)
215 .expect("write constitution a");
216 let before = compute_cache_key(workspace.path(), Some(home.path()));
217
218 fs::write(&constitution, r#"{"schema_version":1,"authority":["b"]}"#)
219 .expect("write constitution b");
220 let after = compute_cache_key(workspace.path(), Some(home.path()));
221
222 assert_ne!(before, after);
223 }
224
225 #[test]
226 fn signature_changes_when_rules_file_changes() {
227 let workspace = tempdir().expect("workspace");
228 let home = tempdir().expect("home");
229 let rules_dir = workspace.path().join(".codewhale/rules");
230 fs::create_dir_all(&rules_dir).expect("mkdir rules");
231 fs::write(rules_dir.join("rule.md"), "alpha").expect("write alpha");
232
233 let before = compute_cache_key(workspace.path(), Some(home.path()));
234
235 fs::write(rules_dir.join("rule.md"), "bravo").expect("write bravo");
236 let after = compute_cache_key(workspace.path(), Some(home.path()));
237
238 assert_ne!(
239 before, after,
240 "cache key must change when rules file changes"
241 );
242 }
243
244 #[test]
245 fn signature_changes_when_rules_file_is_added_or_removed() {
246 let workspace = tempdir().expect("workspace");
247 let home = tempdir().expect("home");
248 let rules_dir = workspace.path().join(".codewhale/rules");
249 fs::create_dir_all(&rules_dir).expect("mkdir rules");
250
251 // No rules yet
252 let before = compute_cache_key(workspace.path(), Some(home.path()));
253
254 fs::write(rules_dir.join("new.md"), "content").expect("write new.md");
255 let after = compute_cache_key(workspace.path(), Some(home.path()));
256
257 assert_ne!(
258 before, after,
259 "cache key must change when rules file is added"
260 );
261 }
262
263 #[test]
264 fn signature_tracks_foreign_fragment_add_change_and_remove() {
265 let workspace = tempdir().expect("workspace");
266 let home = tempdir().expect("home");
267 let cursor_rules = workspace.path().join(".cursor/rules");
268 fs::create_dir_all(cursor_rules.join("nested")).expect("mkdir cursor rules");
269
270 let before = compute_cache_key(workspace.path(), Some(home.path()));
271
272 // Files the bounded fragment loader cannot select must not churn the
273 // project-context cache.
274 fs::write(cursor_rules.join("settings.json"), "{}").expect("write ignored settings");
275 let ignored = compute_cache_key(workspace.path(), Some(home.path()));
276 assert_eq!(before, ignored, "non-Markdown fragment files are ignored");
277
278 let rule = cursor_rules.join("nested/law.md");
279 fs::write(&rule, "alpha").expect("write cursor rule");
280 let added = compute_cache_key(workspace.path(), Some(home.path()));
281 assert_ne!(ignored, added, "adding a loadable fragment must invalidate");
282
283 fs::write(&rule, "bravo").expect("change cursor rule");
284 let changed = compute_cache_key(workspace.path(), Some(home.path()));
285 assert_ne!(added, changed, "changing a fragment must invalidate");
286
287 fs::remove_file(&rule).expect("remove cursor rule");
288 let removed = compute_cache_key(workspace.path(), Some(home.path()));
289 assert_eq!(
290 ignored, removed,
291 "removing the fragment restores the prior key"
292 );
293 }
294
295 #[cfg(unix)]
296 #[test]
297 fn signature_does_not_follow_symlinked_foreign_fragment_directories() {
298 use std::os::unix::fs::symlink;
299
300 let workspace = tempdir().expect("workspace");
301 let home = tempdir().expect("home");
302 let outside = tempdir().expect("outside");
303 fs::write(outside.path().join("law.md"), "outside law").expect("write outside rule");
304 fs::create_dir_all(workspace.path().join(".cursor")).expect("mkdir cursor");
305
306 let before = compute_cache_key(workspace.path(), Some(home.path()));
307 symlink(outside.path(), workspace.path().join(".cursor/rules"))
308 .expect("symlink outside rules");
309 let after = compute_cache_key(workspace.path(), Some(home.path()));
310
311 assert_eq!(
312 before, after,
313 "cache fingerprinting must not read through a rejected fragment-directory symlink"
314 );
315 }
316 }
317
317 lines RUST