1use std::collections::{BTreeMap, BTreeSet, VecDeque};
3use std::path::Path;
4
5use crate::tools::CrpMode;
6use lean_ctx::core::cache::SessionCache;
7use lean_ctx::core::graph_provider::{self, GraphProvider};
8use lean_ctx::core::protocol;
9use lean_ctx::core::task_relevance::{compute_relevance, parse_task_hints};
10
11const DEFAULT_MAX_FILES: usize = 10;
12
13pub fn handle(
14 cache: &mut SessionCache,
15 root: &str,
16 task: Option<&str>,
17 changed_files: Option<&[String]>,
18 budget_tokens: usize,
19 max_files: Option<usize>,
20 crp_mode: CrpMode,
21) -> String {
22 let project_root = if root.trim().is_empty() { "." } else { root };
23 let open = graph_provider::open_or_build(project_root);
24 let gp = open.as_ref().map(|o| &o.provider);
25
26 let mut candidates: BTreeMap<String, f64> = BTreeMap::new();
27
28 if let Some(t) = task
29 && let Some(gp) = gp
30 {
31 let (task_files, task_keywords) = parse_task_hints(t);
32 let mut relevance = compute_relevance(gp, &task_files, &task_keywords);
33 lean_ctx::core::git_signals::apply_boost(&mut relevance, project_root);
34 lean_ctx::core::diagnostics_store::apply_boost(&mut relevance);
35 lean_ctx::core::editor_signal::apply_boost(&mut relevance);
36 for r in relevance.iter().take(50) {
37 if r.score < 0.1 {
38 break;
39 }
40 candidates.insert(r.path.clone(), r.score);
41 }
42 }
43
44 if let Some(changed) = changed_files {
45 for p in changed {
46 let rel = normalize_rel_path(p, project_root);
47 if let Some(gp) = gp {
48 for (path, dist) in blast_radius(gp, &rel, 2) {
49 let boost = 1.0 / (dist.max(1) as f64);
50 candidates
51 .entry(path)
52 .and_modify(|s| *s = (*s + boost).min(1.0))
53 .or_insert(boost.min(1.0));
54 }
55 } else {
56 candidates.entry(rel).or_insert(1.0);
57 }
58 }
59 }
60
61 if candidates.is_empty() {
62 return "ctx_prefetch: no candidates (provide task or changed_files)".to_string();
63 }
64
65 let mut scored: Vec<(String, f64)> = candidates.into_iter().collect();
66 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
67
68 let max_files = max_files.unwrap_or(DEFAULT_MAX_FILES).max(1);
69 let mut picked: Vec<String> = Vec::new();
70 for (p, _s) in scored {
71 picked.push(p);
72 if picked.len() >= max_files {
73 break;
74 }
75 }
76
77 let mut total = 0usize;
78 let mut prefetched: Vec<(String, String)> = Vec::new(); let mut withheld: Vec<String> = Vec::new();
80 let mut remaining = lean_ctx::core::limits::max_read_bytes().saturating_mul(max_files);
81 let jail_root = Path::new(project_root);
82 for p in &picked {
83 let full = to_fs_path(project_root, p);
84 let Ok((jailed, warning)) = lean_ctx::core::io_boundary::jail_and_check_path(
85 "ctx_prefetch",
86 Path::new(&full),
87 jail_root,
88 ) else {
89 continue;
90 };
91 if warning.is_some() {
92 continue;
93 }
94 let jailed_s = jailed.to_string_lossy().to_string();
95
96 if lean_ctx::core::binary_detect::is_binary_file(&jailed_s) {
97 continue;
98 }
99 let cap = lean_ctx::core::limits::max_read_bytes() as u64;
100 if let Ok(meta) = std::fs::metadata(&jailed)
101 && meta.len() > cap
102 {
103 continue;
104 }
105
106 let acquired = crate::tools::admitted_sources::read(
112 &jailed_s,
113 project_root,
114 "ctx_prefetch",
115 &mut remaining,
116 );
117 if let crate::tools::admitted_sources::SourceRead::Withheld(reason) = &acquired {
118 withheld.push(crate::tools::admitted_sources::withheld_line(
119 &protocol::shorten_path(&jailed_s),
120 reason,
121 ));
122 let _ = cache.invalidate(&jailed_s);
123 continue;
124 }
125 let Some(content) = acquired.storable() else {
126 let _ = cache.invalidate(&jailed_s);
127 continue;
128 };
129 let tokens = lean_ctx::core::tokens::count_tokens(content);
130 total = total.saturating_add(tokens);
131
132 let mode = if crate::tools::ctx_read::is_instruction_file(&jailed_s) {
133 "full"
134 } else if budget_tokens > 0 {
135 let ratio = budget_tokens as f64 / total.max(1) as f64;
136 if ratio >= 0.8 {
137 "full"
138 } else if ratio >= 0.4 {
139 "map"
140 } else {
141 "signatures"
142 }
143 } else {
144 "signatures"
145 };
146
147 let _ = crate::tools::admitted_sources::as_agent_acquisition(project_root, || {
150 crate::tools::ctx_read::handle_with_task_resolved(
151 cache, &jailed_s, mode, crp_mode, task,
152 )
153 });
154 prefetched.push((jailed_s, mode.to_string()));
155 }
156
157 let mut lines = vec![
158 format!(
159 "ctx_prefetch: prefetched {} file(s) (max_files={})",
160 prefetched.len(),
161 max_files
162 ),
163 format!(" root: {}", project_root),
164 ];
165 for (p, mode) in prefetched.iter().take(20) {
166 let r = cache.get_file_ref(p);
167 let short = protocol::shorten_path(p);
168 lines.push(format!(" - [{r}] {short} mode={mode}"));
169 }
170 lines.extend(withheld.into_iter().map(|line| format!(" - {line}")));
171 lines.join("\n")
172}
173
174fn blast_radius(gp: &GraphProvider, start_rel: &str, max_depth: usize) -> Vec<(String, usize)> {
175 let all_edges = gp.edges();
176 let mut adj: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
177 for e in &all_edges {
178 adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
179 adj.entry(e.to.as_str()).or_default().push(e.from.as_str());
180 }
181
182 let mut out = Vec::new();
183 let mut q: VecDeque<(String, usize)> = VecDeque::new();
184 let mut seen: BTreeSet<String> = BTreeSet::new();
185
186 q.push_back((start_rel.to_string(), 0));
187 seen.insert(start_rel.to_string());
188
189 while let Some((node, depth)) = q.pop_front() {
190 out.push((node.clone(), depth));
191 if depth >= max_depth {
192 continue;
193 }
194 if let Some(nbrs) = adj.get(node.as_str()) {
195 for &n in nbrs {
196 let ns = n.to_string();
197 if seen.insert(ns.clone()) {
198 q.push_back((ns, depth + 1));
199 }
200 }
201 }
202 }
203 out
204}
205
206fn normalize_rel_path(path: &str, project_root: &str) -> String {
207 let p = Path::new(path);
208 if p.is_absolute()
209 && let Ok(stripped) = p.strip_prefix(project_root)
210 {
211 return stripped
212 .to_string_lossy()
213 .trim_start_matches('/')
214 .to_string();
215 }
216 path.trim_start_matches('/').to_string()
217}
218
219fn to_fs_path(project_root: &str, rel_or_abs: &str) -> String {
220 let p = Path::new(rel_or_abs);
221 if p.is_absolute() {
222 return rel_or_abs.to_string();
223 }
224 Path::new(project_root)
225 .join(rel_or_abs)
226 .to_string_lossy()
227 .to_string()
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 const AWS_KEY: &str = concat!("AK", "IAIOSFODNN7EXAMPLE");
235
236 fn fixture() -> (tempfile::TempDir, String) {
237 let project = tempfile::tempdir().unwrap();
238 let root = std::fs::canonicalize(project.path())
239 .unwrap()
240 .to_string_lossy()
241 .into_owned();
242 (project, root)
243 }
244
245 fn cached(cache: &SessionCache, root: &str, name: &str) -> Option<String> {
246 cache.get_full_content(&Path::new(root).join(name).to_string_lossy())
247 }
248
249 #[test]
253 fn prefetch_caches_admitted_storable_sources_only() {
254 let _data = lean_ctx::core::data_dir::isolated_data_dir();
255 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
256 let (_project, root) = fixture();
257 std::fs::write(Path::new(&root).join("clean.rs"), "pub fn clean() {}\n").unwrap();
258 std::fs::write(
259 Path::new(&root).join("keyed.rs"),
260 format!("pub fn connect() {{\n let aws_key = \"{AWS_KEY}\";\n}}\n"),
261 )
262 .unwrap();
263 std::fs::write(
264 Path::new(&root).join("marked.rs"),
265 "// TOP SECRET\npub fn plan() {}\n",
266 )
267 .unwrap();
268 let changed = vec![
269 "clean.rs".to_string(),
270 "keyed.rs".to_string(),
271 "marked.rs".to_string(),
272 ];
273 let mut cache = SessionCache::new();
274 let out = handle(
275 &mut cache,
276 &root,
277 None,
278 Some(&changed),
279 0,
280 None,
281 CrpMode::Off,
282 );
283 assert!(
284 out.contains("clean.rs") && out.contains("keyed.rs"),
285 "{out}"
286 );
287 assert!(!out.contains("marked.rs"), "{out}");
288 assert!(
289 cached(&cache, &root, "keyed.rs").is_none_or(|text| !text.contains(AWS_KEY)),
290 "the session cache holds a raw key"
291 );
292 assert!(cached(&cache, &root, "marked.rs").is_none());
293 }
294
295 #[test]
296 fn a_withheld_candidate_is_named_and_not_cached() {
297 let _data = lean_ctx::core::data_dir::isolated_data_dir();
298 let policy = lean_ctx::core::policy::load(
299 "name = \"ctx-prefetch-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
300 [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
301 )
302 .unwrap();
303 let _policy = lean_ctx::core::policy::runtime::TestPolicyOverride::set(Some(policy));
304 let (_project, root) = fixture();
305 std::fs::write(
306 Path::new(&root).join("plan.rs"),
307 "CONFIDENTIAL\npub fn hidden_marker() {}\n",
308 )
309 .unwrap();
310 let changed = vec!["plan.rs".to_string()];
311 let mut cache = SessionCache::new();
312 let out = handle(
313 &mut cache,
314 &root,
315 None,
316 Some(&changed),
317 0,
318 None,
319 CrpMode::Off,
320 );
321 assert!(out.contains("prefetched 0 file(s)"), "{out}");
322 assert!(out.contains("[withheld] "), "{out}");
323 assert!(!out.contains("hidden_marker"), "{out}");
324 assert!(cached(&cache, &root, "plan.rs").is_none());
325 }
326}