1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5use std::sync::{Mutex, OnceLock};
6
7use crate::core::ocla::cache_types::{CacheKeyBuilder, ComposedContextKey};
8
9#[derive(Clone, Debug)]
10struct ComposeRecord {
11 corpus_revision: String,
12 source_paths: Vec<PathBuf>,
13 source_digests: Vec<String>,
14 text: String,
15}
16
17#[derive(Default)]
20pub struct ComposeSectionCache {
21 records: Mutex<BTreeMap<(String, String), ComposeRecord>>,
22}
23
24impl ComposeSectionCache {
25 pub fn check(&self, task: &str, path: &str) -> Option<String> {
26 if crate::core::policy::runtime::is_active() {
28 return None;
29 }
30 if crate::core::workspace_authority::is_configured() {
33 return None;
34 }
35 let key = (task.trim().to_string(), path.to_string());
36 let record = self.records.lock().ok()?.get(&key)?.clone();
37 if source_revision(path)? != record.corpus_revision {
38 return None;
39 }
40 let source_digests = source_digests(path, &record.source_paths)?;
41 let builder = ComposedContextKey {
42 task: key.0,
43 path: key.1,
44 source_digests,
45 };
46 (builder.source_digests == record.source_digests).then_some(record.text)
47 }
48
49 pub fn record(&self, task: &str, path: &str, text: String, corpus_revision: &str) -> bool {
50 if source_revision(path).as_deref() != Some(corpus_revision) {
51 return false;
52 }
53 let source_paths = source_paths(path, &text);
54 let Some(source_digests) = source_digests(path, &source_paths) else {
55 return false;
56 };
57 if source_revision(path).as_deref() != Some(corpus_revision) {
58 return false;
59 }
60 let builder = ComposedContextKey {
61 task: task.trim().to_string(),
62 path: path.to_string(),
63 source_digests: source_digests.clone(),
64 };
65 let _cache_key = builder.cache_key();
66 let key = (builder.task, builder.path);
67 if let Ok(mut records) = self.records.lock() {
68 records.insert(
69 key,
70 ComposeRecord {
71 corpus_revision: corpus_revision.to_string(),
72 source_paths,
73 source_digests,
74 text,
75 },
76 );
77 return true;
78 }
79 false
80 }
81}
82
83pub fn source_revision(path: &str) -> Option<String> {
86 if crate::core::policy::runtime::is_active() {
87 return None;
88 }
89 let role = crate::core::roles::active_role();
90 let allow_secret = role.io.allow_secret_paths;
91 let role_fingerprint = crate::core::policy::runtime::role_fingerprint(&role).ok()?;
92 crate::core::search_index::corpus_signature(path, true, allow_secret).map(|revision| {
93 format!(
94 "{revision}:role={role_fingerprint}:dense={}",
95 dense_stamp(Path::new(path))
96 )
97 })
98}
99
100fn dense_stamp(root: &Path) -> String {
105 #[cfg(feature = "embeddings")]
106 {
107 let model = crate::core::embeddings::model_registry::resolve_model();
108 let vectors = crate::core::index_namespace::vectors_dir(root).join("embeddings.bin");
109 let file = std::fs::metadata(vectors).ok().map_or_else(
110 || "none".to_string(),
111 |meta| {
112 let mtime = meta
113 .modified()
114 .ok()
115 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
116 .map_or(0, |d| d.as_nanos());
117 format!("{}@{mtime}", meta.len())
118 },
119 );
120 format!("{model:?}/{file}")
121 }
122 #[cfg(not(feature = "embeddings"))]
123 {
124 let _ = root;
125 "off".to_string()
126 }
127}
128
129pub fn global() -> &'static ComposeSectionCache {
130 static CACHE: OnceLock<ComposeSectionCache> = OnceLock::new();
131 CACHE.get_or_init(ComposeSectionCache::default)
132}
133
134fn source_paths(project_root: &str, text: &str) -> Vec<PathBuf> {
135 let root = Path::new(project_root);
136 let mut paths = text
137 .lines()
138 .filter_map(|line| line.trim().strip_prefix("File: "))
139 .filter_map(|raw| raw.split_whitespace().next())
140 .map(|raw| {
141 let path = PathBuf::from(raw);
142 if path.is_absolute() {
143 path
144 } else {
145 root.join(path)
146 }
147 })
148 .filter(|path| path.is_file())
149 .collect::<Vec<_>>();
150 paths.sort();
151 paths.dedup();
152 paths
153}
154
155fn source_digests(root: &str, paths: &[PathBuf]) -> Option<Vec<String>> {
156 let mut digests = paths
157 .iter()
158 .map(|path| {
159 crate::core::rooted_read::read_file_for_tool_rooted(
160 &path.to_string_lossy(),
161 root,
162 "ctx_compose",
163 )
164 .ok()
165 .map(|content| blake3::hash(content.as_bytes()).to_hex().to_string())
166 })
167 .collect::<Option<Vec<_>>>()?;
168 digests.sort();
169 Some(digests)
170}
171
172#[cfg(test)]
173mod tests {
174 use super::ComposeSectionCache;
175
176 #[test]
177 fn section_cache_hits_only_while_all_sources_match() {
178 let _policy = crate::core::policy::runtime::TestPolicyOverride::set(None);
179 let dir = tempfile::tempdir().unwrap();
180 let first = dir.path().join("first.rs");
181 let second = dir.path().join("second.rs");
182 std::fs::write(&first, "one").unwrap();
183 std::fs::write(&second, "two").unwrap();
184 let root = dir.path().to_string_lossy();
185 let text = "File: first.rs\nbody one\nFile: second.rs\nbody two".to_string();
186 let cache = ComposeSectionCache::default();
187 let revision = super::source_revision(&root).unwrap();
188 assert!(cache.record("task", &root, text.clone(), &revision));
189 assert_eq!(cache.check("task", &root), Some(text));
190 std::fs::write(&second, "changed").unwrap();
191 assert_eq!(cache.check("task", &root), None);
192 }
193
194 #[test]
195 fn section_cache_rejects_corpus_changes_without_file_headers() {
196 let _policy = crate::core::policy::runtime::TestPolicyOverride::set(None);
197 let dir = tempfile::tempdir().unwrap();
198 let source = dir.path().join("source.rs");
199 std::fs::write(&source, "fn before() {}\n").unwrap();
200 let root = dir.path().to_string_lossy();
201 let cache = ComposeSectionCache::default();
202 let revision = super::source_revision(&root).unwrap();
203 assert!(cache.record("task", &root, "old derived result".to_string(), &revision));
204 assert_eq!(
205 cache.check("task", &root).as_deref(),
206 Some("old derived result")
207 );
208 std::fs::write(&source, "fn after_policy_removal() {}\n").unwrap();
209 assert_eq!(cache.check("task", &root), None);
210 assert!(!cache.record("task", &root, "old derived result".to_string(), &revision));
211 }
212
213 #[cfg(feature = "embeddings")]
216 #[test]
217 fn section_cache_rejects_a_rebuilt_dense_index() {
218 let _policy = crate::core::policy::runtime::TestPolicyOverride::set(None);
219 let _lock = crate::core::data_dir::test_env_lock();
220 let data = tempfile::tempdir().unwrap();
221 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
222 let root = tempfile::tempdir().unwrap();
223 std::fs::write(root.path().join("source.rs"), "fn sample() {}\n").unwrap();
224 let path = root.path().to_str().unwrap();
225 let cache = ComposeSectionCache::default();
226 let revision = super::source_revision(path).unwrap();
227 assert!(cache.record("task", path, "derived text".into(), &revision));
228 assert_eq!(cache.check("task", path).as_deref(), Some("derived text"));
229
230 let vectors = crate::core::index_namespace::vectors_dir(root.path());
231 std::fs::create_dir_all(&vectors).unwrap();
232 std::fs::write(vectors.join("embeddings.bin"), b"vectors").unwrap();
233 let after = cache.check("task", path);
234 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
235 assert_eq!(after, None);
236 }
237
238 #[test]
239 fn section_cache_rejects_a_changed_role_even_with_same_secret_visibility() {
240 let _policy = crate::core::policy::runtime::TestPolicyOverride::set(None);
241 let root = tempfile::tempdir().unwrap();
242 std::fs::write(root.path().join("source.rs"), "fn sample() {}\n").unwrap();
243 let path = root.path().to_str().unwrap();
244 let role = crate::core::roles::load_role("coder").unwrap();
245 crate::core::roles::with_test_active_role(role.clone(), || {
246 let cache = ComposeSectionCache::default();
247 let revision = super::source_revision(path).unwrap();
248 assert!(cache.record("task", path, "derived text".into(), &revision));
249 assert_eq!(cache.check("task", path).as_deref(), Some("derived text"));
250 let mut changed = role;
251 changed.tools.denied.push("ctx_symbol".into());
252 crate::core::roles::set_test_active_role(changed);
253 assert_eq!(cache.check("task", path), None);
254 assert!(!cache.record("task", path, "derived text".into(), &revision));
255 });
256 }
257}