返回 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 #[cfg(test)]
65 pub(crate) fn clear() {
66 CACHE.with(|cache| {
67 let mut cache = cache.borrow_mut();
68 cache.by_key.clear();
69 cache.order.clear();
70 });
71 }
72
73 #[must_use]
74 pub(crate) fn compute_cache_key(workspace: &Path, home_dir: Option<&Path>) -> CacheKey {
75 let workspace = canonicalize_or_keep(workspace);
76 CacheKey {
77 signature: ContentSignature::for_loader(&workspace, home_dir),
78 workspace,
79 }
80 }
81
82 impl ContentSignature {
83 fn for_loader(workspace: &Path, home_dir: Option<&Path>) -> Self {
84 let mut entries: Vec<ContentEntry> =
85 crate::project_context::project_context_cache_candidate_paths(workspace, home_dir)
86 .into_iter()
87 .map(|path| ContentEntry {
88 fingerprint: file_fingerprint(&path),
89 path,
90 })
91 .collect();
92
93 entries.sort_by(|a, b| a.path.cmp(&b.path));
94 entries.dedup_by(|a, b| a.path == b.path);
95
96 Self { entries }
97 }
98 }
99
100 fn file_fingerprint(path: &Path) -> Option<String> {
101 let metadata = std::fs::metadata(path).ok()?;
102 if !metadata.is_file() {
103 return Some("non-file".to_string());
104 }
105
106 match std::fs::read(path) {
107 Ok(bytes) => {
108 let mut hasher = Sha256::new();
109 hasher.update(&bytes);
110 Some(format!("sha256:{}", to_hex(&hasher.finalize())))
111 }
112 Err(error) => {
113 let modified = metadata
114 .modified()
115 .ok()
116 .and_then(|mtime| mtime.duration_since(std::time::UNIX_EPOCH).ok())
117 .map(|duration| format!("{}:{}", duration.as_secs(), duration.subsec_nanos()))
118 .unwrap_or_else(|| "unknown".to_string());
119 Some(format!(
120 "unreadable:{}:{}:{error}",
121 metadata.len(),
122 modified
123 ))
124 }
125 }
126 }
127
128 fn canonicalize_or_keep(path: &Path) -> PathBuf {
129 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
130 }
131
132 fn to_hex(bytes: &[u8]) -> String {
133 let mut out = String::with_capacity(bytes.len() * 2);
134 for byte in bytes {
135 use std::fmt::Write as _;
136 let _ = write!(&mut out, "{byte:02x}");
137 }
138 out
139 }
140
141 #[cfg(test)]
142 mod tests {
143 use super::*;
144 use std::fs;
145 use tempfile::tempdir;
146
147 #[test]
148 fn cache_round_trip() {
149 clear();
150 let key = CacheKey {
151 workspace: PathBuf::from("/tmp/context-cache-round-trip"),
152 signature: ContentSignature::default(),
153 };
154 let ctx = ProjectContext::empty(PathBuf::from("/tmp/context-cache-round-trip"));
155
156 store(key.clone(), ctx.clone());
157
158 let got = lookup(&key).expect("cache hit");
159 assert_eq!(got.project_root, ctx.project_root);
160 }
161
162 #[test]
163 fn store_does_not_grow_unbounded() {
164 clear();
165 for i in 0..(DEFAULT_CAPACITY + 4) {
166 let key = CacheKey {
167 workspace: PathBuf::from(format!("/tmp/workspace-{i}")),
168 signature: ContentSignature::default(),
169 };
170 store(key, ProjectContext::empty(PathBuf::from("/tmp")));
171 }
172
173 let count = CACHE.with(|cache| cache.borrow().by_key.len());
174 assert!(count <= DEFAULT_CAPACITY, "cache held {count} entries");
175 }
176
177 #[test]
178 fn cache_key_canonicalizes_equivalent_workspace_paths() {
179 let workspace = tempdir().expect("workspace");
180 let home = tempdir().expect("home");
181 let plain = compute_cache_key(workspace.path(), Some(home.path()));
182 let dotted = compute_cache_key(&workspace.path().join("."), Some(home.path()));
183
184 assert_eq!(plain.workspace, dotted.workspace);
185 }
186
187 #[test]
188 fn signature_changes_when_agents_md_is_overwritten_same_length() {
189 let workspace = tempdir().expect("workspace");
190 let home = tempdir().expect("home");
191 fs::write(workspace.path().join("AGENTS.md"), "alpha").expect("write alpha");
192 let before = compute_cache_key(workspace.path(), Some(home.path()));
193
194 fs::write(workspace.path().join("AGENTS.md"), "bravo").expect("write bravo");
195 let after = compute_cache_key(workspace.path(), Some(home.path()));
196
197 assert_ne!(before, after);
198 }
199
200 #[test]
201 fn signature_changes_when_constitution_json_changes() {
202 let workspace = tempdir().expect("workspace");
203 let home = tempdir().expect("home");
204 fs::create_dir(workspace.path().join(".git")).expect("mkdir git");
205 fs::create_dir(workspace.path().join(".codewhale")).expect("mkdir codewhale");
206 let constitution = workspace
207 .path()
208 .join(".codewhale")
209 .join("constitution.json");
210 fs::write(&constitution, r#"{"schema_version":1,"authority":["a"]}"#)
211 .expect("write constitution a");
212 let before = compute_cache_key(workspace.path(), Some(home.path()));
213
214 fs::write(&constitution, r#"{"schema_version":1,"authority":["b"]}"#)
215 .expect("write constitution b");
216 let after = compute_cache_key(workspace.path(), Some(home.path()));
217
218 assert_ne!(before, after);
219 }
220
221 #[test]
222 fn signature_changes_when_rules_file_changes() {
223 let workspace = tempdir().expect("workspace");
224 let home = tempdir().expect("home");
225 let rules_dir = workspace.path().join(".codewhale/rules");
226 fs::create_dir_all(&rules_dir).expect("mkdir rules");
227 fs::write(rules_dir.join("rule.md"), "alpha").expect("write alpha");
228
229 let before = compute_cache_key(workspace.path(), Some(home.path()));
230
231 fs::write(rules_dir.join("rule.md"), "bravo").expect("write bravo");
232 let after = compute_cache_key(workspace.path(), Some(home.path()));
233
234 assert_ne!(
235 before, after,
236 "cache key must change when rules file changes"
237 );
238 }
239
240 #[test]
241 fn signature_changes_when_rules_file_is_added_or_removed() {
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
247 // No rules yet
248 let before = compute_cache_key(workspace.path(), Some(home.path()));
249
250 fs::write(rules_dir.join("new.md"), "content").expect("write new.md");
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 is added"
256 );
257 }
258 }
259
259 lines RUST