| 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 |