Skip to main content

lean_ctx/tools/
ctx_prefetch.rs

1use std::collections::{BTreeMap, BTreeSet, VecDeque};
2use std::path::Path;
3
4use crate::core::cache::SessionCache;
5use crate::core::graph_provider::{self, GraphProvider};
6use crate::core::protocol;
7use crate::core::task_relevance::{compute_relevance, parse_task_hints};
8use crate::tools::CrpMode;
9
10const DEFAULT_MAX_FILES: usize = 10;
11
12pub fn handle(
13    cache: &mut SessionCache,
14    root: &str,
15    task: Option<&str>,
16    changed_files: Option<&[String]>,
17    budget_tokens: usize,
18    max_files: Option<usize>,
19    crp_mode: CrpMode,
20) -> String {
21    let project_root = if root.trim().is_empty() { "." } else { root };
22    let open = graph_provider::open_or_build(project_root);
23    let gp = open.as_ref().map(|o| &o.provider);
24
25    let mut candidates: BTreeMap<String, f64> = BTreeMap::new();
26
27    if let Some(t) = task {
28        if let Some(gp) = gp {
29            let (task_files, task_keywords) = parse_task_hints(t);
30            let relevance = compute_relevance(gp, &task_files, &task_keywords);
31            for r in relevance.iter().take(50) {
32                if r.score < 0.1 {
33                    break;
34                }
35                candidates.insert(r.path.clone(), r.score);
36            }
37        }
38    }
39
40    if let Some(changed) = changed_files {
41        for p in changed {
42            let rel = normalize_rel_path(p, project_root);
43            if let Some(gp) = gp {
44                for (path, dist) in blast_radius(gp, &rel, 2) {
45                    let boost = 1.0 / (dist.max(1) as f64);
46                    candidates
47                        .entry(path)
48                        .and_modify(|s| *s = (*s + boost).min(1.0))
49                        .or_insert(boost.min(1.0));
50                }
51            } else {
52                candidates.entry(rel).or_insert(1.0);
53            }
54        }
55    }
56
57    if candidates.is_empty() {
58        return "ctx_prefetch: no candidates (provide task or changed_files)".to_string();
59    }
60
61    let mut scored: Vec<(String, f64)> = candidates.into_iter().collect();
62    scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
63
64    let max_files = max_files.unwrap_or(DEFAULT_MAX_FILES).max(1);
65    let mut picked: Vec<String> = Vec::new();
66    for (p, _s) in scored {
67        picked.push(p);
68        if picked.len() >= max_files {
69            break;
70        }
71    }
72
73    let mut total = 0usize;
74    let mut prefetched: Vec<(String, String)> = Vec::new(); // (path, mode)
75    let jail_root = Path::new(project_root);
76    for p in &picked {
77        let full = to_fs_path(project_root, p);
78        let Ok((jailed, warning)) = crate::core::io_boundary::jail_and_check_path(
79            "ctx_prefetch",
80            Path::new(&full),
81            jail_root,
82        ) else {
83            continue;
84        };
85        if warning.is_some() {
86            continue;
87        }
88        let jailed_s = jailed.to_string_lossy().to_string();
89
90        if crate::core::binary_detect::is_binary_file(&jailed_s) {
91            continue;
92        }
93        let cap = crate::core::limits::max_read_bytes() as u64;
94        if let Ok(meta) = std::fs::metadata(&jailed) {
95            if meta.len() > cap {
96                continue;
97            }
98        }
99
100        let Ok(content) = std::fs::read_to_string(&jailed) else {
101            continue;
102        };
103        let tokens = crate::core::tokens::count_tokens(&content);
104        total = total.saturating_add(tokens);
105
106        let mode = if crate::tools::ctx_read::is_instruction_file(&jailed_s) {
107            "full"
108        } else if budget_tokens > 0 {
109            let ratio = budget_tokens as f64 / total.max(1) as f64;
110            if ratio >= 0.8 {
111                "full"
112            } else if ratio >= 0.4 {
113                "map"
114            } else {
115                "signatures"
116            }
117        } else {
118            "signatures"
119        };
120
121        let _ = crate::tools::ctx_read::handle_with_task_resolved(
122            cache, &jailed_s, mode, crp_mode, task,
123        );
124        prefetched.push((jailed_s, mode.to_string()));
125    }
126
127    let mut lines = vec![
128        format!(
129            "ctx_prefetch: prefetched {} file(s) (max_files={})",
130            prefetched.len(),
131            max_files
132        ),
133        format!("  root: {}", project_root),
134    ];
135    for (p, mode) in prefetched.iter().take(20) {
136        let r = cache.get_file_ref(p);
137        let short = protocol::shorten_path(p);
138        lines.push(format!("  - [{r}] {short} mode={mode}"));
139    }
140    lines.join("\n")
141}
142
143fn blast_radius(gp: &GraphProvider, start_rel: &str, max_depth: usize) -> Vec<(String, usize)> {
144    let all_edges = gp.edges();
145    let mut adj: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
146    for e in &all_edges {
147        adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
148        adj.entry(e.to.as_str()).or_default().push(e.from.as_str());
149    }
150
151    let mut out = Vec::new();
152    let mut q: VecDeque<(String, usize)> = VecDeque::new();
153    let mut seen: BTreeSet<String> = BTreeSet::new();
154
155    q.push_back((start_rel.to_string(), 0));
156    seen.insert(start_rel.to_string());
157
158    while let Some((node, depth)) = q.pop_front() {
159        out.push((node.clone(), depth));
160        if depth >= max_depth {
161            continue;
162        }
163        if let Some(nbrs) = adj.get(node.as_str()) {
164            for &n in nbrs {
165                let ns = n.to_string();
166                if seen.insert(ns.clone()) {
167                    q.push_back((ns, depth + 1));
168                }
169            }
170        }
171    }
172    out
173}
174
175fn normalize_rel_path(path: &str, project_root: &str) -> String {
176    let p = Path::new(path);
177    if p.is_absolute() {
178        if let Ok(stripped) = p.strip_prefix(project_root) {
179            return stripped
180                .to_string_lossy()
181                .trim_start_matches('/')
182                .to_string();
183        }
184    }
185    path.trim_start_matches('/').to_string()
186}
187
188fn to_fs_path(project_root: &str, rel_or_abs: &str) -> String {
189    let p = Path::new(rel_or_abs);
190    if p.is_absolute() {
191        return rel_or_abs.to_string();
192    }
193    Path::new(project_root)
194        .join(rel_or_abs)
195        .to_string_lossy()
196        .to_string()
197}