1use std::path::{Path, PathBuf};
16
17use anyhow::Result;
18use serde::{Deserialize, Serialize};
19
20use crate::core::bm25_index::BM25Index;
21use crate::core::compressor::aggressive_compress;
22use crate::core::tokens::count_tokens;
23
24use super::sha256_hex;
25
26pub const DEFAULT_BUDGET_TOKENS: usize = 4000;
28
29const MAX_FILE_BYTES: u64 = 256 * 1024;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum Condition {
36 Baseline,
38 LeanCtx,
40 JsonCrush,
46 TabularCrush,
52 YamlCrush,
58}
59
60impl Condition {
61 pub fn label(self) -> &'static str {
63 match self {
64 Condition::Baseline => "baseline",
65 Condition::LeanCtx => "lean_ctx",
66 Condition::JsonCrush => "json_crush",
67 Condition::TabularCrush => "tabular_crush",
68 Condition::YamlCrush => "yaml_crush",
69 }
70 }
71}
72
73#[derive(Debug, Clone)]
75pub struct AssembledContext {
76 pub text: String,
78 pub tokens: usize,
80 pub files: usize,
82 pub digest: String,
84}
85
86pub fn assemble(
88 condition: Condition,
89 workspace: &Path,
90 query: &str,
91 budget: usize,
92) -> Result<AssembledContext> {
93 let entries = match condition {
94 Condition::Baseline => baseline_entries(workspace),
95 Condition::LeanCtx => lean_ctx_entries(workspace, query),
96 Condition::JsonCrush => json_crush_entries(workspace, query),
97 Condition::TabularCrush => tabular_crush_entries(workspace, query),
98 Condition::YamlCrush => yaml_crush_entries(workspace, query),
99 };
100 Ok(pack(&entries, budget))
101}
102
103fn baseline_entries(workspace: &Path) -> Vec<(String, String)> {
105 let mut files = gather_text_files(workspace);
106 files.sort_by(|a, b| a.0.cmp(&b.0));
107 files
108}
109
110fn lean_ctx_entries(workspace: &Path, query: &str) -> Vec<(String, String)> {
112 ranked_entries(workspace, query, |content, ext| {
113 aggressive_compress(content, ext)
114 })
115}
116
117fn json_crush_entries(workspace: &Path, query: &str) -> Vec<(String, String)> {
121 ranked_entries(workspace, query, |content, ext| match ext {
122 Some("json" | "jsonl") => crate::core::json_crush::crush_text_if_beneficial(content)
123 .unwrap_or_else(|| aggressive_compress(content, ext)),
124 _ => aggressive_compress(content, ext),
125 })
126}
127
128fn tabular_crush_entries(workspace: &Path, query: &str) -> Vec<(String, String)> {
132 ranked_entries(
133 workspace,
134 query,
135 |content, ext| match crate::core::compressor::tabular_delimiter(ext) {
136 Some(delim) => crate::core::tabular_crush::crush_text_if_beneficial(content, delim)
137 .unwrap_or_else(|| aggressive_compress(content, ext)),
138 None => aggressive_compress(content, ext),
139 },
140 )
141}
142
143fn yaml_crush_entries(workspace: &Path, query: &str) -> Vec<(String, String)> {
150 ranked_entries(workspace, query, |content, ext| {
151 if crate::core::compressor::is_yaml_ext(ext) {
152 crate::core::yaml_crush::crush_text_if_beneficial(content)
153 .unwrap_or_else(|| aggressive_compress(content, ext))
154 } else {
155 aggressive_compress(content, ext)
156 }
157 })
158}
159
160fn ranked_entries(
164 workspace: &Path,
165 query: &str,
166 render: impl Fn(&str, Option<&str>) -> String,
167) -> Vec<(String, String)> {
168 let index = BM25Index::build_from_directory(workspace);
169 let ranked = index.search(query, 256);
170 let mut out = Vec::new();
171 let mut seen = std::collections::HashSet::new();
172 for result in ranked {
173 if !seen.insert(result.file_path.clone()) {
174 continue;
175 }
176 let path = resolve(workspace, &result.file_path);
177 let Ok(content) = std::fs::read_to_string(&path) else {
178 continue;
179 };
180 let ext = path.extension().and_then(|e| e.to_str());
181 out.push((rel_label(workspace, &path), render(&content, ext)));
182 }
183 if out.is_empty() {
184 return baseline_entries(workspace);
185 }
186 out
187}
188
189fn resolve(root: &Path, file_path: &str) -> PathBuf {
191 let p = Path::new(file_path);
192 if p.is_absolute() {
193 p.to_path_buf()
194 } else {
195 root.join(p)
196 }
197}
198
199fn rel_label(root: &Path, path: &Path) -> String {
207 path.strip_prefix(root)
208 .unwrap_or(path)
209 .to_string_lossy()
210 .replace('\\', "/")
211}
212
213fn gather_text_files(root: &Path) -> Vec<(String, String)> {
216 let mut out = Vec::new();
217 let walker = ignore::WalkBuilder::new(root)
218 .hidden(true)
219 .git_ignore(true)
220 .require_git(false)
221 .filter_entry(crate::core::walk_filter::keep_entry)
222 .build();
223 for entry in walker.flatten() {
224 if !entry.file_type().is_some_and(|t| t.is_file()) {
225 continue;
226 }
227 let path = entry.path();
228 if entry.metadata().map_or(u64::MAX, |m| m.len()) > MAX_FILE_BYTES {
229 continue;
230 }
231 if let Ok(content) = std::fs::read_to_string(path) {
232 out.push((rel_label(root, path), content));
233 }
234 }
235 out
236}
237
238fn pack(entries: &[(String, String)], budget: usize) -> AssembledContext {
241 let mut text = String::new();
242 let mut running = 0usize;
243 let mut files = 0usize;
244 for (label, content) in entries {
245 let block = format!("// file: {label}\n{content}\n\n");
246 let cost = count_tokens(&block);
247 if running > 0 && running + cost > budget {
248 continue;
249 }
250 text.push_str(&block);
251 running += cost;
252 files += 1;
253 if running >= budget {
254 break;
255 }
256 }
257 let capped = truncate_to_tokens(&text, budget);
258 let tokens = count_tokens(&capped);
259 let digest = sha256_hex(capped.as_bytes());
260 AssembledContext {
261 text: capped,
262 tokens,
263 files,
264 digest,
265 }
266}
267
268fn truncate_to_tokens(text: &str, budget: usize) -> String {
270 if count_tokens(text) <= budget {
271 return text.to_string();
272 }
273 let chars: Vec<char> = text.chars().collect();
274 let (mut lo, mut hi) = (0usize, chars.len());
275 while lo < hi {
276 let mid = lo + (hi - lo).div_ceil(2);
277 let candidate: String = chars[..mid].iter().collect();
278 if count_tokens(&candidate) <= budget {
279 lo = mid;
280 } else {
281 hi = mid - 1;
282 }
283 }
284 chars[..lo].iter().collect()
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use std::fs;
291
292 fn workspace() -> tempfile::TempDir {
293 let dir = tempfile::tempdir().unwrap();
294 fs::write(
295 dir.path().join("relevant.md"),
296 "The consolidation pipeline persists to bm25, graph, knowledge and session stores.",
297 )
298 .unwrap();
299 fs::write(
300 dir.path().join("noise.md"),
301 "Lorem ipsum dolor sit amet, totally unrelated filler content about cats and weather.",
302 )
303 .unwrap();
304 dir
305 }
306
307 #[test]
308 fn conditions_produce_distinct_digests() {
309 let ws = workspace();
310 let a = assemble(Condition::Baseline, ws.path(), "consolidation stores", 4000).unwrap();
311 let b = assemble(Condition::LeanCtx, ws.path(), "consolidation stores", 4000).unwrap();
312 assert!(a.tokens > 0 && b.tokens > 0);
313 assert_ne!(a.digest, b.digest, "raw vs compressed must differ");
314 }
315
316 #[test]
317 fn assembly_is_deterministic() {
318 let ws = workspace();
319 let first = assemble(Condition::LeanCtx, ws.path(), "consolidation", 4000).unwrap();
320 let second = assemble(Condition::LeanCtx, ws.path(), "consolidation", 4000).unwrap();
321 assert_eq!(first.digest, second.digest);
322 }
323
324 #[test]
325 fn json_crush_condition_beats_baseline_and_is_deterministic() {
326 let dir = tempfile::tempdir().unwrap();
327 let rows: Vec<String> = (0..30)
328 .map(|i| {
329 format!(
330 "{{\"id\":{i},\"role\":\"operator\",\"status\":\"active\",\"region\":\"emea\"}}"
331 )
332 })
333 .collect();
334 fs::write(
335 dir.path().join("roster.json"),
336 format!("[{}]", rows.join(",")),
337 )
338 .unwrap();
339
340 let crushed = assemble(Condition::JsonCrush, dir.path(), "roster operator", 4000).unwrap();
341 let baseline = assemble(Condition::Baseline, dir.path(), "roster operator", 4000).unwrap();
342 assert!(
343 crushed.tokens < baseline.tokens,
344 "crush {} must beat baseline {}",
345 crushed.tokens,
346 baseline.tokens
347 );
348
349 let again = assemble(Condition::JsonCrush, dir.path(), "roster operator", 4000).unwrap();
350 assert_eq!(
351 crushed.digest, again.digest,
352 "json_crush assembly is deterministic"
353 );
354 }
355
356 #[test]
357 fn tabular_crush_condition_beats_baseline_and_is_deterministic() {
358 let dir = tempfile::tempdir().unwrap();
359 let mut csv = String::from("id,name,status,region,tier\n");
360 for i in 0..40 {
361 csv.push_str(&format!("{i},user{i},active,eu-central-1,standard\n"));
362 }
363 fs::write(dir.path().join("roster.csv"), csv).unwrap();
364
365 let crushed = assemble(Condition::TabularCrush, dir.path(), "roster status", 4000).unwrap();
366 let baseline = assemble(Condition::Baseline, dir.path(), "roster status", 4000).unwrap();
367 assert!(
368 crushed.tokens < baseline.tokens,
369 "tabular crush {} must beat baseline {}",
370 crushed.tokens,
371 baseline.tokens
372 );
373
374 let again = assemble(Condition::TabularCrush, dir.path(), "roster status", 4000).unwrap();
375 assert_eq!(
376 crushed.digest, again.digest,
377 "tabular_crush assembly is deterministic"
378 );
379 }
380
381 #[test]
382 fn yaml_crush_condition_beats_baseline_and_is_deterministic() {
383 let dir = tempfile::tempdir().unwrap();
384 let mut yaml = String::from("items:\n");
385 for i in 0..40 {
386 yaml.push_str(&format!(
387 " - apiVersion: v1\n kind: Pod\n namespace: prod\n name: pod-{i}\n"
388 ));
389 }
390 fs::write(dir.path().join("pods.yaml"), yaml).unwrap();
391
392 let crushed = assemble(Condition::YamlCrush, dir.path(), "pod namespace", 4000).unwrap();
393 let baseline = assemble(Condition::Baseline, dir.path(), "pod namespace", 4000).unwrap();
394 assert!(
395 crushed.tokens < baseline.tokens,
396 "yaml crush {} must beat baseline {}",
397 crushed.tokens,
398 baseline.tokens
399 );
400
401 let again = assemble(Condition::YamlCrush, dir.path(), "pod namespace", 4000).unwrap();
402 assert_eq!(
403 crushed.digest, again.digest,
404 "yaml_crush assembly is deterministic"
405 );
406 }
407
408 #[test]
409 fn budget_is_respected() {
410 let ws = workspace();
411 let ctx = assemble(Condition::Baseline, ws.path(), "x", 12).unwrap();
412 assert!(ctx.tokens <= 12, "got {} tokens", ctx.tokens);
413 }
414
415 #[test]
416 fn truncate_caps_tokens() {
417 let long = "word ".repeat(5000);
418 let capped = truncate_to_tokens(&long, 50);
419 assert!(count_tokens(&capped) <= 50);
420 }
421}