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