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(&chunk.file_path, &chunk.content)
709 .then(|| chunk.content.lines().take(5).collect::<Vec<_>>().join("\n"));
710 }
711 let snippet = snippet_from_disk(root, row.source, row.start_line, row.end_line, 5);
712 gate.serves(row.source, &snippet).then_some(snippet)
713}
714
715#[cfg(any(feature = "qdrant", feature = "pgvector"))]
720fn resync_undigested(
721 mut sources: Vec<String>,
722 resync: impl FnOnce(&[String]) -> Result<(), String>,
723) {
724 const MAX_RESYNCED_SOURCES: usize = 8;
726 sources.sort();
727 sources.dedup();
728 sources.truncate(MAX_RESYNCED_SOURCES);
729 if !sources.is_empty() && resync(&sources).is_err() {
730 tracing::debug!("[dense] rows without a content digest could not be written again");
731 }
732}
733
734#[cfg(any(feature = "qdrant", feature = "pgvector"))]
741fn forget_deleted_sources(
742 root: &Path,
743 gate: &crate::core::bm25_index::source_gate::SourceGate,
744 delete: impl FnOnce(&[String]) -> Result<(), String>,
745) {
746 const MAX_DELETED_SOURCES: usize = 8;
748 let mut gone: Vec<String> = gate
749 .refused_sources()
750 .filter(|source| !source.contains("://") && !root.join(source).exists())
751 .map(str::to_owned)
752 .collect();
753 gone.sort();
754 gone.truncate(MAX_DELETED_SOURCES);
755 if !gone.is_empty() && delete(&gone).is_err() {
756 tracing::debug!("[dense] rows of deleted sources could not be removed");
757 }
758}
759
760#[cfg(any(feature = "qdrant", feature = "pgvector"))]
761fn snippet_from_disk(
762 root: &Path,
763 rel_path: &str,
764 start_line: usize,
765 end_line: usize,
766 max_lines: usize,
767) -> String {
768 let Ok(path) = crate::core::pathjail::jail_path(&root.join(rel_path), root) else {
769 return String::new();
770 };
771 let Some(content) = crate::core::context_admission::stores::admitted_store_text(&path) else {
773 return String::new();
774 };
775 let lines: Vec<&str> = content.lines().collect();
776 if lines.is_empty() {
777 return String::new();
778 }
779 let start = start_line.saturating_sub(1).min(lines.len());
780 let end = end_line.max(start_line).min(lines.len());
781 let mut slice = &lines[start..end];
782 if slice.len() > max_lines {
783 slice = &slice[..max_lines];
784 }
785 slice.join("\n")
786}
787
788#[cfg(any(feature = "qdrant", feature = "pgvector"))]
789pub fn kind_to_str(kind: &ChunkKind) -> &'static str {
790 match kind {
791 ChunkKind::Function => "Function",
792 ChunkKind::Struct => "Struct",
793 ChunkKind::Impl => "Impl",
794 ChunkKind::Module => "Module",
795 ChunkKind::Class => "Class",
796 ChunkKind::Method => "Method",
797 ChunkKind::Issue => "Issue",
798 ChunkKind::PullRequest => "PullRequest",
799 ChunkKind::WikiPage => "WikiPage",
800 ChunkKind::DbSchema => "DbSchema",
801 ChunkKind::ApiEndpoint => "ApiEndpoint",
802 ChunkKind::Ticket => "Ticket",
803 ChunkKind::ExternalOther => "ExternalOther",
804 ChunkKind::Other => "Other",
805 }
806}
807
808#[cfg(any(feature = "qdrant", feature = "pgvector"))]
809pub fn kind_from_str(s: &str) -> ChunkKind {
810 match s {
811 "Function" => ChunkKind::Function,
812 "Struct" => ChunkKind::Struct,
813 "Impl" => ChunkKind::Impl,
814 "Module" => ChunkKind::Module,
815 "Class" => ChunkKind::Class,
816 "Method" => ChunkKind::Method,
817 "Issue" => ChunkKind::Issue,
818 "PullRequest" => ChunkKind::PullRequest,
819 "WikiPage" => ChunkKind::WikiPage,
820 "DbSchema" => ChunkKind::DbSchema,
821 "ApiEndpoint" => ChunkKind::ApiEndpoint,
822 "Ticket" => ChunkKind::Ticket,
823 "ExternalOther" => ChunkKind::ExternalOther,
824 _ => ChunkKind::Other,
825 }
826}
827
828#[cfg(test)]
829pub mod tests {
830 use super::*;
831 fn set_env(key: &str, value: Option<&str>) -> Option<String> {
832 let old = std::env::var(key).ok();
833 match value {
834 Some(v) => crate::test_env::set_var(key, v),
835 None => crate::test_env::remove_var(key),
836 }
837 old
838 }
839
840 fn restore_env(key: &str, old: Option<String>) {
841 match old {
842 Some(v) => crate::test_env::set_var(key, v),
843 None => crate::test_env::remove_var(key),
844 }
845 }
846
847 #[test]
848 fn dense_backend_defaults_to_local() {
849 let _g = crate::core::data_dir::test_env_lock();
850 let old_backend = set_env("LEANCTX_DENSE_BACKEND", None);
851 let old_url = set_env("LEANCTX_QDRANT_URL", None);
852 let old_pg = set_env("LEANCTX_PGVECTOR_URL", None);
853
854 let got = DenseBackendKind::try_from_env().unwrap();
855 assert_eq!(got, DenseBackendKind::Local);
856
857 restore_env("LEANCTX_DENSE_BACKEND", old_backend);
858 restore_env("LEANCTX_QDRANT_URL", old_url);
859 restore_env("LEANCTX_PGVECTOR_URL", old_pg);
860 }
861
862 #[test]
863 fn dense_backend_unknown_value_errors() {
864 let _g = crate::core::data_dir::test_env_lock();
865 let old_backend = set_env("LEANCTX_DENSE_BACKEND", Some("wat"));
866 let old_url = set_env("LEANCTX_QDRANT_URL", None);
867
868 let err = DenseBackendKind::try_from_env().unwrap_err();
869 assert!(err.contains("Unknown LEANCTX_DENSE_BACKEND"));
870
871 restore_env("LEANCTX_DENSE_BACKEND", old_backend);
872 restore_env("LEANCTX_QDRANT_URL", old_url);
873 }
874
875 #[cfg(feature = "qdrant")]
876 #[test]
877 fn dense_backend_infers_qdrant_from_url() {
878 let _g = crate::core::data_dir::test_env_lock();
879 let old_backend = set_env("LEANCTX_DENSE_BACKEND", None);
880 let old_url = set_env("LEANCTX_QDRANT_URL", Some("http://127.0.0.1:6333"));
881 let old_pg = set_env("LEANCTX_PGVECTOR_URL", None);
882
883 let got = DenseBackendKind::try_from_env().unwrap();
884 assert_eq!(got, DenseBackendKind::Qdrant);
885
886 restore_env("LEANCTX_DENSE_BACKEND", old_backend);
887 restore_env("LEANCTX_QDRANT_URL", old_url);
888 restore_env("LEANCTX_PGVECTOR_URL", old_pg);
889 }
890
891 #[cfg(feature = "pgvector")]
892 #[test]
893 fn dense_backend_infers_pgvector_from_url() {
894 let _g = crate::core::data_dir::test_env_lock();
895 let old_backend = set_env("LEANCTX_DENSE_BACKEND", None);
896 let old_url = set_env("LEANCTX_QDRANT_URL", None);
897 let old_pg = set_env("LEANCTX_PGVECTOR_URL", Some("postgres://localhost/lctx"));
898
899 let got = DenseBackendKind::try_from_env().unwrap();
900 assert_eq!(got, DenseBackendKind::Pgvector);
901
902 restore_env("LEANCTX_DENSE_BACKEND", old_backend);
903 restore_env("LEANCTX_QDRANT_URL", old_url);
904 restore_env("LEANCTX_PGVECTOR_URL", old_pg);
905 }
906
907 #[cfg(all(feature = "qdrant", feature = "pgvector"))]
908 #[test]
909 fn dense_backend_qdrant_wins_when_both_urls_set() {
910 let _g = crate::core::data_dir::test_env_lock();
911 let old_backend = set_env("LEANCTX_DENSE_BACKEND", None);
912 let old_url = set_env("LEANCTX_QDRANT_URL", Some("http://127.0.0.1:6333"));
913 let old_pg = set_env("LEANCTX_PGVECTOR_URL", Some("postgres://localhost/lctx"));
914
915 let got = DenseBackendKind::try_from_env().unwrap();
916 assert_eq!(got, DenseBackendKind::Qdrant);
917
918 crate::test_env::set_var("LEANCTX_DENSE_BACKEND", "pgvector");
920 let got = DenseBackendKind::try_from_env().unwrap();
921 assert_eq!(got, DenseBackendKind::Pgvector);
922
923 restore_env("LEANCTX_DENSE_BACKEND", old_backend);
924 restore_env("LEANCTX_QDRANT_URL", old_url);
925 restore_env("LEANCTX_PGVECTOR_URL", old_pg);
926 }
927
928 #[cfg(not(feature = "qdrant"))]
929 #[test]
930 fn dense_backend_qdrant_requires_feature() {
931 let _g = crate::core::data_dir::test_env_lock();
932 let old_backend = set_env("LEANCTX_DENSE_BACKEND", Some("qdrant"));
933 let old_url = set_env("LEANCTX_QDRANT_URL", None);
934
935 let err = DenseBackendKind::try_from_env().unwrap_err();
936 assert!(err.contains("feature 'qdrant' is not enabled"));
937
938 restore_env("LEANCTX_DENSE_BACKEND", old_backend);
939 restore_env("LEANCTX_QDRANT_URL", old_url);
940 }
941
942 #[cfg(not(feature = "pgvector"))]
943 #[test]
944 fn dense_backend_pgvector_requires_feature() {
945 let _g = crate::core::data_dir::test_env_lock();
946 let old_backend = set_env("LEANCTX_DENSE_BACKEND", Some("pgvector"));
947 let old_url = set_env("LEANCTX_QDRANT_URL", None);
948
949 let err = DenseBackendKind::try_from_env().unwrap_err();
950 assert!(err.contains("feature 'pgvector' is not enabled"));
951
952 restore_env("LEANCTX_DENSE_BACKEND", old_backend);
953 restore_env("LEANCTX_QDRANT_URL", old_url);
954 }
955}