1use std::collections::HashSet;
2use std::fmt::Write;
3use std::path::{Path, PathBuf};
4
5use crate::core::bm25_index::{BM25Index, format_search_results};
6use crate::core::embedding_index::EmbeddingIndex;
7#[cfg(feature = "embeddings")]
8use crate::core::embeddings::EmbeddingEngine;
9use crate::core::hnsw::FlatEmbeddings;
10use crate::core::hybrid_search::{HybridConfig, HybridResult, format_hybrid_results};
11use crate::tools::CrpMode;
12
13#[allow(clippy::too_many_arguments)]
15pub fn handle(
16 query: &str,
17 path: &str,
18 top_k: usize,
19 crp_mode: CrpMode,
20 languages: Option<&[String]>,
21 path_glob: Option<&str>,
22 mode: Option<&str>,
23 workspace: Option<bool>,
24 artifacts: Option<bool>,
25) -> String {
26 let (root_buf, subdir) = match resolve_search_root(path) {
27 Ok(v) => v,
28 Err(e) => return format!("ERR: {e}"),
29 };
30 let root = root_buf.as_path();
31
32 if !query.trim().is_empty()
35 && let Some(mut session) = crate::core::session::SessionState::load_latest()
36 && session.last_semantic_query.as_deref() != Some(query)
37 {
38 session.last_semantic_query = Some(query.to_string());
39 let _ = session.save();
40 }
41
42 let filter = match SearchFilter::new(languages, path_glob) {
43 Ok(f) => f.with_subdir(subdir),
44 Err(e) => return format!("ERR: invalid filter: {e}"),
45 };
46
47 let compact = crp_mode.is_tdd();
48 let mode = mode.unwrap_or("bm25").to_lowercase();
49 let workspace = workspace.unwrap_or(false);
50 let artifacts = artifacts.unwrap_or(false);
51
52 if artifacts {
53 return artifacts_search(query, root, top_k, compact, &filter, workspace);
54 }
55 if workspace {
56 return workspace_search(query, root, top_k, compact, &filter, &mode);
57 }
58
59 let index = match load_or_refresh_bm25(root) {
60 Bm25LoadResult::Ready(idx) => idx,
61 Bm25LoadResult::Building => {
62 return "BM25 index is being built in the background. \
63 Run ctx_semantic_search again in ~30s, or use action=reindex to wait for completion."
64 .to_string();
65 }
66 };
67 if index.doc_count == 0 {
68 return "No code files found to index.".to_string();
69 }
70
71 match mode.as_str() {
72 "bm25" => {
73 let mut results = index.search(query, filtered_candidate_k(top_k, filter.is_active()));
74 if filter.is_active() {
75 results.retain(|x| filter.matches(&x.file_path));
76 }
77 results.truncate(top_k);
78
79 let header = if compact {
80 format!(
81 "semantic_search(bm25,{top_k}) → {} results, {} chunks indexed\n",
82 results.len(),
83 index.doc_count
84 )
85 } else {
86 format!(
87 "Semantic search (BM25): \"{}\" ({} results from {} indexed chunks)\n",
88 truncate_query(query, 60),
89 results.len(),
90 index.doc_count,
91 )
92 };
93 format!("{header}{}", format_search_results(&results, compact))
94 }
95 "dense" => {
96 let out = dense_search_mode(query, root, &index, top_k, compact, &filter);
97 shrink_resident_after_embedding(root, index);
98 out
99 }
100 _ => {
101 let out = hybrid_search_mode(query, root, &index, top_k, compact, &filter);
102 shrink_resident_after_embedding(root, index);
103 out
104 }
105 }
106}
107
108fn shrink_resident_after_embedding(root: &Path, index: std::sync::Arc<BM25Index>) {
118 #[cfg(feature = "embeddings")]
119 {
120 drop(index);
123 if let Some(cache) = get_thread_cache() {
124 let freed = crate::core::bm25_cache::shrink_resident_to_snippet(&cache, root, 5);
125 if freed > 0 {
126 tracing::info!(
127 "[bm25_cache] reclaimed ~{:.1}MB of resident chunk bodies post-embedding",
128 freed as f64 / 1_048_576.0
129 );
130 }
131 }
132 }
133 #[cfg(not(feature = "embeddings"))]
134 {
135 let _ = (root, index);
136 }
137}
138
139pub fn search_hits(
146 query: &str,
147 path: &str,
148 top_k: usize,
149 mode: &str,
150 languages: Option<&[String]>,
151 path_glob: Option<&str>,
152) -> Result<Vec<HybridResult>, String> {
153 let (root_buf, subdir) = resolve_search_root(path)?;
154 let root = root_buf.as_path();
155
156 let filter = SearchFilter::new(languages, path_glob)
157 .map_err(|e| format!("invalid filter: {e}"))?
158 .with_subdir(subdir);
159
160 let index = BM25Index::load_or_build(root);
161 if index.doc_count == 0 {
162 return Ok(Vec::new());
163 }
164
165 let results = match mode.to_lowercase().as_str() {
166 "bm25" => bm25_hits(&index, query, top_k, &filter),
167 "dense" => {
168 #[cfg(feature = "embeddings")]
169 {
170 dense_results_for_root(query, root, &index, top_k, &filter).map(|(v, _)| v)?
171 }
172 #[cfg(not(feature = "embeddings"))]
173 {
174 return Err("dense mode requires the embeddings feature".to_string());
175 }
176 }
177 _ => {
178 #[cfg(feature = "embeddings")]
179 {
180 hybrid_results_for_root(query, root, &index, top_k, &filter).map(|(v, _)| v)?
181 }
182 #[cfg(not(feature = "embeddings"))]
183 {
184 bm25_hits(&index, query, top_k, &filter)
185 }
186 }
187 };
188
189 Ok(results)
190}
191
192fn bm25_hits(
193 index: &BM25Index,
194 query: &str,
195 top_k: usize,
196 filter: &SearchFilter,
197) -> Vec<HybridResult> {
198 let mut results = index.search(query, filtered_candidate_k(top_k, filter.is_active()));
199 if filter.is_active() {
200 results.retain(|x| filter.matches(&x.file_path));
201 }
202 results.truncate(top_k);
203 results
204 .into_iter()
205 .map(HybridResult::from_bm25_public)
206 .collect()
207}
208
209#[must_use]
211pub fn handle_reindex(path: &str) -> String {
212 let (root_buf, _subdir) = match resolve_search_root(path) {
216 Ok(v) => v,
217 Err(e) => return format!("ERR: {e}"),
218 };
219 let root = root_buf.as_path();
220
221 let idx = BM25Index::build_from_directory(root);
222 let files = idx.files.len();
223 let chunks = idx.doc_count;
224 let _ = idx.save(root);
225
226 format!(
227 "Reindexed {}: {files} files, {chunks} chunks",
228 root.display()
229 )
230}
231
232#[must_use]
233pub fn handle_reindex_artifacts(path: &str, workspace: bool) -> String {
234 let (root_buf, _subdir) = match resolve_search_root(path) {
235 Ok(v) => v,
236 Err(e) => return format!("ERR: {e}"),
237 };
238 let root = root_buf.as_path();
239
240 let mut roots: Vec<std::path::PathBuf> = vec![root.to_path_buf()];
241 let mut warnings: Vec<String> = Vec::new();
242
243 if workspace {
244 let linked = crate::core::workspace_config::load_linked_projects(root);
245 warnings.extend(linked.warnings);
246 roots.extend(linked.roots);
247 }
248
249 let mut total_files = 0usize;
250 let mut total_chunks = 0usize;
251 for r in roots {
252 let (idx, w) = crate::core::artifact_index::rebuild_from_scratch(&r);
253 warnings.extend(w);
254 total_files += idx.files.len();
255 total_chunks += idx.doc_count;
256 }
257
258 if warnings.is_empty() {
259 format!("Reindexed artifacts: {total_files} files, {total_chunks} chunks")
260 } else {
261 format!(
262 "Reindexed artifacts: {total_files} files, {total_chunks} chunks ({} warning(s))",
263 warnings.len()
264 )
265 }
266}
267
268pub fn handle_find_related(
273 file_path: &str,
274 line: usize,
275 project_root: &str,
276 top_k: usize,
277 crp_mode: CrpMode,
278) -> String {
279 let (root_buf, _subdir) = match resolve_search_root(project_root) {
280 Ok(v) => v,
281 Err(e) => return format!("ERR: {e}"),
282 };
283 let root = root_buf.as_path();
284
285 let index = BM25Index::load_or_build(root);
286 if index.doc_count == 0 {
287 return "ERR: empty index. Try action=reindex first.".to_string();
288 }
289
290 let source_chunk = index
291 .chunks
292 .iter()
293 .find(|c| c.file_path == file_path && c.start_line <= line && c.end_line >= line);
294
295 let Some(source_chunk) = source_chunk else {
296 return format!(
297 "ERR: no indexed chunk found at {file_path}:{line}. Try action=reindex first."
298 );
299 };
300
301 let query_text = source_chunk.content.clone();
302 let source_file = source_chunk.file_path.clone();
303 let source_start = source_chunk.start_line;
304
305 let compact = crp_mode != CrpMode::Off;
306
307 let results = find_related_internal(&query_text, root, &index, top_k + 5, compact);
308
309 let mut lines: Vec<String> = results
310 .into_iter()
311 .filter(|l| !l.contains(&format!("{source_file}:{source_start}-")))
312 .take(top_k)
313 .collect();
314
315 let header = if compact {
316 format!(
317 "find_related({file_path}:{line}) → {} results\n",
318 lines.len()
319 )
320 } else {
321 format!("Find related to {file_path}:{line} (semantic similarity)\n")
322 };
323
324 lines.insert(0, header);
325 lines.join("")
326}
327
328fn find_related_internal(
329 query: &str,
330 root: &Path,
331 index: &BM25Index,
332 top_k: usize,
333 compact: bool,
334) -> Vec<String> {
335 let Ok(filter) = SearchFilter::new(None, None) else {
336 return vec!["ERR: filter init failed\n".to_string()];
337 };
338 let output = hybrid_search_mode(query, root, index, top_k, compact, &filter);
339 output.lines().map(|l| format!("{l}\n")).collect()
340}
341
342fn truncate_query(q: &str, max: usize) -> &str {
343 if q.len() <= max {
344 return q;
345 }
346 match q.char_indices().nth(max) {
347 Some((byte_idx, _)) => &q[..byte_idx],
348 None => q,
349 }
350}
351
352std::thread_local! {
353 static BM25_SHARED_CACHE: std::cell::RefCell<Option<crate::core::bm25_cache::SharedBm25Cache>> =
354 const { std::cell::RefCell::new(None) };
355}
356
357pub fn set_thread_cache(cache: crate::core::bm25_cache::SharedBm25Cache) {
359 BM25_SHARED_CACHE.with(|c| {
360 *c.borrow_mut() = Some(cache);
361 });
362}
363
364pub fn get_thread_cache() -> Option<crate::core::bm25_cache::SharedBm25Cache> {
368 BM25_SHARED_CACHE.with(|c| c.borrow().clone())
369}
370
371pub(crate) enum Bm25LoadResult {
373 Ready(std::sync::Arc<BM25Index>),
374 Building,
375}
376
377fn load_or_refresh_bm25(root: &Path) -> Bm25LoadResult {
378 let cached = BM25_SHARED_CACHE.with(|c| {
379 let borrow = c.borrow();
380 borrow
381 .as_ref()
382 .and_then(|cache| crate::core::bm25_cache::get_or_background(cache, root))
383 });
384 if let Some(idx) = cached {
385 return Bm25LoadResult::Ready(idx);
386 }
387
388 let root_str = root.to_string_lossy().to_string();
389
390 if let Some(idx) = crate::core::index_orchestrator::try_load_bm25_index(&root_str) {
391 let idx = std::sync::Arc::new(idx);
392 store_in_thread_cache(root, &idx);
393 return Bm25LoadResult::Ready(idx);
394 }
395
396 if crate::core::index_orchestrator::is_building() {
397 return Bm25LoadResult::Building;
398 }
399
400 crate::core::index_orchestrator::ensure_all_background(&root_str);
406
407 let deadline = std::time::Instant::now() + bm25_cold_build_budget();
408 loop {
409 if let Some(idx) = crate::core::index_orchestrator::try_load_bm25_index(&root_str) {
410 let idx = std::sync::Arc::new(idx);
411 store_in_thread_cache(root, &idx);
412 return Bm25LoadResult::Ready(idx);
413 }
414 if std::time::Instant::now() >= deadline {
415 return Bm25LoadResult::Building;
416 }
417 std::thread::sleep(std::time::Duration::from_millis(50));
418 }
419}
420
421fn bm25_cold_build_budget() -> std::time::Duration {
424 let ms = std::env::var("LEAN_CTX_BM25_COLD_BUDGET_MS")
425 .ok()
426 .and_then(|v| v.parse::<u64>().ok())
427 .unwrap_or(60_000);
428 std::time::Duration::from_millis(ms)
429}
430
431fn store_in_thread_cache(root: &Path, idx: &std::sync::Arc<BM25Index>) {
432 BM25_SHARED_CACHE.with(|c| {
433 let borrow = c.borrow();
434 if let Some(cache) = borrow.as_ref() {
435 let mut guard = cache
436 .lock()
437 .unwrap_or_else(std::sync::PoisonError::into_inner);
438 *guard = Some(crate::core::bm25_cache::Bm25CacheEntry {
439 root: root.to_path_buf(),
440 index: std::sync::Arc::clone(idx),
441 loaded_at: std::time::Instant::now(),
442 fingerprint: crate::core::bm25_cache::index_fingerprint(root),
443 });
444 }
445 });
446}
447
448fn filtered_candidate_k(top_k: usize, filtered: bool) -> usize {
449 if !filtered {
450 return top_k;
451 }
452 let candidates = (top_k.max(10)).saturating_mul(10);
453 candidates.clamp(50, 500)
454}
455
456const WORKSPACE_RRF_K: f64 = 60.0;
457
458fn artifacts_search(
459 query: &str,
460 root: &Path,
461 top_k: usize,
462 compact: bool,
463 filter: &SearchFilter,
464 workspace: bool,
465) -> String {
466 let mut roots: Vec<std::path::PathBuf> = vec![root.to_path_buf()];
467 let mut warnings: Vec<String> = Vec::new();
468
469 if workspace {
470 let linked = crate::core::workspace_config::load_linked_projects(root);
471 warnings.extend(linked.warnings);
472 roots.extend(linked.roots);
473 }
474 roots.sort();
475 roots.dedup();
476
477 let mut per_project: Vec<(String, Vec<crate::core::bm25_index::SearchResult>)> = Vec::new();
478 let mut total_chunks = 0usize;
479
480 for r in &roots {
481 let label = label_for_root(r);
482 let (idx, w) = crate::core::artifact_index::load_or_build(r);
483 warnings.extend(w);
484 total_chunks += idx.doc_count;
485 if idx.doc_count == 0 {
486 continue;
487 }
488
489 let mut results = idx.search(query, filtered_candidate_k(top_k, filter.is_active()));
490 if filter.is_active() {
491 results.retain(|x| filter.matches(&x.file_path));
492 }
493 results.truncate(top_k);
494
495 for res in &mut results {
496 res.file_path = if workspace {
497 format!("[project:{label}] [artifact] {}", res.file_path)
498 } else {
499 format!("[artifact] {}", res.file_path)
500 };
501 }
502
503 per_project.push((label, results));
504 }
505
506 let mut fused: Vec<crate::core::bm25_index::SearchResult> = if per_project.len() <= 1 {
507 per_project
508 .into_iter()
509 .next()
510 .map(|(_, v)| v)
511 .unwrap_or_default()
512 } else {
513 rrf_merge_bm25(per_project, top_k)
514 };
515
516 if fused.is_empty() {
517 return "No artifact files found to index.".to_string();
518 }
519
520 fused.truncate(top_k);
521
522 let header = if compact {
523 if workspace {
524 format!(
525 "semantic_search(artifacts,workspace,{top_k}) → {} results, projects={}, {} chunks indexed\n",
526 fused.len(),
527 roots.len(),
528 total_chunks
529 )
530 } else {
531 format!(
532 "semantic_search(artifacts,{top_k}) → {} results, {} chunks indexed\n",
533 fused.len(),
534 total_chunks
535 )
536 }
537 } else if workspace {
538 format!(
539 "Semantic search (Artifacts/Workspace): \"{}\" ({} results from {} projects)\n",
540 truncate_query(query, 60),
541 fused.len(),
542 roots.len()
543 )
544 } else {
545 format!(
546 "Semantic search (Artifacts): \"{}\" ({} results)\n",
547 truncate_query(query, 60),
548 fused.len()
549 )
550 };
551
552 let mut out = format!("{header}{}", format_search_results(&fused, compact));
553 if !warnings.is_empty() && !compact {
554 let _ = writeln!(out, "\nWarnings ({}):", warnings.len());
555 for w in warnings.iter().take(20) {
556 let _ = writeln!(out, "- {w}");
557 }
558 }
559 out
560}
561
562fn workspace_search(
563 query: &str,
564 root: &Path,
565 top_k: usize,
566 compact: bool,
567 filter: &SearchFilter,
568 mode: &str,
569) -> String {
570 let linked = crate::core::workspace_config::load_linked_projects(root);
571 let mut warnings = linked.warnings;
572
573 let mut roots: Vec<std::path::PathBuf> = vec![root.to_path_buf()];
574 roots.extend(linked.roots);
575 roots.sort();
576 roots.dedup();
577
578 let mut per_project: Vec<(String, Vec<HybridResult>)> = Vec::new();
579 let mut avg_cov: Option<f64> = None;
580 let mut cov_count = 0usize;
581
582 for r in &roots {
583 let label = label_for_root(r);
584 let index = BM25Index::load_or_build(r);
585 if index.doc_count == 0 {
586 continue;
587 }
588
589 let mut results: Vec<HybridResult> = match mode {
590 "bm25" => {
591 let mut bm25 = index.search(query, filtered_candidate_k(top_k, filter.is_active()));
592 if filter.is_active() {
593 bm25.retain(|x| filter.matches(&x.file_path));
594 }
595 bm25.truncate(top_k);
596 bm25.into_iter()
597 .map(HybridResult::from_bm25_public)
598 .collect()
599 }
600 "dense" => {
601 #[cfg(feature = "embeddings")]
602 {
603 match dense_results_for_root(query, r, &index, top_k, filter) {
604 Ok((v, cov)) => {
605 avg_cov = Some(avg_cov.unwrap_or(0.0) + cov);
606 cov_count += 1;
607 v
608 }
609 Err(e) => {
610 warnings.push(format!("[{label}] dense search failed: {e}"));
611 let mut bm25 = index
612 .search(query, filtered_candidate_k(top_k, filter.is_active()));
613 if filter.is_active() {
614 bm25.retain(|x| filter.matches(&x.file_path));
615 }
616 bm25.truncate(top_k);
617 bm25.into_iter()
618 .map(HybridResult::from_bm25_public)
619 .collect()
620 }
621 }
622 }
623 #[cfg(not(feature = "embeddings"))]
624 {
625 let _ = (&label, &warnings);
626 let mut bm25 =
627 index.search(query, filtered_candidate_k(top_k, filter.is_active()));
628 if filter.is_active() {
629 bm25.retain(|x| filter.matches(&x.file_path));
630 }
631 bm25.truncate(top_k);
632 bm25.into_iter()
633 .map(HybridResult::from_bm25_public)
634 .collect()
635 }
636 }
637 _ => {
638 #[cfg(feature = "embeddings")]
639 {
640 match hybrid_results_for_root(query, r, &index, top_k, filter) {
641 Ok((v, cov)) => {
642 avg_cov = Some(avg_cov.unwrap_or(0.0) + cov);
643 cov_count += 1;
644 v
645 }
646 Err(e) => {
647 warnings.push(format!("[{label}] hybrid search failed: {e}"));
648 let mut bm25 = index
649 .search(query, filtered_candidate_k(top_k, filter.is_active()));
650 if filter.is_active() {
651 bm25.retain(|x| filter.matches(&x.file_path));
652 }
653 bm25.truncate(top_k);
654 bm25.into_iter()
655 .map(HybridResult::from_bm25_public)
656 .collect()
657 }
658 }
659 }
660 #[cfg(not(feature = "embeddings"))]
661 {
662 let _ = (&label, &warnings);
663 let mut bm25 =
664 index.search(query, filtered_candidate_k(top_k, filter.is_active()));
665 if filter.is_active() {
666 bm25.retain(|x| filter.matches(&x.file_path));
667 }
668 bm25.truncate(top_k);
669 bm25.into_iter()
670 .map(HybridResult::from_bm25_public)
671 .collect()
672 }
673 }
674 };
675
676 for res in &mut results {
677 res.file_path = format!("[project:{label}] {}", res.file_path);
678 }
679 per_project.push((label, results));
680 }
681
682 let mut fused: Vec<HybridResult> = if per_project.len() <= 1 {
683 per_project
684 .into_iter()
685 .next()
686 .map(|(_, v)| v)
687 .unwrap_or_default()
688 } else {
689 rrf_merge_hybrid(per_project, top_k)
690 };
691
692 if fused.is_empty() {
693 return "No code files found to index.".to_string();
694 }
695
696 fused.truncate(top_k);
697 let cov = avg_cov.and_then(|s| {
698 if cov_count == 0 {
699 None
700 } else {
701 Some(s / cov_count as f64)
702 }
703 });
704
705 let header = if compact {
706 match (mode, cov) {
707 (_, Some(c)) => format!(
708 "semantic_search(workspace,{mode},{top_k}) → {} results, projects={}, embed_cov={:.0}%\n",
709 fused.len(),
710 roots.len(),
711 c * 100.0
712 ),
713 _ => format!(
714 "semantic_search(workspace,{mode},{top_k}) → {} results, projects={}\n",
715 fused.len(),
716 roots.len()
717 ),
718 }
719 } else {
720 format!(
721 "Workspace semantic search ({mode}): \"{}\" ({} results from {} projects)\n",
722 truncate_query(query, 60),
723 fused.len(),
724 roots.len()
725 )
726 };
727
728 let mut out = format!("{header}{}", format_hybrid_results(&fused, compact));
729 if !warnings.is_empty() && !compact {
730 out.push_str(&format!("\nWarnings ({}):\n", warnings.len()));
731 for w in warnings.iter().take(20) {
732 out.push_str(&format!("- {w}\n"));
733 }
734 }
735 out
736}
737
738fn rrf_merge_hybrid(lists: Vec<(String, Vec<HybridResult>)>, top_k: usize) -> Vec<HybridResult> {
739 use std::collections::HashMap;
740
741 let mut acc: HashMap<String, (HybridResult, f64)> = HashMap::new();
742 for (label, results) in lists {
743 for (rank, r) in results.into_iter().enumerate() {
744 let key = format!(
745 "{label}|{}|{}|{}|{}",
746 r.file_path, r.symbol_name, r.start_line, r.end_line
747 );
748 let rrf = 1.0 / (WORKSPACE_RRF_K + (rank as f64) + 1.0);
749 acc.entry(key)
750 .and_modify(|(_, s)| *s += rrf)
751 .or_insert((r, rrf));
752 }
753 }
754
755 let mut out: Vec<HybridResult> = acc
756 .into_values()
757 .map(|(mut r, s)| {
758 r.rrf_score = s;
759 r
760 })
761 .collect();
762 out.sort_by(|a, b| {
763 b.rrf_score
764 .partial_cmp(&a.rrf_score)
765 .unwrap_or(std::cmp::Ordering::Equal)
766 .then_with(|| a.file_path.cmp(&b.file_path))
767 .then_with(|| a.symbol_name.cmp(&b.symbol_name))
768 .then_with(|| a.start_line.cmp(&b.start_line))
769 .then_with(|| a.end_line.cmp(&b.end_line))
770 });
771 out.truncate(top_k);
772 out
773}
774
775fn rrf_merge_bm25(
776 lists: Vec<(String, Vec<crate::core::bm25_index::SearchResult>)>,
777 top_k: usize,
778) -> Vec<crate::core::bm25_index::SearchResult> {
779 use std::collections::HashMap;
780
781 let mut acc: HashMap<String, (crate::core::bm25_index::SearchResult, f64)> = HashMap::new();
782 for (label, results) in lists {
783 for (rank, r) in results.into_iter().enumerate() {
784 let key = format!(
785 "{label}|{}|{}|{}|{}",
786 r.file_path, r.symbol_name, r.start_line, r.end_line
787 );
788 let rrf = 1.0 / (WORKSPACE_RRF_K + (rank as f64) + 1.0);
789 acc.entry(key)
790 .and_modify(|(_, s)| *s += rrf)
791 .or_insert((r, rrf));
792 }
793 }
794
795 let mut out: Vec<crate::core::bm25_index::SearchResult> = acc
796 .into_values()
797 .map(|(mut r, s)| {
798 r.score = s;
799 r
800 })
801 .collect();
802 out.sort_by(|a, b| {
803 b.score
804 .partial_cmp(&a.score)
805 .unwrap_or(std::cmp::Ordering::Equal)
806 .then_with(|| a.file_path.cmp(&b.file_path))
807 .then_with(|| a.symbol_name.cmp(&b.symbol_name))
808 .then_with(|| a.start_line.cmp(&b.start_line))
809 .then_with(|| a.end_line.cmp(&b.end_line))
810 });
811 out.truncate(top_k);
812 out
813}
814
815#[cfg(feature = "embeddings")]
816fn dense_results_for_root(
817 query: &str,
818 root: &Path,
819 index: &BM25Index,
820 top_k: usize,
821 filter: &SearchFilter,
822) -> Result<(Vec<HybridResult>, f64), String> {
823 let (engine, mut embed_idx) = load_engine_and_index(root)?;
824 if let Some(pending) = cold_start_embed_guard(&embed_idx, index) {
828 return Err(dense_build_hint(pending, true));
829 }
830 let (aligned, coverage, changed_files) =
831 ensure_embeddings(root, index, engine, &mut embed_idx)?;
832
833 let backend = crate::core::dense_backend::DenseBackendKind::try_from_env()?;
834 let filter_fn = |p: &str| filter.matches(p);
835 let filter_pred: Option<&dyn Fn(&str) -> bool> = filter
836 .is_active()
837 .then_some(&filter_fn as &dyn Fn(&str) -> bool);
838
839 let candidate_k = filtered_candidate_k(top_k, filter.is_active());
840 let mut results = crate::core::dense_backend::dense_results_as_hybrid(
841 backend,
842 root,
843 index,
844 engine,
845 &aligned,
846 &changed_files,
847 query,
848 candidate_k,
849 filter_pred,
850 )?;
851 results.truncate(top_k);
852
853 Ok((results, coverage))
854}
855
856#[cfg(feature = "embeddings")]
857fn hybrid_results_for_root(
858 query: &str,
859 root: &Path,
860 index: &BM25Index,
861 top_k: usize,
862 filter: &SearchFilter,
863) -> Result<(Vec<HybridResult>, f64), String> {
864 let (engine, mut embed_idx) = load_engine_and_index(root)?;
865 if let Some(pending) = cold_start_embed_guard(&embed_idx, index) {
869 tracing::info!(
870 pending,
871 "hybrid cold-start guard: dense index not built — degrading to BM25 \
872 (build once: lean-ctx index build-semantic)"
873 );
874 return Ok((bm25_hits(index, query, top_k, filter), 0.0));
875 }
876 let (aligned, coverage, changed_files) =
877 ensure_embeddings(root, index, engine, &mut embed_idx)?;
878
879 let backend = crate::core::dense_backend::DenseBackendKind::try_from_env()?;
880 let cfg = HybridConfig::from_config();
881 let filter_fn = |p: &str| filter.matches(p);
882 let filter_pred: Option<&dyn Fn(&str) -> bool> = filter
883 .is_active()
884 .then_some(&filter_fn as &dyn Fn(&str) -> bool);
885 let candidate_k = filtered_candidate_k(top_k, filter.is_active());
886 let graph_ranks = graph_rrf_ranks_for_search_root(root);
887 let graph_ranks_ref = graph_ranks.as_ref();
888 let mut results = crate::core::dense_backend::hybrid_results(
889 backend,
890 root,
891 index,
892 engine,
893 &aligned,
894 &changed_files,
895 query,
896 candidate_k,
897 &cfg,
898 filter_pred,
899 graph_ranks_ref,
900 )?;
901
902 if cfg.splade_weight > 0.0 {
903 let splade = crate::core::splade_retrieval::hybrid_retrieve(query, index, candidate_k);
904 if !splade.is_empty() {
905 boost_with_splade(&mut results, &splade, cfg.splade_weight);
906 }
907 }
908
909 results.truncate(top_k);
910 Ok((results, coverage))
911}
912
913fn boost_with_splade(
915 results: &mut [HybridResult],
916 splade: &[crate::core::splade_retrieval::SpladeResult],
917 weight: f64,
918) {
919 use std::collections::HashMap;
920 let rrf_k = 60.0_f64;
921
922 let boosts: HashMap<&str, f64> = splade
923 .iter()
924 .enumerate()
925 .map(|(rank, sr)| (sr.file_path.as_str(), weight / (rrf_k + rank as f64 + 1.0)))
926 .collect();
927
928 for r in results.iter_mut() {
929 if let Some(&boost) = boosts.get(r.file_path.as_str()) {
930 r.rrf_score += boost;
931 }
932 }
933
934 results.sort_by(|a, b| {
935 b.rrf_score
936 .partial_cmp(&a.rrf_score)
937 .unwrap_or(std::cmp::Ordering::Equal)
938 });
939}
940
941fn label_for_root(root: &Path) -> String {
942 root.file_name()
943 .and_then(|s| s.to_str())
944 .map(str::to_string)
945 .filter(|s| !s.is_empty())
946 .unwrap_or_else(|| root.to_string_lossy().to_string())
947}
948
949fn graph_rrf_ranks_for_search_root(
950 root: &Path,
951) -> Option<std::collections::HashMap<String, usize>> {
952 let root_s = root.to_string_lossy().to_string();
953 let session = crate::core::session::SessionState::load_latest_for_project_root(&root_s)?;
954
955 if session.files_touched.is_empty() {
956 return None;
957 }
958
959 let recent: Vec<String> = session
960 .files_touched
961 .iter()
962 .rev()
963 .filter(|f| path_under_search_root(&f.path, root))
964 .take(12)
965 .map(|f| f.path.clone())
966 .collect();
967
968 if recent.is_empty() {
969 return None;
970 }
971
972 crate::core::graph_context::graph_neighbor_ranks_for_recent_files(&root_s, &recent, 40, 120)
973}
974
975fn path_under_search_root(path: &str, root: &Path) -> bool {
976 let p = std::path::Path::new(path);
977 if p.is_absolute() {
978 let root_norm = crate::core::pathutil::safe_canonicalize_or_self(root);
979 let path_norm = crate::core::pathutil::safe_canonicalize_or_self(p);
980 path_norm.starts_with(&root_norm)
981 } else {
982 true
983 }
984}
985
986#[cfg(feature = "embeddings")]
994fn bm25_graph_search(
995 query: &str,
996 root: &Path,
997 index: &BM25Index,
998 top_k: usize,
999 compact: bool,
1000 filter: &SearchFilter,
1001 cfg: &HybridConfig,
1002) -> String {
1003 let graph_ranks = graph_rrf_ranks_for_search_root(root);
1004 let graph_enhances = graph_ranks.as_ref().is_some_and(|m| !m.is_empty());
1005
1006 let mut results = crate::core::hybrid_search::hybrid_search(
1007 query,
1008 index,
1009 None,
1010 None,
1011 top_k,
1012 cfg,
1013 graph_ranks.as_ref(),
1014 );
1015 if filter.is_active() {
1016 results.retain(|r| filter.matches(&r.file_path));
1017 }
1018 results.truncate(top_k);
1019
1020 if cfg.splade_weight > 0.0 {
1021 let splade = crate::core::splade_retrieval::hybrid_retrieve(query, index, top_k);
1022 if !splade.is_empty() {
1023 boost_with_splade(&mut results, &splade, cfg.splade_weight);
1024 }
1025 }
1026 results.truncate(top_k);
1027
1028 let graph_tag = if graph_enhances { "+graph" } else { "" };
1029 let header = if compact {
1030 format!(
1031 "semantic_search(bm25{graph_tag},{top_k}) → {} results, {} chunks indexed\n",
1032 results.len(),
1033 index.doc_count
1034 )
1035 } else {
1036 format!(
1037 "Semantic search (BM25{graph_tag}): \"{}\" ({} results from {} indexed chunks)\n",
1038 truncate_query(query, 60),
1039 results.len(),
1040 index.doc_count,
1041 )
1042 };
1043 format!("{header}{}", format_hybrid_results(&results, compact))
1044}
1045
1046#[cfg(feature = "embeddings")]
1054fn inline_embed_max_chunks() -> usize {
1055 const DEFAULT_MAX: usize = 2000;
1056 std::env::var("LEAN_CTX_HYBRID_INLINE_EMBED_MAX")
1057 .ok()
1058 .and_then(|v| v.trim().parse::<usize>().ok())
1059 .unwrap_or(DEFAULT_MAX)
1060}
1061
1062#[cfg(feature = "embeddings")]
1065fn exceeds_inline_embed_budget(pending: usize, max: usize) -> bool {
1066 max > 0 && pending > max
1067}
1068
1069#[cfg(feature = "embeddings")]
1074fn cold_start_embed_guard(embed_idx: &EmbeddingIndex, index: &BM25Index) -> Option<usize> {
1075 let pending = embed_idx.pending_chunk_count(&index.chunks);
1076 exceeds_inline_embed_budget(pending, inline_embed_max_chunks()).then_some(pending)
1077}
1078
1079#[cfg(feature = "embeddings")]
1082fn dense_build_hint(pending: usize, compact: bool) -> String {
1083 if compact {
1084 format!("[dense not built: {pending} chunks pending — run: lean-ctx index build-semantic]")
1085 } else {
1086 format!(
1087 "[lean-ctx: dense index not built ({pending} chunks would embed inline). \
1088 Build it once — no per-query embed, no cold-start hang: \
1089 lean-ctx index build-semantic]"
1090 )
1091 }
1092}
1093
1094fn hybrid_search_mode(
1095 query: &str,
1096 root: &Path,
1097 index: &BM25Index,
1098 top_k: usize,
1099 compact: bool,
1100 filter: &SearchFilter,
1101) -> String {
1102 #[cfg(feature = "embeddings")]
1103 {
1104 let cfg = HybridConfig::from_config();
1105
1106 if !cfg.dense_enabled {
1111 return bm25_graph_search(query, root, index, top_k, compact, filter, &cfg);
1112 }
1113
1114 let (engine, mut embed_idx) = match load_engine_and_index(root) {
1115 Ok(v) => v,
1116 Err(e) => return format!("ERR: {e}"),
1117 };
1118
1119 if let Some(pending) = cold_start_embed_guard(&embed_idx, index) {
1126 let base = bm25_graph_search(query, root, index, top_k, compact, filter, &cfg);
1127 return format!("{base}\n{}", dense_build_hint(pending, compact));
1128 }
1129
1130 let (aligned, coverage, changed_files) =
1131 match ensure_embeddings(root, index, engine, &mut embed_idx) {
1132 Ok(v) => v,
1133 Err(e) => return format!("ERR: {e}"),
1134 };
1135
1136 let backend = match crate::core::dense_backend::DenseBackendKind::try_from_env() {
1137 Ok(v) => v,
1138 Err(e) => return format!("ERR: {e}"),
1139 };
1140 let filter_fn = |p: &str| filter.matches(p);
1141 let filter_pred: Option<&dyn Fn(&str) -> bool> = filter
1142 .is_active()
1143 .then_some(&filter_fn as &dyn Fn(&str) -> bool);
1144 let graph_ranks = graph_rrf_ranks_for_search_root(root);
1145 let graph_ranks_ref = graph_ranks.as_ref();
1146 let mut results = match crate::core::dense_backend::hybrid_results(
1147 backend,
1148 root,
1149 index,
1150 engine,
1151 &aligned,
1152 &changed_files,
1153 query,
1154 top_k,
1155 &cfg,
1156 filter_pred,
1157 graph_ranks_ref,
1158 ) {
1159 Ok(v) => v,
1160 Err(e) => return format!("ERR: {e}"),
1161 };
1162
1163 if cfg.splade_weight > 0.0 {
1164 let splade = crate::core::splade_retrieval::hybrid_retrieve(query, index, top_k);
1165 if !splade.is_empty() {
1166 boost_with_splade(&mut results, &splade, cfg.splade_weight);
1167 }
1168 }
1169
1170 results.truncate(top_k);
1171
1172 let header = if compact {
1173 format!(
1174 "semantic_search(hybrid,{top_k}) → {} results, {} chunks, embed_cov={:.0}%\n",
1175 results.len(),
1176 index.doc_count,
1177 coverage * 100.0
1178 )
1179 } else {
1180 format!(
1181 "Semantic search (Hybrid): \"{}\" ({} results from {} indexed chunks, embeddings coverage {:.0}%)\n",
1182 truncate_query(query, 60),
1183 results.len(),
1184 index.doc_count,
1185 coverage * 100.0
1186 )
1187 };
1188
1189 format!("{header}{}", format_hybrid_results(&results, compact))
1190 }
1191 #[cfg(not(feature = "embeddings"))]
1192 {
1193 let mut results = index.search(query, filtered_candidate_k(top_k, filter.is_active()));
1194 if filter.is_active() {
1195 results.retain(|x| filter.matches(&x.file_path));
1196 }
1197
1198 let graph_ranks = graph_rrf_ranks_for_search_root(root);
1199 if let Some(ref graph_ranks) = graph_ranks {
1200 const GRAPH_RRF_K: f64 = 60.0;
1201 for r in &mut results {
1202 if let Some(&rank) = graph_ranks.get(&r.file_path) {
1203 r.score += 1.0 / (GRAPH_RRF_K + rank as f64 + 1.0);
1204 }
1205 }
1206 results.sort_by(|a, b| {
1207 b.score
1208 .partial_cmp(&a.score)
1209 .unwrap_or(std::cmp::Ordering::Equal)
1210 });
1211 }
1212
1213 results.truncate(top_k);
1214 let graph_tag = if graph_ranks.is_some() { "+graph" } else { "" };
1215 let header = if compact {
1216 format!(
1217 "semantic_search(bm25{graph_tag},{top_k}) → {} results, {} chunks indexed\n",
1218 results.len(),
1219 index.doc_count
1220 )
1221 } else {
1222 format!(
1223 "Semantic search (BM25{graph_tag}): \"{}\" ({} results from {} indexed chunks)\n",
1224 truncate_query(query, 60),
1225 results.len(),
1226 index.doc_count,
1227 )
1228 };
1229 format!("{header}{}", format_search_results(&results, compact))
1230 }
1231}
1232
1233fn dense_search_mode(
1234 query: &str,
1235 root: &Path,
1236 index: &BM25Index,
1237 top_k: usize,
1238 compact: bool,
1239 filter: &SearchFilter,
1240) -> String {
1241 #[cfg(feature = "embeddings")]
1242 {
1243 let (engine, mut embed_idx) = match load_engine_and_index(root) {
1244 Ok(v) => v,
1245 Err(e) => return format!("ERR: {e}"),
1246 };
1247
1248 if let Some(pending) = cold_start_embed_guard(&embed_idx, index) {
1253 return dense_build_hint(pending, compact);
1254 }
1255
1256 let (aligned, coverage, changed_files) =
1257 match ensure_embeddings(root, index, engine, &mut embed_idx) {
1258 Ok(v) => v,
1259 Err(e) => return format!("ERR: {e}"),
1260 };
1261
1262 let backend = match crate::core::dense_backend::DenseBackendKind::try_from_env() {
1263 Ok(v) => v,
1264 Err(e) => return format!("ERR: {e}"),
1265 };
1266
1267 let filter_fn = |p: &str| filter.matches(p);
1268 let filter_pred: Option<&dyn Fn(&str) -> bool> = filter
1269 .is_active()
1270 .then_some(&filter_fn as &dyn Fn(&str) -> bool);
1271
1272 let candidate_k = filtered_candidate_k(top_k, filter.is_active());
1273 let mut results = match crate::core::dense_backend::dense_results_as_hybrid(
1274 backend,
1275 root,
1276 index,
1277 engine,
1278 &aligned,
1279 &changed_files,
1280 query,
1281 candidate_k,
1282 filter_pred,
1283 ) {
1284 Ok(v) => v,
1285 Err(e) => return format!("ERR: {e}"),
1286 };
1287 results.truncate(top_k);
1288
1289 let header = if compact {
1290 format!(
1291 "semantic_search(dense,{top_k}) → {} results, {} chunks, embed_cov={:.0}%\n",
1292 results.len(),
1293 index.doc_count,
1294 coverage * 100.0
1295 )
1296 } else {
1297 format!(
1298 "Semantic search (Dense): \"{}\" ({} results from {} indexed chunks, embeddings coverage {:.0}%)\n",
1299 truncate_query(query, 60),
1300 results.len(),
1301 index.doc_count,
1302 coverage * 100.0
1303 )
1304 };
1305
1306 format!("{header}{}", format_hybrid_results(&results, compact))
1307 }
1308 #[cfg(not(feature = "embeddings"))]
1309 {
1310 "ERR: embeddings feature not enabled".to_string()
1311 }
1312}
1313
1314#[cfg(feature = "embeddings")]
1315fn load_engine_and_index(
1316 root: &Path,
1317) -> Result<(&'static EmbeddingEngine, EmbeddingIndex), String> {
1318 let cfg = crate::core::config::Config::load();
1319 let profile = crate::core::config::MemoryProfile::effective(&cfg);
1320 if !profile.embeddings_enabled() {
1321 return Err("embeddings disabled by memory_profile=low".into());
1322 }
1323
1324 let engine = crate::core::embeddings::shared_engine()
1325 .ok_or_else(|| "embedding engine load failed".to_string())?;
1326
1327 let model_name = engine.model_name();
1328 let mut idx = EmbeddingIndex::load(root)
1329 .unwrap_or_else(|| EmbeddingIndex::new_with_model(engine.dimensions(), model_name));
1330
1331 if let Some((stored, current)) = idx.model_mismatch(model_name) {
1332 tracing::warn!(
1333 "[embeddings] model changed: {stored} → {current}. Re-indexing all embeddings."
1334 );
1335 idx = EmbeddingIndex::new_with_model(engine.dimensions(), model_name);
1336 } else if idx.dimension_mismatch(engine.dimensions()) {
1337 tracing::warn!(
1338 "[embeddings] dimension mismatch: index={}d, engine={}d. Re-indexing.",
1339 idx.dimensions,
1340 engine.dimensions()
1341 );
1342 idx = EmbeddingIndex::new_with_model(engine.dimensions(), model_name);
1343 }
1344
1345 if idx.model_id.is_none() {
1346 idx.model_id = Some(model_name.to_string());
1347 }
1348
1349 Ok((engine, idx))
1350}
1351
1352#[cfg(feature = "embeddings")]
1357type AlignedEmbeddings = (FlatEmbeddings, f64, Vec<String>);
1358
1359#[cfg(feature = "embeddings")]
1360fn ensure_embeddings(
1361 root: &Path,
1362 index: &BM25Index,
1363 engine: &EmbeddingEngine,
1364 embed_idx: &mut EmbeddingIndex,
1365) -> Result<AlignedEmbeddings, String> {
1366 if index.content_truncated {
1376 let aligned = embed_idx.get_aligned_flat(&index.chunks).ok_or_else(|| {
1377 "embedding alignment failed on truncated resident index; \
1378 refusing to re-embed snippet-only bodies"
1379 .to_string()
1380 })?;
1381 let coverage = embed_idx.coverage(index.chunks.len());
1382 return Ok((aligned, coverage, Vec::new()));
1383 }
1384
1385 let mut changed_files = embed_idx.files_needing_update(&index.chunks);
1386 changed_files.sort();
1387 changed_files.dedup();
1388
1389 if !changed_files.is_empty() {
1390 let changed_set: std::collections::HashSet<&str> = changed_files
1391 .iter()
1392 .map(std::string::String::as_str)
1393 .collect();
1394
1395 let mut changed_indices: Vec<usize> = Vec::new();
1396 let mut changed_texts: Vec<&str> = Vec::new();
1397 for (i, c) in index.chunks.iter().enumerate() {
1398 if changed_set.contains(c.file_path.as_str()) {
1399 changed_indices.push(i);
1400 changed_texts.push(&c.content);
1401 }
1402 }
1403
1404 let batch_embeddings = engine
1405 .embed_batch(&changed_texts)
1406 .map_err(|e| format!("batch embed failed: {e}"))?;
1407
1408 let new_embeddings: Vec<(usize, Vec<f32>)> =
1409 changed_indices.into_iter().zip(batch_embeddings).collect();
1410
1411 embed_idx.update(&index.chunks, &new_embeddings, &changed_files, None);
1412 embed_idx
1413 .save(root)
1414 .map_err(|e| format!("save embeddings failed: {e}"))?;
1415 }
1416
1417 if let Some(aligned) = embed_idx.get_aligned_flat(&index.chunks) {
1418 let coverage = embed_idx.coverage(index.chunks.len());
1419 return Ok((aligned, coverage, changed_files));
1420 }
1421
1422 let mut all_files: Vec<String> = index.chunks.iter().map(|c| c.file_path.clone()).collect();
1424 all_files.sort();
1425 all_files.dedup();
1426
1427 let all_texts: Vec<&str> = index.chunks.iter().map(|c| c.content.as_str()).collect();
1428 let batch_embeddings = engine
1429 .embed_batch(&all_texts)
1430 .map_err(|e| format!("batch embed failed: {e}"))?;
1431
1432 let new_embeddings: Vec<(usize, Vec<f32>)> = batch_embeddings.into_iter().enumerate().collect();
1433
1434 embed_idx.update(&index.chunks, &new_embeddings, &all_files, None);
1435 embed_idx
1436 .save(root)
1437 .map_err(|e| format!("save embeddings failed: {e}"))?;
1438
1439 let aligned = embed_idx
1440 .get_aligned_flat(&index.chunks)
1441 .ok_or_else(|| "embedding alignment failed after full rebuild".to_string())?;
1442 let coverage = embed_idx.coverage(index.chunks.len());
1443 Ok((aligned, coverage, all_files))
1444}
1445
1446fn resolve_search_root(path: &str) -> Result<(PathBuf, Option<String>), String> {
1458 let raw = Path::new(path);
1459 if !raw.exists() {
1460 return Err(format!("path does not exist: {path}"));
1461 }
1462 let raw_dir = if raw.is_file() {
1463 raw.parent().unwrap_or(raw)
1464 } else {
1465 raw
1466 };
1467 let root = PathBuf::from(crate::core::protocol::detect_project_root_or_cwd(
1468 &raw_dir.to_string_lossy(),
1469 ));
1470 let subdir = search_subdir_filter(&root, raw_dir);
1471 Ok((root, subdir))
1472}
1473
1474fn search_subdir_filter(root: &Path, requested: &Path) -> Option<String> {
1479 let root_c = crate::core::pathutil::safe_canonicalize_or_self(root);
1480 let req_c = crate::core::pathutil::safe_canonicalize_or_self(requested);
1481 let rel = req_c.strip_prefix(&root_c).ok()?;
1482 let rel = rel.to_string_lossy().replace('\\', "/");
1483 let rel = rel.trim_matches('/').to_string();
1484 if rel.is_empty() { None } else { Some(rel) }
1485}
1486
1487struct SearchFilter {
1488 allowed_exts: Option<HashSet<String>>,
1489 path_glob: Option<glob::Pattern>,
1490 subdir: Option<String>,
1495}
1496
1497impl SearchFilter {
1498 fn new(languages: Option<&[String]>, path_glob: Option<&str>) -> Result<Self, String> {
1499 let allowed_exts = languages.map(normalize_languages);
1500 let path_glob = match path_glob {
1501 None => None,
1502 Some(s) if s.trim().is_empty() => None,
1503 Some(s) => Some(glob::Pattern::new(s).map_err(|e| e.msg.to_string())?),
1504 };
1505 Ok(Self {
1506 allowed_exts,
1507 path_glob,
1508 subdir: None,
1509 })
1510 }
1511
1512 fn with_subdir(mut self, subdir: Option<String>) -> Self {
1515 self.subdir = subdir.filter(|s| !s.is_empty());
1516 self
1517 }
1518
1519 fn is_active(&self) -> bool {
1520 self.allowed_exts.is_some() || self.path_glob.is_some() || self.subdir.is_some()
1521 }
1522
1523 fn matches(&self, rel_path: &str) -> bool {
1524 let rel_path = rel_path.replace('\\', "/");
1525 if let Some(prefix) = &self.subdir
1526 && rel_path != *prefix
1527 && !rel_path.starts_with(&format!("{prefix}/"))
1528 {
1529 return false;
1530 }
1531 if let Some(p) = &self.path_glob
1532 && !p.matches(&rel_path)
1533 {
1534 return false;
1535 }
1536 if let Some(exts) = &self.allowed_exts {
1537 let ext = Path::new(&rel_path)
1538 .extension()
1539 .and_then(|e| e.to_str())
1540 .unwrap_or("")
1541 .to_lowercase();
1542 if ext.is_empty() || !exts.contains(&ext) {
1543 return false;
1544 }
1545 }
1546 true
1547 }
1548}
1549
1550fn normalize_languages(langs: &[String]) -> HashSet<String> {
1551 let mut out = HashSet::new();
1552 for l in langs {
1553 let raw = l.trim().trim_start_matches('.').to_lowercase();
1554 match raw.as_str() {
1555 "rust" | "rs" => {
1556 out.insert("rs".to_string());
1557 }
1558 "ts" | "typescript" => {
1559 out.insert("ts".to_string());
1560 out.insert("tsx".to_string());
1561 }
1562 "js" | "javascript" => {
1563 out.insert("js".to_string());
1564 out.insert("jsx".to_string());
1565 out.insert("mjs".to_string());
1566 out.insert("cjs".to_string());
1567 }
1568 "py" | "python" => {
1569 out.insert("py".to_string());
1570 }
1571 "go" => {
1572 out.insert("go".to_string());
1573 }
1574 "java" => {
1575 out.insert("java".to_string());
1576 }
1577 "ruby" | "rb" => {
1578 out.insert("rb".to_string());
1579 }
1580 "php" => {
1581 out.insert("php".to_string());
1582 }
1583 "c" => {
1584 out.insert("c".to_string());
1585 out.insert("h".to_string());
1586 }
1587 "cpp" | "c++" | "cc" => {
1588 out.insert("cpp".to_string());
1589 out.insert("hpp".to_string());
1590 out.insert("cc".to_string());
1591 out.insert("hh".to_string());
1592 }
1593 "cs" | "csharp" => {
1594 out.insert("cs".to_string());
1595 }
1596 "swift" => {
1597 out.insert("swift".to_string());
1598 }
1599 "kt" | "kotlin" => {
1600 out.insert("kt".to_string());
1601 out.insert("kts".to_string());
1602 }
1603 "json" => {
1604 out.insert("json".to_string());
1605 }
1606 "yaml" | "yml" => {
1607 out.insert("yaml".to_string());
1608 out.insert("yml".to_string());
1609 }
1610 other if !other.is_empty() => {
1611 out.insert(other.to_string());
1612 }
1613 _ => {}
1614 }
1615 }
1616 out
1617}
1618
1619#[cfg(feature = "embeddings")]
1621pub fn load_engine_and_index_pub(
1622 root: &Path,
1623) -> Result<(&'static EmbeddingEngine, EmbeddingIndex), String> {
1624 load_engine_and_index(root)
1625}
1626
1627#[cfg(feature = "embeddings")]
1629pub fn ensure_embeddings_for_eval(
1630 root: &Path,
1631 index: &BM25Index,
1632 engine: &EmbeddingEngine,
1633 embed_idx: &mut EmbeddingIndex,
1634) -> Result<AlignedEmbeddings, String> {
1635 ensure_embeddings(root, index, engine, embed_idx)
1636}
1637
1638pub fn boost_with_splade_pub(
1640 results: &mut [HybridResult],
1641 splade: &[crate::core::splade_retrieval::SpladeResult],
1642 weight: f64,
1643) {
1644 boost_with_splade(results, splade, weight);
1645}
1646
1647#[cfg(test)]
1648mod filter_tests {
1649 use super::*;
1650
1651 #[test]
1652 fn filter_language_rust() {
1653 let f = SearchFilter::new(Some(&["rust".into()]), None).unwrap();
1654 assert!(f.matches("src/main.rs"));
1655 assert!(!f.matches("src/main.ts"));
1656 }
1657
1658 #[test]
1659 fn filter_path_glob() {
1660 let f = SearchFilter::new(None, Some("rust/src/**")).unwrap();
1661 assert!(f.matches("rust/src/core/mod.rs"));
1662 assert!(!f.matches("website/src/pages/index.astro"));
1663 }
1664}
1665
1666#[cfg(test)]
1667mod root_resolution_tests {
1668 use super::*;
1669
1670 #[test]
1671 fn subdir_filter_scopes_results_to_subtree() {
1672 let f = SearchFilter::new(None, None)
1673 .unwrap()
1674 .with_subdir(Some("crate_a/src".to_string()));
1675 assert!(f.is_active());
1676 assert!(f.matches("crate_a/src/auth.rs"));
1677 assert!(f.matches("crate_a/src/nested/db.rs"));
1678 assert!(!f.matches("crate_b/src/auth.rs"));
1679 assert!(!f.matches("crate_a/src_extra/x.rs"));
1681 assert!(f.matches(r"crate_a\src\win.rs"));
1683 }
1684
1685 #[test]
1686 fn subdir_filter_combines_with_extension_filter() {
1687 let f = SearchFilter::new(Some(&["rust".to_string()]), None)
1688 .unwrap()
1689 .with_subdir(Some("src".to_string()));
1690 assert!(f.matches("src/main.rs"));
1691 assert!(!f.matches("src/readme.md"), "wrong extension");
1692 assert!(!f.matches("docs/main.rs"), "outside subdir");
1693 }
1694
1695 #[test]
1696 fn empty_subdir_is_no_scope() {
1697 let f = SearchFilter::new(None, None)
1698 .unwrap()
1699 .with_subdir(Some(String::new()));
1700 assert!(!f.is_active());
1701 assert!(f.matches("anything/here.rs"));
1702 }
1703
1704 #[test]
1705 fn search_subdir_filter_derives_relative_prefix() {
1706 let tmp = tempfile::tempdir().unwrap();
1707 let root = tmp.path();
1708 let sub = root.join("a").join("b");
1709 std::fs::create_dir_all(&sub).unwrap();
1710 assert_eq!(search_subdir_filter(root, &sub).as_deref(), Some("a/b"));
1711 assert_eq!(search_subdir_filter(root, root), None);
1712 assert_eq!(search_subdir_filter(&sub, root), None);
1714 }
1715
1716 #[test]
1717 fn resolve_search_root_promotes_subdir_to_project_root() {
1718 let _lock = crate::core::data_dir::test_env_lock();
1722 let tmp = tempfile::tempdir().unwrap();
1723 let root = crate::core::pathutil::safe_canonicalize_or_self(tmp.path());
1724 let sub = root.join("crate_a").join("src");
1725 std::fs::create_dir_all(&sub).unwrap();
1726
1727 crate::test_env::set_var("LEAN_CTX_PROJECT_ROOT", root.to_string_lossy().as_ref());
1730 let from_sub = resolve_search_root(&sub.to_string_lossy());
1731 let from_root = resolve_search_root(&root.to_string_lossy());
1732 crate::test_env::remove_var("LEAN_CTX_PROJECT_ROOT");
1733
1734 let (resolved, subdir) = from_sub.unwrap();
1735 assert_eq!(resolved, root, "subdir must promote to the project root");
1736 assert_eq!(subdir.as_deref(), Some("crate_a/src"));
1737
1738 let (resolved_root, subdir_root) = from_root.unwrap();
1739 assert_eq!(resolved_root, root);
1740 assert_eq!(subdir_root, None, "the root itself carries no subdir scope");
1741 }
1742
1743 #[test]
1744 fn resolve_search_root_errors_on_missing_path() {
1745 assert!(resolve_search_root("/definitely/not/here/xyzzy-7f3a91").is_err());
1746 }
1747}
1748
1749#[cfg(all(test, feature = "embeddings"))]
1750mod cold_start_guard_tests {
1751 use super::*;
1752
1753 #[test]
1754 fn budget_zero_disables_guard() {
1755 assert!(!exceeds_inline_embed_budget(1_000_000, 0));
1757 }
1758
1759 #[test]
1760 fn budget_is_inclusive_and_triggers_above_threshold() {
1761 assert!(!exceeds_inline_embed_budget(0, 2000), "warm index: inline");
1762 assert!(
1763 !exceeds_inline_embed_budget(2000, 2000),
1764 "at the budget: still inline"
1765 );
1766 assert!(
1767 exceeds_inline_embed_budget(2001, 2000),
1768 "over the budget: degrade"
1769 );
1770 }
1771
1772 #[test]
1773 fn default_threshold_positive_when_env_unset() {
1774 if std::env::var_os("LEAN_CTX_HYBRID_INLINE_EMBED_MAX").is_none() {
1776 assert!(inline_embed_max_chunks() >= 1);
1777 }
1778 }
1779
1780 #[test]
1781 fn dense_build_hint_always_points_at_the_cli_build() {
1782 let full = dense_build_hint(22_741, false);
1783 assert!(full.contains("lean-ctx index build-semantic"));
1784 assert!(full.contains("22741"));
1785 let compact = dense_build_hint(22_741, true);
1786 assert!(compact.contains("lean-ctx index build-semantic"));
1787 assert!(compact.contains("22741"));
1788 }
1789}
1790
1791#[cfg(test)]
1792mod determinism_tests {
1793 use super::*;
1794
1795 #[test]
1796 fn rrf_merge_hybrid_is_deterministic_on_ties() {
1797 let a = HybridResult {
1798 file_path: "a.rs".to_string(),
1799 symbol_name: "foo".to_string(),
1800 kind: crate::core::bm25_index::ChunkKind::Function,
1801 start_line: 1,
1802 end_line: 1,
1803 snippet: "a".to_string(),
1804 rrf_score: 0.0,
1805 bm25_score: None,
1806 dense_score: None,
1807 bm25_rank: None,
1808 dense_rank: None,
1809 };
1810 let b = HybridResult {
1811 file_path: "b.rs".to_string(),
1812 symbol_name: "foo".to_string(),
1813 kind: crate::core::bm25_index::ChunkKind::Function,
1814 start_line: 1,
1815 end_line: 1,
1816 snippet: "b".to_string(),
1817 rrf_score: 0.0,
1818 bm25_score: None,
1819 dense_score: None,
1820 bm25_rank: None,
1821 dense_rank: None,
1822 };
1823
1824 let fused = rrf_merge_hybrid(
1826 vec![
1827 ("root".to_string(), vec![a.clone(), b.clone()]),
1828 ("root".to_string(), vec![b.clone(), a.clone()]),
1829 ],
1830 10,
1831 );
1832
1833 assert_eq!(fused.len(), 2);
1834 assert_eq!(fused[0].file_path, "a.rs");
1835 assert_eq!(fused[1].file_path, "b.rs");
1836 }
1837}
1838
1839#[cfg(test)]
1840mod dense_config_tests {
1841 use super::*;
1842
1843 #[test]
1845 fn dense_enabled_defaults_true() {
1846 assert!(HybridConfig::default().dense_enabled);
1847 }
1848
1849 #[test]
1851 fn dense_enabled_deserializes_false() {
1852 let cfg: HybridConfig = toml::from_str("dense_enabled = false").unwrap();
1853 assert!(!cfg.dense_enabled);
1854 assert_eq!(cfg.bm25_candidates, 75);
1855 assert_eq!(cfg.splade_weight, 0.5);
1856 }
1857}
1858
1859#[cfg(all(test, feature = "embeddings"))]
1860mod dense_toggle_tests {
1861 use super::*;
1862 use crate::core::bm25_index::{BM25Index, ChunkKind, CodeChunk, tokenize};
1863
1864 fn small_index() -> BM25Index {
1865 BM25Index::from_chunks_for_test(vec![
1866 CodeChunk {
1867 file_path: "auth.rs".into(),
1868 symbol_name: "validate_token".into(),
1869 kind: ChunkKind::Function,
1870 start_line: 1,
1871 end_line: 10,
1872 content: "fn validate_token(token: &str) -> bool { check_jwt_expiry(token) }"
1873 .into(),
1874 tokens: tokenize("fn validate_token token str bool check_jwt_expiry token"),
1875 token_count: 0,
1876 },
1877 CodeChunk {
1878 file_path: "db.rs".into(),
1879 symbol_name: "connect_database".into(),
1880 kind: ChunkKind::Function,
1881 start_line: 1,
1882 end_line: 5,
1883 content: "fn connect_database(url: &str) -> Pool { create_pool(url) }".into(),
1884 tokens: tokenize("fn connect_database url str Pool create_pool url"),
1885 token_count: 0,
1886 },
1887 ])
1888 }
1889
1890 #[test]
1895 fn bm25_graph_search_ranks_without_embeddings() {
1896 let dir = tempfile::tempdir().unwrap();
1897 let root = dir.path();
1898 let index = small_index();
1899 let cfg = HybridConfig {
1900 dense_enabled: false,
1901 ..Default::default()
1902 };
1903 let filter = SearchFilter::new(None, None).unwrap();
1904
1905 let out = bm25_graph_search(
1906 "jwt token validation",
1907 root,
1908 &index,
1909 5,
1910 false,
1911 &filter,
1912 &cfg,
1913 );
1914
1915 assert!(
1916 out.contains("Semantic search (BM25"),
1917 "expected BM25 header, got: {out}"
1918 );
1919 assert!(
1920 out.contains("validate_token"),
1921 "expected lexical match, got: {out}"
1922 );
1923 assert!(
1924 !root.join("embeddings.json").exists(),
1925 "dense-disabled path must not persist embeddings.json"
1926 );
1927 }
1928}