1use std::collections::{BTreeMap, BTreeSet, HashSet};
28use std::path::Path;
29
30use crate::core::bm25_index::{BM25Index, ChunkKind, SearchResult};
31use crate::core::context_packing::{CoverageItem, greedy_max_coverage};
32use crate::core::graph_provider;
33use crate::core::task_relevance::parse_task_hints;
34use crate::core::tokens::count_tokens;
35use crate::tools::CrpMode;
36
37const DEFAULT_MAX_TURNS: usize = 3;
38const DEFAULT_PER_TURN_K: usize = 8;
39const DEFAULT_BUDGET_TOKENS: usize = 1200;
40const MAX_HITS: usize = 100;
42const MAX_CANDIDATES: usize = 60;
43const MAX_CITATIONS: usize = 15;
44const MAX_KEYWORDS: usize = 6;
45const MAX_SYMS_PER_KW: usize = 3;
46
47fn env_usize(key: &str, default: usize) -> usize {
48 std::env::var(key)
49 .ok()
50 .and_then(|v| v.parse::<usize>().ok())
51 .filter(|&v| v > 0)
52 .unwrap_or(default)
53}
54
55#[derive(Debug, Clone)]
58pub struct ExploreOptions {
59 pub max_turns: usize,
60 pub citation_only: bool,
61}
62
63impl ExploreOptions {
64 pub fn new(max_turns: Option<usize>, citation_only: bool) -> Self {
65 let mt = max_turns
66 .unwrap_or_else(|| env_usize("LEAN_CTX_EXPLORE_MAX_TURNS", DEFAULT_MAX_TURNS))
67 .clamp(1, 8);
68 Self {
69 max_turns: mt,
70 citation_only,
71 }
72 }
73}
74
75impl Default for ExploreOptions {
76 fn default() -> Self {
77 Self::new(None, false)
78 }
79}
80
81#[derive(Debug, Clone)]
83pub struct Citation {
84 pub file: String,
85 pub start: usize,
86 pub end: usize,
87 pub label: String,
88}
89
90#[derive(Debug, Clone)]
92pub struct ExploreOutcome {
93 pub text: String,
94 pub tokens: usize,
95 pub citations: Vec<Citation>,
96}
97
98#[derive(Debug, Clone)]
100struct Candidate {
101 file: String,
102 start: usize,
103 end: usize,
104 label: String,
105 score: f64,
106 cost: usize,
107 terms: HashSet<String>,
108}
109
110fn short_kind(kind: &str) -> &str {
112 match kind.to_ascii_lowercase().as_str() {
113 "function" | "fn" | "method" => "fn",
114 "struct" => "struct",
115 "impl" => "impl",
116 "module" | "mod" => "mod",
117 "class" => "class",
118 "trait" => "trait",
119 "enum" => "enum",
120 "issue" => "issue",
121 "pullrequest" => "pr",
122 other if !other.is_empty() => "sym",
123 _ => "",
124 }
125}
126
127fn chunk_kind_tag(kind: &ChunkKind) -> &'static str {
128 match kind {
129 ChunkKind::Function | ChunkKind::Method => "fn",
130 ChunkKind::Struct => "struct",
131 ChunkKind::Impl => "impl",
132 ChunkKind::Module => "mod",
133 ChunkKind::Class => "class",
134 ChunkKind::Issue => "issue",
135 ChunkKind::PullRequest => "pr",
136 _ => "",
137 }
138}
139
140fn rel_path(file: &str, root: &str) -> String {
142 let stripped = file
143 .strip_prefix(root)
144 .map_or(file, |s| s.trim_start_matches(['/', '\\']));
145 crate::core::protocol::display_path(stripped)
146}
147
148fn query_terms(keywords: &[String]) -> Vec<String> {
150 let mut seen = HashSet::new();
151 let mut out = Vec::new();
152 for k in keywords {
153 let lk = k.to_ascii_lowercase();
154 if lk.len() >= 3 && seen.insert(lk.clone()) {
155 out.push(lk);
156 if out.len() >= MAX_KEYWORDS {
157 break;
158 }
159 }
160 }
161 out
162}
163
164fn covered_terms(text: &str, terms: &[String]) -> HashSet<String> {
166 let lower = text.to_ascii_lowercase();
167 terms
168 .iter()
169 .filter(|t| lower.contains(t.as_str()))
170 .cloned()
171 .collect()
172}
173
174fn build_adjacency(project_root: &str) -> BTreeMap<String, BTreeSet<String>> {
177 let mut adj: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
178 if let Some(open) = graph_provider::open_or_build(project_root) {
179 for e in open.provider.edges() {
180 let from = rel_path(&e.from, project_root);
181 let to = rel_path(&e.to, project_root);
182 if from == to {
183 continue;
184 }
185 adj.entry(from.clone()).or_default().insert(to.clone());
186 adj.entry(to).or_default().insert(from);
187 }
188 }
189 adj
190}
191
192fn symbol_candidates(project_root: &str, keywords: &[String], terms: &[String]) -> Vec<Candidate> {
194 let Some(open) = graph_provider::open_or_build(project_root) else {
195 return Vec::new();
196 };
197 let gp = &open.provider;
198 let mut out: Vec<Candidate> = Vec::new();
199 for kw in keywords.iter().take(MAX_KEYWORDS) {
200 let mut syms = gp.find_symbols(kw, None, None);
201 syms.sort_by(|a, b| {
203 b.is_exported
204 .cmp(&a.is_exported)
205 .then_with(|| a.file.cmp(&b.file))
206 .then_with(|| a.start_line.cmp(&b.start_line))
207 });
208 for sym in syms.into_iter().take(MAX_SYMS_PER_KW) {
209 let file = rel_path(&sym.file, project_root);
210 let label = format!("{} ({})", sym.name, short_kind(&sym.kind));
211 let text = format!("{} {}", sym.name, sym.kind);
212 let mut covered = covered_terms(&text, terms);
213 covered.insert(kw.to_ascii_lowercase());
214 out.push(Candidate {
215 file,
216 start: sym.start_line,
217 end: sym.end_line.max(sym.start_line),
218 label,
219 score: 0.0,
220 cost: 1,
221 terms: covered,
222 });
223 }
224 }
225 out
226}
227
228fn candidate_from_hit(hit: &SearchResult, project_root: &str, terms: &[String]) -> Candidate {
230 let file = rel_path(&hit.file_path, project_root);
231 let tag = chunk_kind_tag(&hit.kind);
232 let label = if hit.symbol_name.is_empty() {
233 if tag.is_empty() {
234 String::new()
235 } else {
236 format!("({tag})")
237 }
238 } else if tag.is_empty() {
239 hit.symbol_name.clone()
240 } else {
241 format!("{} ({})", hit.symbol_name, tag)
242 };
243 let terms_covered = covered_terms(&format!("{} {}", hit.symbol_name, hit.snippet), terms);
244 Candidate {
245 file,
246 start: hit.start_line,
247 end: hit.end_line.max(hit.start_line),
248 label,
249 score: hit.score,
250 cost: count_tokens(&hit.snippet).max(1),
251 terms: terms_covered,
252 }
253}
254
255fn expand_frontier(
258 seed_files: &[String],
259 relevant: &BTreeSet<String>,
260 adjacency: &BTreeMap<String, BTreeSet<String>>,
261 max_turns: usize,
262) -> (BTreeSet<String>, usize) {
263 let mut discovered: BTreeSet<String> = seed_files.iter().cloned().collect();
264 let mut frontier: Vec<String> = seed_files.to_vec();
265 let mut turns = 1usize;
266
267 while turns < max_turns && !frontier.is_empty() {
268 let mut next: BTreeSet<String> = BTreeSet::new();
269 for f in &frontier {
270 if let Some(nbrs) = adjacency.get(f) {
271 for nbr in nbrs {
272 if !discovered.contains(nbr) && relevant.contains(nbr) {
273 next.insert(nbr.clone());
274 }
275 }
276 }
277 }
278 if next.is_empty() {
279 break; }
281 for n in &next {
282 discovered.insert(n.clone());
283 }
284 frontier = next.into_iter().collect();
285 turns += 1;
286 }
287 (discovered, turns)
288}
289
290fn select_citations(mut candidates: Vec<Candidate>, budget: usize) -> Vec<Candidate> {
293 if candidates.is_empty() {
294 return Vec::new();
295 }
296 candidates.sort_by(|a, b| {
299 a.file
300 .cmp(&b.file)
301 .then_with(|| a.start.cmp(&b.start))
302 .then_with(|| a.end.cmp(&b.end))
303 });
304 candidates.truncate(MAX_CANDIDATES);
305
306 let any_terms = candidates.iter().any(|c| !c.terms.is_empty());
307 let mut chosen: Vec<usize> = if any_terms {
308 let items: Vec<CoverageItem> = candidates
309 .iter()
310 .map(|c| CoverageItem {
311 terms: c.terms.clone(),
312 cost: c.cost,
313 })
314 .collect();
315 greedy_max_coverage(&items, budget, |_| 1.0)
316 } else {
317 Vec::new()
318 };
319
320 let mut spent: usize = chosen.iter().map(|&i| candidates[i].cost).sum();
321 let in_chosen: HashSet<usize> = chosen.iter().copied().collect();
322
323 let mut by_score: Vec<usize> = (0..candidates.len())
325 .filter(|i| !in_chosen.contains(i))
326 .collect();
327 by_score.sort_by(|&a, &b| {
328 candidates[b]
329 .score
330 .partial_cmp(&candidates[a].score)
331 .unwrap_or(std::cmp::Ordering::Equal)
332 .then_with(|| candidates[a].file.cmp(&candidates[b].file))
333 .then_with(|| candidates[a].start.cmp(&candidates[b].start))
334 });
335 for idx in by_score {
336 if chosen.len() >= MAX_CITATIONS {
337 break;
338 }
339 let cost = candidates[idx].cost;
340 if spent + cost <= budget {
341 chosen.push(idx);
342 spent += cost;
343 }
344 }
345
346 chosen.sort_by(|&a, &b| {
348 candidates[a]
349 .file
350 .cmp(&candidates[b].file)
351 .then_with(|| candidates[a].start.cmp(&candidates[b].start))
352 });
353 chosen.into_iter().map(|i| candidates[i].clone()).collect()
354}
355
356fn render(
359 query: &str,
360 keywords: &[String],
361 turns: usize,
362 files_examined: usize,
363 citations: &[Citation],
364 crp_mode: CrpMode,
365 citation_only: bool,
366) -> String {
367 let mut block = String::from("<final_answer>\n");
368 for c in citations {
369 if c.label.is_empty() {
370 block.push_str(&format!("{}:{}-{}\n", c.file, c.start, c.end));
371 } else {
372 block.push_str(&format!("{}:{}-{} {}\n", c.file, c.start, c.end, c.label));
373 }
374 }
375 block.push_str("</final_answer>\n");
376
377 if citation_only {
378 return block;
379 }
380
381 let mut out = String::new();
382 if crp_mode.is_tdd() {
383 out.push_str(&format!(
384 "explore({query}) → {} citations\n\n",
385 citations.len()
386 ));
387 } else {
388 out.push_str(&format!("EXPLORE: {query}\n"));
389 if keywords.is_empty() {
390 out.push_str("keywords: (none extracted)\n");
391 } else {
392 out.push_str(&format!("keywords: {}\n", keywords.join(", ")));
393 }
394 out.push_str(&format!(
395 "turns: {turns} files_examined: {files_examined} citations: {}\n\n",
396 citations.len()
397 ));
398 }
399 out.push_str(&block);
400 out
401}
402
403pub fn handle(
405 query: &str,
406 project_root: &str,
407 crp_mode: CrpMode,
408 opts: &ExploreOptions,
409) -> ExploreOutcome {
410 let query = query.trim();
411 if query.is_empty() {
412 return ExploreOutcome {
413 text: "ERROR: query is required".to_string(),
414 tokens: 0,
415 citations: Vec::new(),
416 };
417 }
418
419 let (_hint_files, keywords) = parse_task_hints(query);
420 let terms = query_terms(&keywords);
421
422 let root = Path::new(project_root);
424 let index = BM25Index::load_or_build(root);
425 let hits: Vec<SearchResult> = if index.doc_count == 0 {
426 Vec::new()
427 } else {
428 index.search(query, MAX_HITS)
429 };
430
431 let mut best_hit_for_file: BTreeMap<String, Candidate> = BTreeMap::new();
433 let mut seed_order: Vec<String> = Vec::new();
434 for hit in &hits {
435 let cand = candidate_from_hit(hit, project_root, &terms);
436 if let std::collections::btree_map::Entry::Vacant(slot) =
437 best_hit_for_file.entry(cand.file.clone())
438 {
439 seed_order.push(cand.file.clone());
440 slot.insert(cand);
441 }
442 }
443 let relevant: BTreeSet<String> = best_hit_for_file.keys().cloned().collect();
444
445 let per_turn_k = env_usize("LEAN_CTX_EXPLORE_K", DEFAULT_PER_TURN_K);
447 let seeds: Vec<String> = seed_order.iter().take(per_turn_k).cloned().collect();
448
449 let adjacency = build_adjacency(project_root);
451 let (discovered, turns) = expand_frontier(&seeds, &relevant, &adjacency, opts.max_turns);
452
453 let mut by_loc: BTreeMap<(String, usize, usize), Candidate> = BTreeMap::new();
455 let mut insert = |c: Candidate| {
456 let key = (c.file.clone(), c.start, c.end);
457 by_loc
458 .entry(key)
459 .and_modify(|e| {
460 if c.score > e.score {
461 e.score = c.score;
462 }
463 if e.label.is_empty() && !c.label.is_empty() {
464 e.label.clone_from(&c.label);
465 }
466 for t in &c.terms {
467 e.terms.insert(t.clone());
468 }
469 })
470 .or_insert(c);
471 };
472 for file in &discovered {
473 if let Some(c) = best_hit_for_file.get(file) {
474 insert(c.clone());
475 }
476 }
477 for c in symbol_candidates(project_root, &keywords, &terms) {
478 insert(c);
479 }
480
481 let files_examined = discovered.len();
482 let candidates: Vec<Candidate> = by_loc.into_values().collect();
483 let budget = env_usize("LEAN_CTX_EXPLORE_BUDGET_TOKENS", DEFAULT_BUDGET_TOKENS);
484 let selected = select_citations(candidates, budget);
485
486 let citations: Vec<Citation> = selected
487 .into_iter()
488 .map(|c| Citation {
489 file: c.file,
490 start: c.start,
491 end: c.end,
492 label: c.label,
493 })
494 .collect();
495
496 let text = render(
497 query,
498 &keywords,
499 turns,
500 files_examined,
501 &citations,
502 crp_mode,
503 opts.citation_only,
504 );
505 let tokens = count_tokens(&text);
506 ExploreOutcome {
507 text,
508 tokens,
509 citations,
510 }
511}
512
513pub fn parse_final_answer(text: &str) -> Vec<(String, usize, usize)> {
517 let mut out = Vec::new();
518 let mut inside = false;
519 for line in text.lines() {
520 let trimmed = line.trim();
521 if trimmed == "<final_answer>" {
522 inside = true;
523 continue;
524 }
525 if trimmed == "</final_answer>" {
526 break;
527 }
528 if !inside || trimmed.is_empty() {
529 continue;
530 }
531 let token = trimmed.split_whitespace().next().unwrap_or("");
533 if let Some((path, range)) = token.rsplit_once(':')
534 && let Some((s, e)) = range.split_once('-')
535 && let (Ok(start), Ok(end)) = (s.parse::<usize>(), e.parse::<usize>())
536 {
537 out.push((path.to_string(), start, end));
538 }
539 }
540 out
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546
547 #[test]
548 fn empty_query_is_rejected() {
549 let outcome = handle(" ", "/tmp", CrpMode::Off, &ExploreOptions::default());
550 assert!(outcome.text.starts_with("ERROR"));
551 assert_eq!(outcome.tokens, 0);
552 assert!(outcome.citations.is_empty());
553 }
554
555 #[test]
556 fn short_kind_maps_known_kinds() {
557 assert_eq!(short_kind("Function"), "fn");
558 assert_eq!(short_kind("method"), "fn");
559 assert_eq!(short_kind("struct"), "struct");
560 assert_eq!(short_kind("Constant"), "sym");
561 assert_eq!(short_kind(""), "");
562 }
563
564 #[test]
565 fn rel_path_strips_root_and_normalizes() {
566 assert_eq!(rel_path("/repo/src/a.rs", "/repo"), "src/a.rs");
567 assert_eq!(rel_path("src/a.rs", "/repo"), "src/a.rs");
568 }
569
570 #[test]
571 fn query_terms_dedup_and_cap() {
572 let kws: Vec<String> = vec!["Cache", "cache", "Index", "ab", "Search"]
573 .into_iter()
574 .map(String::from)
575 .collect();
576 let terms = query_terms(&kws);
577 assert!(terms.contains(&"cache".to_string()));
578 assert!(terms.contains(&"index".to_string()));
579 assert!(!terms.iter().any(|t| t == "ab")); assert_eq!(terms.iter().filter(|t| *t == "cache").count(), 1);
582 }
583
584 #[test]
585 fn parse_final_answer_extracts_citations() {
586 let text = "EXPLORE: foo\n\n<final_answer>\nsrc/a.rs:10-20 foo (fn)\nsrc/b.rs:5-5\n</final_answer>\n";
587 let cits = parse_final_answer(text);
588 assert_eq!(
589 cits,
590 vec![
591 ("src/a.rs".to_string(), 10, 20),
592 ("src/b.rs".to_string(), 5, 5),
593 ]
594 );
595 }
596
597 #[test]
598 fn parse_final_answer_ignores_text_outside_block() {
599 let text = "noise:1-2 should be ignored\n<final_answer>\nsrc/x.rs:1-3\n</final_answer>\ntrailing:9-9";
600 let cits = parse_final_answer(text);
601 assert_eq!(cits, vec![("src/x.rs".to_string(), 1, 3)]);
602 }
603
604 #[test]
605 fn select_citations_respects_budget_and_is_deterministic() {
606 let mk = |file: &str, start: usize, term: &str, cost: usize, score: f64| Candidate {
607 file: file.to_string(),
608 start,
609 end: start + 5,
610 label: format!("{term} (fn)"),
611 score,
612 cost,
613 terms: HashSet::from([term.to_string()]),
614 };
615 let cands = vec![
616 mk("src/a.rs", 1, "cache", 100, 2.0),
617 mk("src/b.rs", 1, "index", 100, 1.5),
618 mk("src/c.rs", 1, "cache", 100, 1.0), ];
620 let a = select_citations(cands.clone(), 250);
621 let b = select_citations(cands, 250);
622 let fa: Vec<_> = a.iter().map(|c| (c.file.clone(), c.start)).collect();
624 let fb: Vec<_> = b.iter().map(|c| (c.file.clone(), c.start)).collect();
625 assert_eq!(fa, fb);
626 assert!(a.len() <= 2, "budget should cap to 2: {}", a.len());
628 }
629
630 #[test]
631 fn expand_frontier_stops_on_coverage_saturation() {
632 let adj: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
635 let relevant: BTreeSet<String> = ["a.rs".to_string()].into_iter().collect();
636 let (discovered, turns) = expand_frontier(&["a.rs".to_string()], &relevant, &adj, 8);
637 assert_eq!(turns, 1, "no new files ⇒ stop after turn 1");
638 assert_eq!(discovered.len(), 1);
639 }
640
641 #[test]
642 fn expand_frontier_respects_max_turns() {
643 let mut adj: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
646 adj.insert("a.rs".into(), ["b.rs".to_string()].into_iter().collect());
647 adj.insert("b.rs".into(), ["c.rs".to_string()].into_iter().collect());
648 adj.insert("c.rs".into(), ["d.rs".to_string()].into_iter().collect());
649 let relevant: BTreeSet<String> = ["a.rs", "b.rs", "c.rs", "d.rs"]
650 .iter()
651 .map(ToString::to_string)
652 .collect();
653 let (discovered, turns) = expand_frontier(&["a.rs".to_string()], &relevant, &adj, 2);
654 assert_eq!(turns, 2, "max_turns caps BFS depth");
655 assert!(discovered.contains("b.rs"));
656 assert!(
657 !discovered.contains("c.rs"),
658 "depth beyond max_turns is unexplored"
659 );
660 }
661
662 #[test]
663 fn expand_frontier_follows_only_relevant_files() {
664 let mut adj: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
667 adj.insert("a.rs".into(), ["b.rs".to_string()].into_iter().collect());
668 let relevant: BTreeSet<String> = ["a.rs".to_string()].into_iter().collect();
669 let (discovered, turns) = expand_frontier(&["a.rs".to_string()], &relevant, &adj, 8);
670 assert_eq!(turns, 1);
671 assert!(
672 !discovered.contains("b.rs"),
673 "irrelevant neighbours are skipped"
674 );
675 }
676
677 #[test]
678 fn handle_output_is_byte_stable_across_runs() {
679 let dir = tempfile::tempdir().unwrap();
683 let root = dir.path();
684 std::fs::write(
685 root.join("cache.rs"),
686 "pub struct CacheStore { entries: usize }\n\
687 impl CacheStore {\n pub fn lookup(&self, key: &str) -> Option<usize> { let _ = key; None }\n}\n",
688 )
689 .unwrap();
690 std::fs::write(
691 root.join("index.rs"),
692 "pub fn build_index(cache: &str) -> usize { cache.len() }\n",
693 )
694 .unwrap();
695 let root_str = root.to_string_lossy().to_string();
696 let opts = ExploreOptions::new(Some(3), false);
697
698 let a = handle(
699 "how does the cache lookup work",
700 &root_str,
701 CrpMode::Off,
702 &opts,
703 );
704 let b = handle(
705 "how does the cache lookup work",
706 &root_str,
707 CrpMode::Off,
708 &opts,
709 );
710
711 assert_eq!(a.text, b.text, "explore output must be byte-stable");
712 assert_eq!(a.tokens, b.tokens);
713 let locs = |o: &ExploreOutcome| -> Vec<(String, usize, usize)> {
714 o.citations
715 .iter()
716 .map(|c| (c.file.clone(), c.start, c.end))
717 .collect()
718 };
719 assert_eq!(locs(&a), locs(&b));
720 assert!(a.text.contains("<final_answer>"));
721 assert!(a.text.contains("</final_answer>"));
722 }
723}