1#[cfg(feature = "embeddings")]
2use std::path::Path;
3
4#[cfg(feature = "embeddings")]
5use crate::core::bm25_index::BM25Index;
6#[cfg(any(feature = "qdrant", feature = "pgvector"))]
7use crate::core::bm25_index::ChunkKind;
8#[cfg(feature = "embeddings")]
9use crate::core::hnsw::FlatEmbeddings;
10#[cfg(feature = "embeddings")]
11use crate::core::hybrid_search::{DenseSearchResult, HybridConfig, HybridResult};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum DenseBackendKind {
15 Local,
16 #[cfg(feature = "qdrant")]
17 Qdrant,
18 #[cfg(feature = "pgvector")]
19 Pgvector,
20}
21
22impl DenseBackendKind {
23 pub fn try_from_env() -> Result<Self, String> {
24 let explicit = std::env::var("LEANCTX_DENSE_BACKEND")
25 .ok()
26 .map(|v| v.trim().to_ascii_lowercase())
27 .filter(|v| !v.is_empty());
28
29 let inferred_qdrant =
30 std::env::var("LEANCTX_QDRANT_URL").is_ok_and(|v| !v.trim().is_empty());
31 let inferred_pgvector =
32 std::env::var("LEANCTX_PGVECTOR_URL").is_ok_and(|v| !v.trim().is_empty());
33
34 let requested = explicit
35 .or_else(|| inferred_qdrant.then_some("qdrant".to_string()))
36 .or_else(|| inferred_pgvector.then_some("pgvector".to_string()));
37
38 match requested.as_deref() {
39 None | Some("local") => Ok(Self::Local),
40 Some("qdrant") => {
41 #[cfg(feature = "qdrant")]
42 {
43 Ok(Self::Qdrant)
44 }
45 #[cfg(not(feature = "qdrant"))]
46 {
47 Err("Dense backend 'qdrant' requested, but feature 'qdrant' is not enabled. Rebuild with --features qdrant.".to_string())
48 }
49 }
50 Some("pgvector") => {
51 #[cfg(feature = "pgvector")]
52 {
53 Ok(Self::Pgvector)
54 }
55 #[cfg(not(feature = "pgvector"))]
56 {
57 Err("Dense backend 'pgvector' requested, but feature 'pgvector' is not enabled. Rebuild with --features pgvector.".to_string())
58 }
59 }
60 Some(other) => Err(format!(
61 "Unknown LEANCTX_DENSE_BACKEND={other:?} (expected 'local', 'qdrant' or 'pgvector')"
62 )),
63 }
64 }
65
66 pub fn label(&self) -> &'static str {
67 match self {
68 Self::Local => "local",
69 #[cfg(feature = "qdrant")]
70 Self::Qdrant => "qdrant",
71 #[cfg(feature = "pgvector")]
72 Self::Pgvector => "pgvector",
73 }
74 }
75}
76
77#[cfg(feature = "embeddings")]
78#[allow(clippy::too_many_arguments)]
79pub fn dense_results_as_hybrid(
80 backend: DenseBackendKind,
81 root: &Path,
82 index: &BM25Index,
83 engine: &crate::core::embeddings::EmbeddingEngine,
84 aligned_embeddings: &FlatEmbeddings,
85 changed_files: &[String],
86 query: &str,
87 top_k: usize,
88 filter: Option<&dyn Fn(&str) -> bool>,
89) -> Result<Vec<HybridResult>, String> {
90 let dense =
93 crate::core::bm25_index::source_gate::pinned(gate_root(backend, root, index), || {
94 dense_results(
95 backend,
96 root,
97 index,
98 engine,
99 aligned_embeddings,
100 changed_files,
101 query,
102 top_k,
103 filter,
104 )
105 })
106 .unwrap_or_else(|| Ok(Vec::new()))?;
107
108 Ok(dense
109 .into_iter()
110 .map(|d| HybridResult {
111 file_path: d.file_path,
112 symbol_name: d.symbol_name,
113 kind: d.kind,
114 start_line: d.start_line,
115 end_line: d.end_line,
116 snippet: d.snippet,
117 rrf_score: d.similarity as f64,
118 bm25_score: None,
119 dense_score: Some(d.similarity),
120 bm25_rank: None,
121 dense_rank: None,
122 })
123 .collect())
124}
125
126#[cfg(feature = "embeddings")]
130fn gate_root<'a>(
131 backend: DenseBackendKind,
132 root: &'a Path,
133 index: &'a BM25Index,
134) -> Option<&'a Path> {
135 if matches!(backend, DenseBackendKind::Local) {
136 index.source_root.as_deref()
137 } else {
138 Some(root)
139 }
140}
141
142#[cfg(feature = "embeddings")]
146#[allow(clippy::too_many_arguments)]
147pub fn hybrid_results(
148 backend: DenseBackendKind,
149 root: &Path,
150 index: &BM25Index,
151 engine: &crate::core::embeddings::EmbeddingEngine,
152 aligned_embeddings: &FlatEmbeddings,
153 changed_files: &[String],
154 query: &str,
155 top_k: usize,
156 config: &HybridConfig,
157 filter: Option<&dyn Fn(&str) -> bool>,
158 graph_file_ranks: Option<&std::collections::HashMap<String, usize>>,
159) -> Result<Vec<HybridResult>, String> {
160 crate::core::bm25_index::source_gate::pinned(gate_root(backend, root, index), || {
161 hybrid_results_pinned(
162 backend,
163 root,
164 index,
165 engine,
166 aligned_embeddings,
167 changed_files,
168 query,
169 top_k,
170 config,
171 filter,
172 graph_file_ranks,
173 )
174 })
175 .unwrap_or_else(|| Ok(Vec::new()))
176}
177
178#[cfg(feature = "embeddings")]
179#[allow(clippy::too_many_arguments)]
180fn hybrid_results_pinned(
181 backend: DenseBackendKind,
182 root: &Path,
183 index: &BM25Index,
184 engine: &crate::core::embeddings::EmbeddingEngine,
185 aligned_embeddings: &FlatEmbeddings,
186 changed_files: &[String],
187 query: &str,
188 top_k: usize,
189 config: &HybridConfig,
190 filter: Option<&dyn Fn(&str) -> bool>,
191 graph_file_ranks: Option<&std::collections::HashMap<String, usize>>,
192) -> Result<Vec<HybridResult>, String> {
193 match backend {
194 DenseBackendKind::Local => {
195 let _ = (root, changed_files);
196 let mut results = crate::core::hybrid_search::hybrid_search(
197 query,
198 index,
199 Some(engine),
200 Some(aligned_embeddings),
201 top_k,
202 config,
203 graph_file_ranks,
204 );
205 if let Some(pred) = filter {
206 results.retain(|r| pred(&r.file_path));
207 }
208 results.truncate(top_k);
209 Ok(results)
210 }
211 #[cfg(feature = "qdrant")]
212 DenseBackendKind::Qdrant => remote_hybrid_results(
213 backend,
214 root,
215 index,
216 engine,
217 aligned_embeddings,
218 changed_files,
219 query,
220 top_k,
221 config,
222 filter,
223 graph_file_ranks,
224 ),
225 #[cfg(feature = "pgvector")]
226 DenseBackendKind::Pgvector => remote_hybrid_results(
227 backend,
228 root,
229 index,
230 engine,
231 aligned_embeddings,
232 changed_files,
233 query,
234 top_k,
235 config,
236 filter,
237 graph_file_ranks,
238 ),
239 }
240}
241
242#[cfg(all(feature = "embeddings", any(feature = "qdrant", feature = "pgvector")))]
245#[allow(clippy::too_many_arguments)]
246fn remote_hybrid_results(
247 backend: DenseBackendKind,
248 root: &Path,
249 index: &BM25Index,
250 engine: &crate::core::embeddings::EmbeddingEngine,
251 aligned_embeddings: &FlatEmbeddings,
252 changed_files: &[String],
253 query: &str,
254 top_k: usize,
255 config: &HybridConfig,
256 filter: Option<&dyn Fn(&str) -> bool>,
257 graph_file_ranks: Option<&std::collections::HashMap<String, usize>>,
258) -> Result<Vec<HybridResult>, String> {
259 let bm25_k = config.bm25_candidates.max(top_k);
260 let dense_k = config.dense_candidates.max(top_k);
261
262 let mut bm25 = index.search(query, bm25_k);
263 if let Some(pred) = filter {
264 bm25.retain(|r| pred(&r.file_path));
265 }
266
267 let dense = dense_results(
268 backend,
269 root,
270 index,
271 engine,
272 aligned_embeddings,
273 changed_files,
274 query,
275 dense_k,
276 filter,
277 )?;
278
279 let mut fused = crate::core::hybrid_search::reciprocal_rank_fusion(
280 &bm25,
281 &dense,
282 config,
283 top_k,
284 graph_file_ranks,
285 );
286 if let Some(pred) = filter {
287 fused.retain(|r| pred(&r.file_path));
288 }
289 fused.truncate(top_k);
290 Ok(fused)
291}
292
293#[cfg(feature = "embeddings")]
294#[allow(clippy::too_many_arguments)]
295fn dense_results(
296 backend: DenseBackendKind,
297 root: &Path,
298 index: &BM25Index,
299 engine: &crate::core::embeddings::EmbeddingEngine,
300 aligned_embeddings: &FlatEmbeddings,
301 changed_files: &[String],
302 query: &str,
303 top_k: usize,
304 filter: Option<&dyn Fn(&str) -> bool>,
305) -> Result<Vec<DenseSearchResult>, String> {
306 match backend {
307 DenseBackendKind::Local => {
308 let _ = (root, changed_files);
309 dense_results_local(index, engine, aligned_embeddings, query, top_k, filter)
310 }
311 #[cfg(feature = "qdrant")]
312 DenseBackendKind::Qdrant => {
313 let vecs: Vec<Vec<f32>> = (0..aligned_embeddings.n_vectors())
314 .map(|i| aligned_embeddings.get_vec(i))
315 .collect();
316 dense_results_qdrant(
317 root,
318 index,
319 engine,
320 &vecs,
321 changed_files,
322 query,
323 top_k,
324 filter,
325 )
326 }
327 #[cfg(feature = "pgvector")]
328 DenseBackendKind::Pgvector => {
329 let vecs: Vec<Vec<f32>> = (0..aligned_embeddings.n_vectors())
330 .map(|i| aligned_embeddings.get_vec(i))
331 .collect();
332 dense_results_pgvector(
333 root,
334 index,
335 engine,
336 &vecs,
337 changed_files,
338 query,
339 top_k,
340 filter,
341 )
342 }
343 }
344}
345
346#[cfg(feature = "embeddings")]
347fn dense_results_local(
348 index: &BM25Index,
349 engine: &crate::core::embeddings::EmbeddingEngine,
350 aligned_embeddings: &FlatEmbeddings,
351 query: &str,
352 top_k: usize,
353 filter: Option<&dyn Fn(&str) -> bool>,
354) -> Result<Vec<DenseSearchResult>, String> {
355 let query_embedding = engine
356 .embed_query(query)
357 .map_err(|e| format!("embedding failed: {e}"))?;
358
359 let mut gate = index.source_gate();
364 let fetch = if gate.is_some() {
365 crate::core::bm25_index::source_gate::overfetch(top_k)
366 } else {
367 top_k
368 };
369 let top = top_k_by_similarity(&query_embedding, aligned_embeddings, fetch, |i| {
370 let Some(pred) = filter else { return true };
371 index.chunks.get(i).is_some_and(|c| pred(&c.file_path))
372 });
373
374 Ok(top
375 .into_iter()
376 .filter_map(|(idx, sim)| {
377 let chunk = index.chunks.get(idx)?;
378 if !crate::core::bm25_index::source_gate::chunk_served(&mut gate, chunk) {
379 return None;
380 }
381 let snippet = chunk.content.lines().take(5).collect::<Vec<_>>().join("\n");
382 Some(DenseSearchResult {
383 chunk_idx: idx,
384 similarity: sim,
385 file_path: chunk.file_path.clone(),
386 symbol_name: chunk.symbol_name.clone(),
387 kind: chunk.kind.clone(),
388 start_line: chunk.start_line,
389 end_line: chunk.end_line,
390 snippet,
391 })
392 })
393 .take(top_k)
394 .collect())
395}
396
397#[cfg(feature = "embeddings")]
405fn top_k_by_similarity(
406 query: &[f32],
407 embeddings: &FlatEmbeddings,
408 k: usize,
409 filter: impl Fn(usize) -> bool,
410) -> Vec<(usize, f32)> {
411 use std::cmp::Ordering;
412 use std::collections::BinaryHeap;
413
414 #[derive(PartialEq)]
415 struct MinEntry(f32, usize);
416
417 impl Eq for MinEntry {}
418 impl PartialOrd for MinEntry {
419 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
420 Some(self.cmp(other))
421 }
422 }
423 impl Ord for MinEntry {
424 fn cmp(&self, other: &Self) -> Ordering {
425 other
426 .0
427 .partial_cmp(&self.0)
428 .unwrap_or(Ordering::Equal)
429 .then_with(|| self.1.cmp(&other.1))
430 }
431 }
432
433 let n = embeddings.n_vectors();
434 let mut heap: BinaryHeap<MinEntry> = BinaryHeap::with_capacity(k + 1);
435
436 for i in 0..n {
437 if !filter(i) {
438 continue;
439 }
440 let emb = embeddings.get(i);
441 let sim = crate::core::embeddings::cosine_similarity(query, emb);
442 if heap.len() < k {
443 heap.push(MinEntry(sim, i));
444 } else if let Some(min) = heap.peek()
445 && sim > min.0
446 {
447 heap.pop();
448 heap.push(MinEntry(sim, i));
449 }
450 }
451
452 let mut result: Vec<(usize, f32)> = heap.into_iter().map(|e| (e.1, e.0)).collect();
453 result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
454 result
455}
456
457#[cfg(feature = "qdrant")]
458#[cfg(feature = "embeddings")]
459fn dense_results_qdrant(
460 root: &Path,
461 index: &BM25Index,
462 engine: &crate::core::embeddings::EmbeddingEngine,
463 aligned_embeddings: &[Vec<f32>],
464 changed_files: &[String],
465 query: &str,
466 top_k: usize,
467 filter: Option<&dyn Fn(&str) -> bool>,
468) -> Result<Vec<DenseSearchResult>, String> {
469 let store = crate::core::qdrant_store::QdrantStore::from_env()?;
470 let collection = store.collection_name(root, engine.dimensions())?;
471 let created_new = store.ensure_collection(&collection, engine.dimensions())?;
472 store.sync_index(
473 &collection,
474 index,
475 aligned_embeddings,
476 changed_files,
477 created_new,
478 )?;
479
480 let query_vec = engine
481 .embed_query(query)
482 .map_err(|e| format!("embedding failed: {e}"))?;
483
484 let mut gate = crate::core::bm25_index::source_gate::SourceGate::new(root, "ctx_search")
488 .with_origins(&index.provider_origins);
489 let hits = remote_candidates(
490 &mut gate,
491 index,
492 query,
493 top_k,
494 |limit, sources| match sources {
495 Some(sources) => store.search_within(&collection, &query_vec, limit, sources),
496 None => store.search(&collection, &query_vec, limit),
497 },
498 )?;
499 let mut out = Vec::with_capacity(top_k.min(hits.len()));
500 let mut undigested = Vec::new();
501 for hit in hits {
502 if out.len() >= top_k {
503 break;
504 }
505 if let Some(pred) = filter
506 && !pred(&hit.file_path)
507 {
508 continue;
509 }
510 let row = RemoteRow {
511 source: &hit.file_path,
512 start_line: hit.start_line,
513 end_line: hit.end_line,
514 content_digest: hit.content_digest.as_deref(),
515 };
516 let Some(snippet) = remote_row_snippet(&mut gate, index, root, &row, &mut undigested)
517 else {
518 continue;
519 };
520 out.push(DenseSearchResult {
521 chunk_idx: 0,
522 similarity: hit.score,
523 file_path: hit.file_path,
524 symbol_name: hit.symbol_name,
525 kind: hit.kind,
526 start_line: hit.start_line,
527 end_line: hit.end_line,
528 snippet,
529 });
530 }
531 forget_deleted_sources(root, &gate, |gone| {
532 store.sync_index(&collection, &BM25Index::new(), &[], gone, false)
533 });
534 resync_undigested(undigested, |sources| {
535 store.sync_index(&collection, index, aligned_embeddings, sources, false)
536 });
537 Ok(out)
538}
539
540#[cfg(feature = "pgvector")]
541#[cfg(feature = "embeddings")]
542#[allow(clippy::too_many_arguments)]
543fn dense_results_pgvector(
544 root: &Path,
545 index: &BM25Index,
546 engine: &crate::core::embeddings::EmbeddingEngine,
547 aligned_embeddings: &[Vec<f32>],
548 changed_files: &[String],
549 query: &str,
550 top_k: usize,
551 filter: Option<&dyn Fn(&str) -> bool>,
552) -> Result<Vec<DenseSearchResult>, String> {
553 let store = crate::core::pgvector_store::PgvectorStore::from_env()?;
554 let table = store.table_name(root, engine.dimensions())?;
555 let created_new = store.ensure_table(&table, engine.dimensions())?;
556 store.sync_index(
557 &table,
558 index,
559 aligned_embeddings,
560 changed_files,
561 created_new,
562 )?;
563
564 let query_vec = engine
565 .embed_query(query)
566 .map_err(|e| format!("embedding failed: {e}"))?;
567
568 let mut gate = crate::core::bm25_index::source_gate::SourceGate::new(root, "ctx_search")
571 .with_origins(&index.provider_origins);
572 let hits = remote_candidates(
573 &mut gate,
574 index,
575 query,
576 top_k,
577 |limit, sources| match sources {
578 Some(sources) => store.search_within(&table, &query_vec, limit, sources),
579 None => store.search(&table, &query_vec, limit),
580 },
581 )?;
582 let mut out = Vec::with_capacity(top_k.min(hits.len()));
583 let mut undigested = Vec::new();
584 for hit in hits {
585 if out.len() >= top_k {
586 break;
587 }
588 if let Some(pred) = filter
589 && !pred(&hit.file_path)
590 {
591 continue;
592 }
593 let row = RemoteRow {
594 source: &hit.file_path,
595 start_line: hit.start_line,
596 end_line: hit.end_line,
597 content_digest: hit.content_digest.as_deref(),
598 };
599 let Some(snippet) = remote_row_snippet(&mut gate, index, root, &row, &mut undigested)
600 else {
601 continue;
602 };
603 out.push(DenseSearchResult {
604 chunk_idx: 0,
605 similarity: hit.score,
606 file_path: hit.file_path,
607 symbol_name: hit.symbol_name,
608 kind: hit.kind,
609 start_line: hit.start_line,
610 end_line: hit.end_line,
611 snippet,
612 });
613 }
614 forget_deleted_sources(root, &gate, |gone| {
615 store.sync_index(&table, &BM25Index::new(), &[], gone, false)
616 });
617 resync_undigested(undigested, |sources| {
618 store.sync_index(&table, index, aligned_embeddings, sources, false)
619 });
620 Ok(out)
621}
622
623#[cfg(all(feature = "embeddings", any(feature = "qdrant", feature = "pgvector")))]
649fn remote_candidates<Hit>(
650 gate: &mut crate::core::bm25_index::source_gate::SourceGate,
651 index: &BM25Index,
652 query: &str,
653 top_k: usize,
654 search: impl Fn(usize, Option<&[String]>) -> Result<Vec<Hit>, String>,
655) -> Result<Vec<Hit>, String> {
656 let fetch = crate::core::bm25_index::source_gate::overfetch(top_k);
657 if !gate.protected() {
658 return search(fetch, None);
659 }
660 let admitted = index.admitted_sources(query, gate);
661 if admitted.is_empty() {
662 return Ok(Vec::new());
663 }
664 search(fetch, Some(&admitted))
665}
666
667#[cfg(any(feature = "qdrant", feature = "pgvector"))]
669struct RemoteRow<'a> {
670 source: &'a str,
671 start_line: usize,
672 end_line: usize,
673 content_digest: Option<&'a str>,
676}
677
678#[cfg(any(feature = "qdrant", feature = "pgvector"))]
691fn remote_row_snippet(
692 gate: &mut crate::core::bm25_index::source_gate::SourceGate,
693 index: &BM25Index,
694 root: &Path,
695 row: &RemoteRow<'_>,
696 undigested: &mut Vec<String>,
697) -> Option<String> {
698 use crate::core::bm25_index::external_origin::chunk_digest;
699 if row.source.contains("://") && gate.protected() {
700 let Some(digest) = row.content_digest else {
701 undigested.push(row.source.to_owned());
702 return None;
703 };
704 let chunk = index.chunks.iter().find(|chunk| {
705 chunk.file_path == row.source && chunk_digest(&chunk.content) == digest
706 })?;
707 return gate
708 .serves_with(
709 &chunk.file_path,
710 &chunk.content,
711 chunk.input_origin.as_ref(),
712 )
713 .then(|| chunk.content.lines().take(5).collect::<Vec<_>>().join("\n"));
714 }
715 let snippet = snippet_from_disk(root, row.source, row.start_line, row.end_line, 5);
716 gate.serves(row.source, &snippet).then_some(snippet)
717}
718
719#[cfg(any(feature = "qdrant", feature = "pgvector"))]
724fn resync_undigested(
725 mut sources: Vec<String>,
726 resync: impl FnOnce(&[String]) -> Result<(), String>,
727) {
728 const MAX_RESYNCED_SOURCES: usize = 8;
730 sources.sort();
731 sources.dedup();
732 sources.truncate(MAX_RESYNCED_SOURCES);
733 if !sources.is_empty() && resync(&sources).is_err() {
734 tracing::debug!("[dense] rows without a content digest could not be written again");
735 }
736}
737
738#[cfg(any(feature = "qdrant", feature = "pgvector"))]
745fn forget_deleted_sources(
746 root: &Path,
747 gate: &crate::core::bm25_index::source_gate::SourceGate,
748 delete: impl FnOnce(&[String]) -> Result<(), String>,
749) {
750 const MAX_DELETED_SOURCES: usize = 8;
752 let mut gone: Vec<String> = gate
753 .refused_sources()
754 .filter(|source| !source.contains("://") && !root.join(source).exists())
755 .map(str::to_owned)
756 .collect();
757 gone.sort();
758 gone.truncate(MAX_DELETED_SOURCES);
759 if !gone.is_empty() && delete(&gone).is_err() {
760 tracing::debug!("[dense] rows of deleted sources could not be removed");
761 }
762}
763
764#[cfg(any(feature = "qdrant", feature = "pgvector"))]
765fn snippet_from_disk(
766 root: &Path,
767 rel_path: &str,
768 start_line: usize,
769 end_line: usize,
770 max_lines: usize,
771) -> String {
772 let Ok(path) = crate::core::pathjail::jail_path(&root.join(rel_path), root) else {
773 return String::new();
774 };
775 let Some(content) = crate::core::context_admission::stores::admitted_store_text(&path) else {
777 return String::new();
778 };
779 let lines: Vec<&str> = content.lines().collect();
780 if lines.is_empty() {
781 return String::new();
782 }
783 let start = start_line.saturating_sub(1).min(lines.len());
784 let end = end_line.max(start_line).min(lines.len());
785 let mut slice = &lines[start..end];
786 if slice.len() > max_lines {
787 slice = &slice[..max_lines];
788 }
789 slice.join("\n")
790}
791
792#[cfg(any(feature = "qdrant", feature = "pgvector"))]
793pub fn kind_to_str(kind: &ChunkKind) -> &'static str {
794 match kind {
795 ChunkKind::Function => "Function",
796 ChunkKind::Struct => "Struct",
797 ChunkKind::Impl => "Impl",
798 ChunkKind::Module => "Module",
799 ChunkKind::Class => "Class",
800 ChunkKind::Method => "Method",
801 ChunkKind::Issue => "Issue",
802 ChunkKind::PullRequest => "PullRequest",
803 ChunkKind::WikiPage => "WikiPage",
804 ChunkKind::DbSchema => "DbSchema",
805 ChunkKind::ApiEndpoint => "ApiEndpoint",
806 ChunkKind::Ticket => "Ticket",
807 ChunkKind::ExternalOther => "ExternalOther",
808 ChunkKind::Other => "Other",
809 }
810}
811
812#[cfg(any(feature = "qdrant", feature = "pgvector"))]
813pub fn kind_from_str(s: &str) -> ChunkKind {
814 match s {
815 "Function" => ChunkKind::Function,
816 "Struct" => ChunkKind::Struct,
817 "Impl" => ChunkKind::Impl,
818 "Module" => ChunkKind::Module,
819 "Class" => ChunkKind::Class,
820 "Method" => ChunkKind::Method,
821 "Issue" => ChunkKind::Issue,
822 "PullRequest" => ChunkKind::PullRequest,
823 "WikiPage" => ChunkKind::WikiPage,
824 "DbSchema" => ChunkKind::DbSchema,
825 "ApiEndpoint" => ChunkKind::ApiEndpoint,
826 "Ticket" => ChunkKind::Ticket,
827 "ExternalOther" => ChunkKind::ExternalOther,
828 _ => ChunkKind::Other,
829 }
830}
831
832#[cfg(test)]
833pub mod tests {
834 use super::*;
835 fn set_env(key: &str, value: Option<&str>) -> Option<String> {
836 let old = std::env::var(key).ok();
837 match value {
838 Some(v) => crate::test_env::set_var(key, v),
839 None => crate::test_env::remove_var(key),
840 }
841 old
842 }
843
844 fn restore_env(key: &str, old: Option<String>) {
845 match old {
846 Some(v) => crate::test_env::set_var(key, v),
847 None => crate::test_env::remove_var(key),
848 }
849 }
850
851 #[test]
852 fn dense_backend_defaults_to_local() {
853 let _g = crate::core::data_dir::test_env_lock();
854 let old_backend = set_env("LEANCTX_DENSE_BACKEND", None);
855 let old_url = set_env("LEANCTX_QDRANT_URL", None);
856 let old_pg = set_env("LEANCTX_PGVECTOR_URL", None);
857
858 let got = DenseBackendKind::try_from_env().unwrap();
859 assert_eq!(got, DenseBackendKind::Local);
860
861 restore_env("LEANCTX_DENSE_BACKEND", old_backend);
862 restore_env("LEANCTX_QDRANT_URL", old_url);
863 restore_env("LEANCTX_PGVECTOR_URL", old_pg);
864 }
865
866 #[test]
867 fn dense_backend_unknown_value_errors() {
868 let _g = crate::core::data_dir::test_env_lock();
869 let old_backend = set_env("LEANCTX_DENSE_BACKEND", Some("wat"));
870 let old_url = set_env("LEANCTX_QDRANT_URL", None);
871
872 let err = DenseBackendKind::try_from_env().unwrap_err();
873 assert!(err.contains("Unknown LEANCTX_DENSE_BACKEND"));
874
875 restore_env("LEANCTX_DENSE_BACKEND", old_backend);
876 restore_env("LEANCTX_QDRANT_URL", old_url);
877 }
878
879 #[cfg(feature = "qdrant")]
880 #[test]
881 fn dense_backend_infers_qdrant_from_url() {
882 let _g = crate::core::data_dir::test_env_lock();
883 let old_backend = set_env("LEANCTX_DENSE_BACKEND", None);
884 let old_url = set_env("LEANCTX_QDRANT_URL", Some("http://127.0.0.1:6333"));
885 let old_pg = set_env("LEANCTX_PGVECTOR_URL", None);
886
887 let got = DenseBackendKind::try_from_env().unwrap();
888 assert_eq!(got, DenseBackendKind::Qdrant);
889
890 restore_env("LEANCTX_DENSE_BACKEND", old_backend);
891 restore_env("LEANCTX_QDRANT_URL", old_url);
892 restore_env("LEANCTX_PGVECTOR_URL", old_pg);
893 }
894
895 #[cfg(feature = "pgvector")]
896 #[test]
897 fn dense_backend_infers_pgvector_from_url() {
898 let _g = crate::core::data_dir::test_env_lock();
899 let old_backend = set_env("LEANCTX_DENSE_BACKEND", None);
900 let old_url = set_env("LEANCTX_QDRANT_URL", None);
901 let old_pg = set_env("LEANCTX_PGVECTOR_URL", Some("postgres://localhost/lctx"));
902
903 let got = DenseBackendKind::try_from_env().unwrap();
904 assert_eq!(got, DenseBackendKind::Pgvector);
905
906 restore_env("LEANCTX_DENSE_BACKEND", old_backend);
907 restore_env("LEANCTX_QDRANT_URL", old_url);
908 restore_env("LEANCTX_PGVECTOR_URL", old_pg);
909 }
910
911 #[cfg(all(feature = "qdrant", feature = "pgvector"))]
912 #[test]
913 fn dense_backend_qdrant_wins_when_both_urls_set() {
914 let _g = crate::core::data_dir::test_env_lock();
915 let old_backend = set_env("LEANCTX_DENSE_BACKEND", None);
916 let old_url = set_env("LEANCTX_QDRANT_URL", Some("http://127.0.0.1:6333"));
917 let old_pg = set_env("LEANCTX_PGVECTOR_URL", Some("postgres://localhost/lctx"));
918
919 let got = DenseBackendKind::try_from_env().unwrap();
920 assert_eq!(got, DenseBackendKind::Qdrant);
921
922 crate::test_env::set_var("LEANCTX_DENSE_BACKEND", "pgvector");
924 let got = DenseBackendKind::try_from_env().unwrap();
925 assert_eq!(got, DenseBackendKind::Pgvector);
926
927 restore_env("LEANCTX_DENSE_BACKEND", old_backend);
928 restore_env("LEANCTX_QDRANT_URL", old_url);
929 restore_env("LEANCTX_PGVECTOR_URL", old_pg);
930 }
931
932 #[cfg(not(feature = "qdrant"))]
933 #[test]
934 fn dense_backend_qdrant_requires_feature() {
935 let _g = crate::core::data_dir::test_env_lock();
936 let old_backend = set_env("LEANCTX_DENSE_BACKEND", Some("qdrant"));
937 let old_url = set_env("LEANCTX_QDRANT_URL", None);
938
939 let err = DenseBackendKind::try_from_env().unwrap_err();
940 assert!(err.contains("feature 'qdrant' is not enabled"));
941
942 restore_env("LEANCTX_DENSE_BACKEND", old_backend);
943 restore_env("LEANCTX_QDRANT_URL", old_url);
944 }
945
946 #[cfg(not(feature = "pgvector"))]
947 #[test]
948 fn dense_backend_pgvector_requires_feature() {
949 let _g = crate::core::data_dir::test_env_lock();
950 let old_backend = set_env("LEANCTX_DENSE_BACKEND", Some("pgvector"));
951 let old_url = set_env("LEANCTX_QDRANT_URL", None);
952
953 let err = DenseBackendKind::try_from_env().unwrap_err();
954 assert!(err.contains("feature 'pgvector' is not enabled"));
955
956 restore_env("LEANCTX_DENSE_BACKEND", old_backend);
957 restore_env("LEANCTX_QDRANT_URL", old_url);
958 }
959}