Skip to main content

lean_ctx/tools/
ctx_fill.rs

1use 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}