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