lean_ctx/tools/
ctx_fill.rs1use std::path::Path;
2
3use crate::core::cache::SessionCache;
4use crate::core::signatures;
5use crate::core::tokens::count_tokens;
6use crate::tools::CrpMode;
7
8struct FileCandidate {
9 path: String,
10 score: f64,
11 tokens_full: usize,
12 tokens_map: usize,
13 tokens_sig: usize,
14}
15
16pub fn handle(
17 cache: &mut SessionCache,
18 paths: &[String],
19 budget: usize,
20 crp_mode: CrpMode,
21 task: Option<&str>,
22) -> String {
23 if paths.is_empty() {
24 return "No files specified.".to_string();
25 }
26
27 let mut candidates: Vec<FileCandidate> = Vec::new();
28
29 for path in paths {
30 let content = match std::fs::read_to_string(path) {
31 Ok(c) => c,
32 Err(_) => continue,
33 };
34
35 let ext = Path::new(path)
36 .extension()
37 .and_then(|e| e.to_str())
38 .unwrap_or("");
39 let tokens_full = count_tokens(&content);
40 let sigs = signatures::extract_signatures(&content, ext);
41 let sig_text: String = sigs
42 .iter()
43 .map(|s| s.to_compact())
44 .collect::<Vec<_>>()
45 .join("\n");
46 let tokens_sig = count_tokens(&sig_text);
47
48 let map_text = format_map(&content, ext, &sigs);
49 let tokens_map = count_tokens(&map_text);
50
51 let score = compute_relevance_score(path, &content);
52
53 candidates.push(FileCandidate {
54 path: path.clone(),
55 score,
56 tokens_full,
57 tokens_map,
58 tokens_sig,
59 });
60 }
61
62 candidates.sort_by(|a, b| {
63 b.score
64 .partial_cmp(&a.score)
65 .unwrap_or(std::cmp::Ordering::Equal)
66 });
67
68 let mut pop_lines: Vec<String> = Vec::new();
69 if let Some(t) = task {
70 if let Some(root) = paths
71 .first()
72 .and_then(|p| crate::core::protocol::detect_project_root(p))
73 {
74 let rs: Vec<crate::core::task_relevance::RelevanceScore> = candidates
75 .iter()
76 .map(|c| crate::core::task_relevance::RelevanceScore {
77 path: c.path.clone(),
78 score: c.score,
79 recommended_mode: "signatures",
80 })
81 .collect();
82 let refs: Vec<&crate::core::task_relevance::RelevanceScore> = rs.iter().collect();
83 let pop = crate::core::pop_pruning::decide_for_candidates(t, &root, &refs);
84 if !pop.excluded_modules.is_empty() {
85 let excluded: std::collections::BTreeSet<&str> = pop
86 .excluded_modules
87 .iter()
88 .map(|e| e.module.as_str())
89 .collect();
90 candidates.retain(|c| {
91 let m = crate::core::pop_pruning::module_for_path(&c.path, &root);
92 !excluded.contains(m.as_str())
93 });
94 pop_lines.push("POP:".to_string());
95 for ex in &pop.excluded_modules {
96 pop_lines.push(format!(
97 " - exclude {}/ ({} candidates) — {}",
98 ex.module, ex.candidate_files, ex.reason
99 ));
100 }
101 }
102 }
103 }
104
105 let mut used_tokens = 0usize;
106 let mut selections: Vec<(String, String)> = Vec::new();
107
108 for candidate in &candidates {
109 if used_tokens >= budget {
110 break;
111 }
112
113 let remaining = budget - used_tokens;
114 let (mode, cost) = select_best_fit(candidate, remaining);
115
116 if cost > remaining {
117 let sig_cost = candidate.tokens_sig;
118 if sig_cost <= remaining {
119 selections.push((candidate.path.clone(), "signatures".to_string()));
120 used_tokens += sig_cost;
121 }
122 continue;
123 }
124
125 selections.push((candidate.path.clone(), mode));
126 used_tokens += cost;
127 }
128
129 let mut output_parts = Vec::new();
130 output_parts.push(format!(
131 "ctx_fill: {budget} token budget, {} files analyzed, {} selected",
132 candidates.len(),
133 selections.len()
134 ));
135 if !pop_lines.is_empty() {
136 output_parts.push(pop_lines.join("\n"));
137 }
138 output_parts.push(String::new());
139
140 for (path, mode) in &selections {
141 let result = crate::tools::ctx_read::handle(cache, path, mode, crp_mode);
142 output_parts.push(result);
143 output_parts.push("---".to_string());
144 }
145
146 let skipped = candidates.len() - selections.len();
147 if skipped > 0 {
148 output_parts.push(format!("{skipped} files skipped (budget exhausted)"));
149 }
150 output_parts.push(format!("\nUsed: {used_tokens}/{budget} tokens"));
151
152 output_parts.join("\n")
153}
154
155fn select_best_fit(candidate: &FileCandidate, remaining: usize) -> (String, usize) {
156 if candidate.tokens_full <= remaining {
157 return ("full".to_string(), candidate.tokens_full);
158 }
159 if candidate.tokens_map <= remaining {
160 return ("map".to_string(), candidate.tokens_map);
161 }
162 if candidate.tokens_sig <= remaining {
163 return ("signatures".to_string(), candidate.tokens_sig);
164 }
165 ("signatures".to_string(), candidate.tokens_sig)
166}
167
168fn compute_relevance_score(path: &str, content: &str) -> f64 {
169 let mut score = 1.0;
170
171 let name = Path::new(path)
172 .file_name()
173 .and_then(|n| n.to_str())
174 .unwrap_or("");
175 if name.contains("test") || name.contains("spec") {
176 score *= 0.5;
177 }
178 if name.contains("config") || name.contains("types") || name.contains("schema") {
179 score *= 1.3;
180 }
181 if name == "mod.rs" || name == "index.ts" || name == "index.js" || name == "__init__.py" {
182 score *= 1.5;
183 }
184
185 let ext = Path::new(path)
186 .extension()
187 .and_then(|e| e.to_str())
188 .unwrap_or("");
189 if matches!(ext, "rs" | "ts" | "py" | "go" | "java") {
190 score *= 1.2;
191 }
192
193 let lines = content.lines().count();
194 if lines > 500 {
195 score *= 0.8;
196 }
197 if lines < 50 {
198 score *= 1.1;
199 }
200
201 let export_count = content
202 .lines()
203 .filter(|l| l.contains("pub ") || l.contains("export ") || l.contains("def "))
204 .count();
205 score *= 1.0 + (export_count as f64 * 0.02).min(0.5);
206
207 score
208}
209
210fn format_map(content: &str, ext: &str, sigs: &[crate::core::signatures::Signature]) -> String {
211 let deps = crate::core::deps::extract_deps(content, ext);
212 let mut parts = Vec::new();
213 if !deps.imports.is_empty() {
214 parts.push(format!("deps: {}", deps.imports.join(", ")));
215 }
216 if !deps.exports.is_empty() {
217 parts.push(format!("exports: {}", deps.exports.join(", ")));
218 }
219 let key_sigs: Vec<_> = sigs
220 .iter()
221 .filter(|s| s.is_exported || s.indent == 0)
222 .collect();
223 for sig in &key_sigs {
224 parts.push(sig.to_compact());
225 }
226 parts.join("\n")
227}