Skip to main content

ragfs_store/
lancedb.rs

1//! `LanceDB` implementation of `VectorStore`.
2
3use arrow_array::{
4    Array, ArrayRef, FixedSizeListArray, Float32Array, RecordBatch, RecordBatchIterator,
5    RecordBatchReader, StringArray, UInt8Array, UInt16Array, UInt32Array, UInt64Array,
6};
7use arrow_schema::{DataType, Field, Schema};
8use async_trait::async_trait;
9use chrono::Utc;
10use futures::TryStreamExt;
11use lancedb::index::Index;
12use lancedb::index::scalar::{FtsIndexBuilder, FullTextSearchQuery};
13use lancedb::index::vector::IvfPqIndexBuilder;
14use lancedb::query::{ExecutableQuery, QueryBase, QueryExecutionOptions};
15use lancedb::table::{NewColumnTransform, OptimizeAction, OptimizeOptions};
16use lancedb::{Connection, DistanceType, Table, connect};
17use ragfs_core::{
18    Chunk, ChunkMetadata, ContentType, DirectoryScope, DistanceMetric, FileRecord, FileStatus,
19    SearchFilter, SearchQuery, SearchResult, StoreError, StoreStats, VectorStore,
20};
21use std::collections::{HashMap, HashSet};
22use std::path::{Path, PathBuf};
23use std::sync::Arc;
24
25use crate::schema::{
26    CHUNKS_SCHEMA_VERSION, SCHEMA_SIDECAR_FILENAME, SchemaSidecar, missing_scope_columns,
27};
28use tokio::sync::RwLock;
29use tracing::{debug, info, warn};
30use uuid::Uuid;
31
32const CHUNKS_TABLE: &str = "chunks";
33const FILES_TABLE: &str = "files";
34/// Lance IVF training uses a default sample rate of 256. Below this, stay on exact scan.
35const MIN_ANN_ROWS: usize = 256;
36/// Product default search metric (`gte-small`). Train IVF-PQ with this so ANN is valid.
37const ANN_INDEX_METRIC: DistanceMetric = DistanceMetric::Cosine;
38
39/// LanceDB-based vector store.
40pub struct LanceStore {
41    /// Path to the `LanceDB` database
42    db_path: PathBuf,
43    /// Embedding dimension
44    embedding_dim: usize,
45    /// Embedder model id that should match this index, when known.
46    embedding_model: Option<String>,
47    /// Database connection (lazy initialized)
48    connection: RwLock<Option<Connection>>,
49    /// Chunks table handle
50    chunks_table: RwLock<Option<Table>>,
51    /// Files table handle
52    files_table: RwLock<Option<Table>>,
53}
54
55impl LanceStore {
56    /// Create a new `LanceStore`.
57    ///
58    /// `embedding_dim` must come from `Embedder::dimension` at the call site.
59    /// Opening an existing index whose `vector` column width differs fails in `init`.
60    #[must_use]
61    pub fn new(db_path: PathBuf, embedding_dim: usize) -> Self {
62        Self {
63            db_path,
64            embedding_dim,
65            embedding_model: None,
66            connection: RwLock::new(None),
67            chunks_table: RwLock::new(None),
68            files_table: RwLock::new(None),
69        }
70    }
71
72    /// Record the embedder model id in the schema sidecar.
73    #[must_use]
74    pub fn with_embedding_model(mut self, model: impl Into<String>) -> Self {
75        self.embedding_model = Some(model.into());
76        self
77    }
78
79    /// Get the database path.
80    pub fn db_path(&self) -> &Path {
81        &self.db_path
82    }
83
84    /// Get the embedding dimension.
85    pub fn embedding_dim(&self) -> usize {
86        self.embedding_dim
87    }
88
89    /// Width of the chunks `vector` `FixedSizeList`.
90    fn stored_embedding_dim(schema: &Schema) -> Result<usize, StoreError> {
91        let field = schema.field_with_name("vector").map_err(|_| {
92            StoreError::Schema("chunks table is missing the 'vector' column".into())
93        })?;
94        match field.data_type() {
95            DataType::FixedSizeList(_, size) => Ok(*size as usize),
96            other => Err(StoreError::Schema(format!(
97                "chunks 'vector' column has type {other:?}, expected FixedSizeList"
98            ))),
99        }
100    }
101
102    fn ensure_embedding_dim_matches(&self, schema: &Schema) -> Result<(), StoreError> {
103        let stored = Self::stored_embedding_dim(schema)?;
104        if stored != self.embedding_dim {
105            return Err(StoreError::Schema(format!(
106                "Index embedding dimension {stored} does not match embedder dimension {}",
107                self.embedding_dim
108            )));
109        }
110        Ok(())
111    }
112
113    /// Whether the table is large enough for Lance IVF-PQ training.
114    pub(crate) fn should_build_ann_index(row_count: usize) -> bool {
115        row_count >= MIN_ANN_ROWS
116    }
117
118    /// Refresh IVF-PQ once the unindexed tail is large enough to train another sample.
119    pub(crate) fn should_refresh_ann_index(unindexed_rows: usize) -> bool {
120        unindexed_rows >= MIN_ANN_ROWS
121    }
122
123    /// ANN is trained as cosine; other metrics must not use the index.
124    pub(crate) fn ann_index_covers_metric(metric: DistanceMetric) -> bool {
125        metric == ANN_INDEX_METRIC
126    }
127
128    /// Reuse/refresh only a cosine-trained vector index.
129    pub(crate) fn ann_index_is_cosine(distance_type: Option<DistanceType>) -> bool {
130        distance_type == Some(DistanceType::Cosine)
131    }
132
133    async fn vector_index_name(table: &Table) -> Option<String> {
134        match table.list_indices().await {
135            Ok(indices) => indices
136                .into_iter()
137                .find(|idx| idx.columns.iter().any(|col| col == "vector"))
138                .map(|idx| idx.name),
139            Err(_) => None,
140        }
141    }
142
143    async fn refresh_vector_index(table: &Table, index_name: &str) {
144        let unindexed = match table.index_stats(index_name).await {
145            Ok(Some(stats)) => stats.num_unindexed_rows,
146            Ok(None) => return,
147            Err(e) => {
148                debug!("Skipping ANN refresh; could not read index stats: {e}");
149                return;
150            }
151        };
152
153        if !Self::should_refresh_ann_index(unindexed) {
154            debug!("Skipping ANN refresh: {unindexed} unindexed rows < {MIN_ANN_ROWS}");
155            return;
156        }
157
158        info!("Refreshing IVF-PQ ANN index ({unindexed} unindexed rows)");
159        match table
160            .optimize(OptimizeAction::Index(
161                OptimizeOptions::append().index_names(vec![index_name.to_string()]),
162            ))
163            .await
164        {
165            Ok(_) => info!("IVF-PQ ANN index refreshed"),
166            Err(e) => warn!("IVF-PQ ANN index refresh failed (unindexed tail stays exact): {e}"),
167        }
168    }
169
170    /// Best-effort IVF-PQ on `vector`. Small tables keep exact scan.
171    /// Existing cosine indexes are incrementally refreshed after large appends.
172    /// A non-cosine vector index is dropped and replaced when the table is large enough.
173    async fn ensure_vector_index(&self) -> Result<(), StoreError> {
174        let table = self.get_chunks_table().await?;
175        if let Some(name) = Self::vector_index_name(&table).await {
176            match table.index_stats(&name).await {
177                Ok(Some(stats)) if Self::ann_index_is_cosine(stats.distance_type) => {
178                    Self::refresh_vector_index(&table, &name).await;
179                    return Ok(());
180                }
181                Ok(Some(stats)) => {
182                    info!(
183                        "Replacing vector index trained as {:?} with cosine IVF-PQ",
184                        stats.distance_type
185                    );
186                    if let Err(e) = table.drop_index(&name).await {
187                        warn!("Could not drop mismatched vector index (search may bypass): {e}");
188                        return Ok(());
189                    }
190                }
191                Ok(None) => {}
192                Err(e) => {
193                    debug!("Skipping ANN reuse; could not read index stats: {e}");
194                    return Ok(());
195                }
196            }
197        }
198
199        let count = match table.count_rows(None).await {
200            Ok(n) => n,
201            Err(e) => {
202                debug!("Skipping ANN index; could not count rows: {e}");
203                return Ok(());
204            }
205        };
206
207        if !Self::should_build_ann_index(count) {
208            debug!("Skipping IVF-PQ ANN index: {count} rows < {MIN_ANN_ROWS} (exact scan)");
209            return Ok(());
210        }
211
212        info!("Creating IVF-PQ ANN index on vector ({count} rows, cosine)");
213        match table
214            .create_index(
215                &["vector"],
216                Index::IvfPq(IvfPqIndexBuilder::default().distance_type(DistanceType::Cosine)),
217            )
218            .execute()
219            .await
220        {
221            Ok(()) => info!("IVF-PQ ANN index ready"),
222            Err(e) => warn!("IVF-PQ ANN index not created (search stays exact scan): {e}"),
223        }
224        Ok(())
225    }
226
227    /// Get or create connection.
228    async fn get_connection(&self) -> Result<Connection, StoreError> {
229        {
230            let conn = self.connection.read().await;
231            if let Some(ref c) = *conn {
232                return Ok(c.clone());
233            }
234        }
235
236        let mut conn = self.connection.write().await;
237        if conn.is_none() {
238            let db_path_str = self.db_path.to_string_lossy().to_string();
239            let new_conn = connect(&db_path_str)
240                .execute()
241                .await
242                .map_err(|e| StoreError::Init(format!("Failed to connect to LanceDB: {e}")))?;
243            *conn = Some(new_conn);
244        }
245        Ok(conn.as_ref().unwrap().clone())
246    }
247
248    /// Build chunks table schema.
249    fn chunks_schema(&self) -> Schema {
250        Schema::new(vec![
251            Field::new("chunk_id", DataType::Utf8, false),
252            Field::new("file_id", DataType::Utf8, false),
253            Field::new("file_path", DataType::Utf8, false),
254            Field::new("content", DataType::Utf8, false),
255            Field::new("content_type", DataType::Utf8, false),
256            Field::new("chunk_index", DataType::UInt32, false),
257            Field::new("start_byte", DataType::UInt64, false),
258            Field::new("end_byte", DataType::UInt64, false),
259            Field::new("start_line", DataType::UInt32, true),
260            Field::new("end_line", DataType::UInt32, true),
261            Field::new("parent_chunk_id", DataType::Utf8, true),
262            Field::new("depth", DataType::UInt8, false),
263            Field::new(
264                "vector",
265                DataType::FixedSizeList(
266                    Arc::new(Field::new("item", DataType::Float32, true)),
267                    self.embedding_dim as i32,
268                ),
269                false,
270            ),
271            Field::new("embedding_model", DataType::Utf8, true),
272            Field::new("indexed_at", DataType::Utf8, false),
273            Field::new("file_mime_type", DataType::Utf8, true),
274            Field::new("language", DataType::Utf8, true),
275            Field::new("symbol_type", DataType::Utf8, true),
276            Field::new("symbol_name", DataType::Utf8, true),
277            Field::new("dir_path", DataType::Utf8, false),
278            Field::new("dir_depth", DataType::UInt16, false),
279            Field::new("path_components", DataType::Utf8, false),
280        ])
281    }
282
283    /// Build files table schema.
284    fn files_schema(&self) -> Schema {
285        Schema::new(vec![
286            Field::new("file_id", DataType::Utf8, false),
287            Field::new("path", DataType::Utf8, false),
288            Field::new("size_bytes", DataType::UInt64, false),
289            Field::new("mime_type", DataType::Utf8, false),
290            Field::new("content_hash", DataType::Utf8, false),
291            Field::new("modified_at", DataType::Utf8, false),
292            Field::new("indexed_at", DataType::Utf8, true),
293            Field::new("chunk_count", DataType::UInt32, false),
294            Field::new("status", DataType::Utf8, false),
295            Field::new("error_message", DataType::Utf8, true),
296        ])
297    }
298
299    /// Get or open chunks table.
300    async fn get_chunks_table(&self) -> Result<Table, StoreError> {
301        {
302            let table = self.chunks_table.read().await;
303            if let Some(ref t) = *table {
304                return Ok(t.clone());
305            }
306        }
307
308        let conn = self.get_connection().await?;
309        let mut table_lock = self.chunks_table.write().await;
310
311        if table_lock.is_none() {
312            let t = conn
313                .open_table(CHUNKS_TABLE)
314                .execute()
315                .await
316                .map_err(|e| StoreError::Init(format!("Failed to open chunks table: {e}")))?;
317            *table_lock = Some(t);
318        }
319
320        Ok(table_lock.as_ref().unwrap().clone())
321    }
322
323    /// Get or open files table.
324    async fn get_files_table(&self) -> Result<Table, StoreError> {
325        {
326            let table = self.files_table.read().await;
327            if let Some(ref t) = *table {
328                return Ok(t.clone());
329            }
330        }
331
332        let conn = self.get_connection().await?;
333        let mut table_lock = self.files_table.write().await;
334
335        if table_lock.is_none() {
336            let t = conn
337                .open_table(FILES_TABLE)
338                .execute()
339                .await
340                .map_err(|e| StoreError::Init(format!("Failed to open files table: {e}")))?;
341            *table_lock = Some(t);
342        }
343
344        Ok(table_lock.as_ref().unwrap().clone())
345    }
346
347    fn schema_sidecar_path(&self) -> PathBuf {
348        self.db_path
349            .parent()
350            .unwrap_or(self.db_path.as_path())
351            .join(SCHEMA_SIDECAR_FILENAME)
352    }
353
354    async fn read_schema_sidecar(&self) -> Option<u32> {
355        let bytes = tokio::fs::read(self.schema_sidecar_path()).await.ok()?;
356        serde_json::from_slice::<SchemaSidecar>(&bytes)
357            .ok()
358            .map(|s| s.chunks_schema_version)
359    }
360
361    async fn write_schema_sidecar(&self, version: u32) -> Result<(), StoreError> {
362        let path = self.schema_sidecar_path();
363        if let Some(parent) = path.parent() {
364            tokio::fs::create_dir_all(parent).await.map_err(|e| {
365                StoreError::Schema(format!(
366                    "Failed to create schema sidecar directory {}: {e}",
367                    parent.display()
368                ))
369            })?;
370        }
371        let json = serde_json::to_string_pretty(&SchemaSidecar {
372            chunks_schema_version: version,
373            embedding_dim: Some(self.embedding_dim),
374            embedding_model: self.embedding_model.clone(),
375        })
376        .map_err(|e| StoreError::Schema(format!("Failed to serialize schema sidecar: {e}")))?;
377        tokio::fs::write(&path, json).await.map_err(|e| {
378            StoreError::Schema(format!(
379                "Failed to write schema sidecar {}: {e}",
380                path.display()
381            ))
382        })?;
383        Ok(())
384    }
385
386    fn schema_migration_failed_message(db_path: &Path, error: &impl std::fmt::Display) -> String {
387        let index_dir = db_path.parent().unwrap_or(db_path);
388        format!(
389            "Existing Lance index is missing directory-scope columns \
390             (dir_path, dir_depth, path_components) and automatic schema \
391             migration to v{CHUNKS_SCHEMA_VERSION} failed ({error}). \
392             The index was not deleted. Remove `{}` and run \
393             `ragfs index <dir> --force` to rebuild.",
394            index_dir.display()
395        )
396    }
397
398    /// Detect a pre-v2 chunks table, add scope columns, and backfill from `file_path`.
399    ///
400    /// `--force` rewrites index-root-relative `dir_path` on new upserts; it cannot
401    /// add Lance columns. This path is what existing indexes need on upgrade.
402    async fn ensure_chunks_schema_v2(&self) -> Result<(), StoreError> {
403        let table = {
404            let conn = self.get_connection().await?;
405            conn.open_table(CHUNKS_TABLE)
406                .execute()
407                .await
408                .map_err(|e| StoreError::Init(format!("Failed to open chunks table: {e}")))?
409        };
410
411        let schema = table
412            .schema()
413            .await
414            .map_err(|e| StoreError::Schema(format!("Failed to read chunks schema: {e}")))?;
415        self.ensure_embedding_dim_matches(&schema)?;
416        let missing = missing_scope_columns(&schema);
417        let sidecar_version = self.read_schema_sidecar().await.unwrap_or(0);
418
419        if missing.is_empty() && sidecar_version >= CHUNKS_SCHEMA_VERSION {
420            return Ok(());
421        }
422
423        if !missing.is_empty() {
424            info!(
425                "Migrating chunks table to schema v{CHUNKS_SCHEMA_VERSION} \
426                 (adding directory-scope columns)"
427            );
428            table
429                .add_columns()
430                .transform(NewColumnTransform::AllNulls(Arc::new(Schema::new(missing))))
431                .execute()
432                .await
433                .map_err(|e| {
434                    StoreError::Schema(Self::schema_migration_failed_message(&self.db_path, &e))
435                })?;
436
437            // Drop the cached handle so later ops see the evolved schema.
438            *self.chunks_table.write().await = None;
439        }
440
441        self.backfill_scope_columns().await?;
442        self.write_schema_sidecar(CHUNKS_SCHEMA_VERSION).await?;
443        info!("Chunks table schema is v{CHUNKS_SCHEMA_VERSION}");
444        Ok(())
445    }
446
447    async fn backfill_scope_columns(&self) -> Result<(), StoreError> {
448        let table = self.get_chunks_table().await?;
449        let mut results = table
450            .query()
451            .only_if("dir_path IS NULL OR dir_path = ''")
452            .execute()
453            .await
454            .map_err(|e| {
455                StoreError::Schema(format!(
456                    "Failed to scan chunks for directory-scope backfill: {e}"
457                ))
458            })?;
459
460        let mut paths = HashSet::new();
461        while let Some(batch) = results.try_next().await.map_err(|e| {
462            StoreError::Schema(format!(
463                "Failed to read chunks for directory-scope backfill: {e}"
464            ))
465        })? {
466            let Some(file_paths) = batch
467                .column_by_name("file_path")
468                .and_then(|c| c.as_any().downcast_ref::<StringArray>())
469            else {
470                continue;
471            };
472            for i in 0..batch.num_rows() {
473                if !file_paths.is_null(i) {
474                    paths.insert(file_paths.value(i).to_string());
475                }
476            }
477        }
478
479        if paths.is_empty() {
480            return Ok(());
481        }
482
483        info!(
484            "Backfilling directory-scope columns for {} file path(s)",
485            paths.len()
486        );
487        for path in paths {
488            let scope = DirectoryScope::from_file_path(Path::new(&path));
489            table
490                .update()
491                .only_if(format!("file_path = '{}'", escape_sql_literal(&path)))
492                .column(
493                    "dir_path",
494                    format!("'{}'", escape_sql_literal(&scope.dir_path)),
495                )
496                .column("dir_depth", scope.dir_depth.to_string())
497                .column(
498                    "path_components",
499                    format!("'{}'", escape_sql_literal(&scope.path_components)),
500                )
501                .execute()
502                .await
503                .map_err(|e| {
504                    StoreError::Schema(format!(
505                        "Failed to backfill directory-scope columns for '{path}': {e}"
506                    ))
507                })?;
508        }
509        Ok(())
510    }
511
512    /// Convert chunks to Arrow `RecordBatch`.
513    fn chunks_to_batch(&self, chunks: &[Chunk]) -> Result<RecordBatch, StoreError> {
514        let chunk_ids: Vec<_> = chunks.iter().map(|c| c.id.to_string()).collect();
515        let file_ids: Vec<_> = chunks.iter().map(|c| c.file_id.to_string()).collect();
516        let file_paths: Vec<_> = chunks
517            .iter()
518            .map(|c| c.file_path.to_string_lossy().to_string())
519            .collect();
520        let contents: Vec<_> = chunks.iter().map(|c| c.content.clone()).collect();
521        let content_types: Vec<_> = chunks
522            .iter()
523            .map(|c| content_type_to_string(&c.content_type))
524            .collect();
525        let chunk_indices: Vec<_> = chunks.iter().map(|c| c.chunk_index).collect();
526        let start_bytes: Vec<_> = chunks.iter().map(|c| c.byte_range.start).collect();
527        let end_bytes: Vec<_> = chunks.iter().map(|c| c.byte_range.end).collect();
528        let start_lines: Vec<_> = chunks
529            .iter()
530            .map(|c| c.line_range.as_ref().map(|r| r.start))
531            .collect();
532        let end_lines: Vec<_> = chunks
533            .iter()
534            .map(|c| c.line_range.as_ref().map(|r| r.end))
535            .collect();
536        let parent_ids: Vec<_> = chunks
537            .iter()
538            .map(|c| c.parent_chunk_id.map(|id| id.to_string()))
539            .collect();
540        let depths: Vec<_> = chunks.iter().map(|c| c.depth).collect();
541
542        // Build embeddings as FixedSizeList
543        let embeddings: Vec<Option<Vec<Option<f32>>>> = chunks
544            .iter()
545            .map(|c| {
546                c.embedding
547                    .as_ref()
548                    .map(|e| e.iter().map(|&v| Some(v)).collect())
549            })
550            .collect();
551
552        let embedding_models: Vec<_> = chunks
553            .iter()
554            .map(|c| c.metadata.embedding_model.clone())
555            .collect();
556        let indexed_ats: Vec<_> = chunks
557            .iter()
558            .map(|c| {
559                c.metadata
560                    .indexed_at
561                    .map_or_else(|| Utc::now().to_rfc3339(), |t| t.to_rfc3339())
562            })
563            .collect();
564
565        // Extract language/symbol info from content_type
566        let languages: Vec<_> = chunks
567            .iter()
568            .map(|c| match &c.content_type {
569                ContentType::Code { language, .. } => Some(language.clone()),
570                _ => None,
571            })
572            .collect();
573        let symbol_types: Vec<_> = chunks
574            .iter()
575            .map(|c| match &c.content_type {
576                ContentType::Code { symbol, .. } => {
577                    symbol.as_ref().map(|s| format!("{:?}", s.kind))
578                }
579                _ => None,
580            })
581            .collect();
582        let symbol_names: Vec<_> = chunks
583            .iter()
584            .map(|c| match &c.content_type {
585                ContentType::Code { symbol, .. } => symbol.as_ref().map(|s| s.name.clone()),
586                _ => None,
587            })
588            .collect();
589
590        let mime_types: Vec<Option<String>> = chunks.iter().map(|c| c.mime_type.clone()).collect();
591
592        let dir_paths: Vec<_> = chunks.iter().map(|c| c.dir_path.clone()).collect();
593        let dir_depths: Vec<_> = chunks.iter().map(|c| c.dir_depth).collect();
594        let path_components: Vec<_> = chunks.iter().map(|c| c.path_components.clone()).collect();
595
596        // Build arrays
597        let schema = Arc::new(self.chunks_schema());
598
599        let vector_array = build_vector_array(&embeddings, self.embedding_dim)?;
600
601        let batch = RecordBatch::try_new(
602            schema,
603            vec![
604                Arc::new(StringArray::from(chunk_ids)),
605                Arc::new(StringArray::from(file_ids)),
606                Arc::new(StringArray::from(file_paths)),
607                Arc::new(StringArray::from(contents)),
608                Arc::new(StringArray::from(content_types)),
609                Arc::new(UInt32Array::from(chunk_indices)),
610                Arc::new(UInt64Array::from(start_bytes)),
611                Arc::new(UInt64Array::from(end_bytes)),
612                Arc::new(UInt32Array::from(start_lines)),
613                Arc::new(UInt32Array::from(end_lines)),
614                Arc::new(StringArray::from(parent_ids)),
615                Arc::new(UInt8Array::from(depths)),
616                vector_array,
617                Arc::new(StringArray::from(embedding_models)),
618                Arc::new(StringArray::from(indexed_ats)),
619                Arc::new(StringArray::from(mime_types.clone())),
620                Arc::new(StringArray::from(languages)),
621                Arc::new(StringArray::from(symbol_types)),
622                Arc::new(StringArray::from(symbol_names)),
623                Arc::new(StringArray::from(dir_paths)),
624                Arc::new(UInt16Array::from(dir_depths)),
625                Arc::new(StringArray::from(path_components)),
626            ],
627        )
628        .map_err(|e| StoreError::Insert(format!("Failed to create RecordBatch: {e}")))?;
629
630        Ok(batch)
631    }
632
633    /// Convert file record to Arrow `RecordBatch`.
634    fn file_to_batch(&self, record: &FileRecord) -> Result<RecordBatch, StoreError> {
635        let schema = Arc::new(self.files_schema());
636
637        let batch = RecordBatch::try_new(
638            schema,
639            vec![
640                Arc::new(StringArray::from(vec![record.id.to_string()])),
641                Arc::new(StringArray::from(vec![
642                    record.path.to_string_lossy().to_string(),
643                ])),
644                Arc::new(UInt64Array::from(vec![record.size_bytes])),
645                Arc::new(StringArray::from(vec![record.mime_type.clone()])),
646                Arc::new(StringArray::from(vec![record.content_hash.clone()])),
647                Arc::new(StringArray::from(vec![record.modified_at.to_rfc3339()])),
648                Arc::new(StringArray::from(vec![
649                    record.indexed_at.map(|t| t.to_rfc3339()),
650                ])),
651                Arc::new(UInt32Array::from(vec![record.chunk_count])),
652                Arc::new(StringArray::from(vec![status_to_string(&record.status)])),
653                Arc::new(StringArray::from(vec![record.error_message.clone()])),
654            ],
655        )
656        .map_err(|e| StoreError::Insert(format!("Failed to create file RecordBatch: {e}")))?;
657
658        Ok(batch)
659    }
660
661    /// Combine chunk-column filters with source-file `modified_at` constraints.
662    async fn chunk_filter_sql(
663        &self,
664        filters: &[SearchFilter],
665    ) -> Result<Option<String>, StoreError> {
666        Ok(combine_predicates(
667            filters_to_sql(filters),
668            self.modified_at_path_predicate(filters).await?,
669        ))
670    }
671
672    /// Resolve `ModifiedAfter` / `ModifiedBefore` against the files table.
673    async fn modified_at_path_predicate(
674        &self,
675        filters: &[SearchFilter],
676    ) -> Result<Option<String>, StoreError> {
677        let Some(date_sql) = file_date_filters_to_sql(filters) else {
678            return Ok(None);
679        };
680
681        let table = self.get_files_table().await?;
682        let mut results = table
683            .query()
684            .only_if(date_sql)
685            .execute()
686            .await
687            .map_err(|e| StoreError::Query(format!("Failed to apply modified_at filter: {e}")))?;
688
689        let mut paths = Vec::new();
690        while let Some(batch) = results.try_next().await.map_err(|e| {
691            StoreError::Query(format!("Failed to fetch files for modified_at filter: {e}"))
692        })? {
693            for record in batch_to_file_records(&batch)? {
694                paths.push(record.path.to_string_lossy().to_string());
695            }
696        }
697
698        if paths.is_empty() {
699            return Ok(Some("1 = 0".to_string()));
700        }
701
702        let in_list = paths
703            .iter()
704            .map(|p| format!("'{}'", escape_sql_literal(p)))
705            .collect::<Vec<_>>()
706            .join(", ");
707        Ok(Some(format!("file_path IN ({in_list})")))
708    }
709}
710
711#[async_trait]
712impl VectorStore for LanceStore {
713    async fn init(&self) -> Result<(), StoreError> {
714        info!("Initializing LanceDB at {:?}", self.db_path);
715
716        // Ensure directory exists
717        if let Some(parent) = self.db_path.parent() {
718            tokio::fs::create_dir_all(parent)
719                .await
720                .map_err(|e| StoreError::Init(format!("Failed to create db directory: {e}")))?;
721        }
722
723        let conn = self.get_connection().await?;
724
725        // Check existing tables
726        let tables = conn
727            .table_names()
728            .execute()
729            .await
730            .map_err(|e| StoreError::Init(format!("Failed to list tables: {e}")))?;
731
732        if tables.contains(&CHUNKS_TABLE.to_string()) {
733            self.ensure_chunks_schema_v2().await?;
734        } else {
735            info!("Creating chunks table");
736            let schema = Arc::new(self.chunks_schema());
737            conn.create_empty_table(CHUNKS_TABLE, schema)
738                .execute()
739                .await
740                .map_err(|e| StoreError::Init(format!("Failed to create chunks table: {e}")))?;
741
742            // Create FTS index on content column for hybrid search
743            info!("Creating FTS index on content column");
744            let table = conn
745                .open_table(CHUNKS_TABLE)
746                .execute()
747                .await
748                .map_err(|e| StoreError::Init(format!("Failed to open chunks table: {e}")))?;
749
750            if let Err(e) = table
751                .create_index(&["content"], Index::FTS(FtsIndexBuilder::default()))
752                .execute()
753                .await
754            {
755                warn!("Failed to create FTS index (may already exist): {e}");
756            }
757
758            self.write_schema_sidecar(CHUNKS_SCHEMA_VERSION).await?;
759        }
760
761        // Create files table if not exists
762        if !tables.contains(&FILES_TABLE.to_string()) {
763            info!("Creating files table");
764            let schema = Arc::new(self.files_schema());
765            conn.create_empty_table(FILES_TABLE, schema)
766                .execute()
767                .await
768                .map_err(|e| StoreError::Init(format!("Failed to create files table: {e}")))?;
769        }
770
771        // Existing tables may already have enough rows for ANN.
772        if let Err(e) = self.ensure_vector_index().await {
773            warn!("ANN index check on init failed: {e}");
774        }
775
776        info!("LanceDB initialized successfully");
777        Ok(())
778    }
779
780    async fn upsert_chunks(&self, chunks: &[Chunk]) -> Result<(), StoreError> {
781        if chunks.is_empty() {
782            return Ok(());
783        }
784
785        debug!("Upserting {} chunks", chunks.len());
786
787        let table = self.get_chunks_table().await?;
788        let batch = self.chunks_to_batch(chunks)?;
789        let schema = batch.schema();
790
791        let batches = RecordBatchIterator::new(vec![Ok(batch)], schema);
792
793        table
794            .add(Box::new(batches) as Box<dyn RecordBatchReader + Send>)
795            .execute()
796            .await
797            .map_err(|e| StoreError::Insert(format!("Failed to insert chunks: {e}")))?;
798
799        debug!("Successfully upserted {} chunks", chunks.len());
800        if let Err(e) = self.ensure_vector_index().await {
801            warn!("ANN index check after upsert failed: {e}");
802        }
803        Ok(())
804    }
805
806    async fn search(&self, query: SearchQuery) -> Result<Vec<SearchResult>, StoreError> {
807        debug!(
808            "Searching with limit {} metric {:?} filters {}",
809            query.limit,
810            query.metric,
811            query.filters.len()
812        );
813
814        let table = self.get_chunks_table().await?;
815        let filter_sql = combine_predicates(
816            self.chunk_filter_sql(&query.filters).await?,
817            query.scope_prefix.as_deref().and_then(scope_prefix_sql),
818        );
819
820        let mut search_q = table
821            .vector_search(query.embedding.clone())
822            .map_err(|e| StoreError::Query(format!("Failed to create search query: {e}")))?
823            .distance_type(distance_type_from_metric(query.metric))
824            .limit(query.limit);
825
826        if !Self::ann_index_covers_metric(query.metric) {
827            search_q = search_q.bypass_vector_index();
828        }
829
830        if let Some(ref filter) = filter_sql {
831            debug!("Applying search filter: {filter}");
832            search_q = search_q.only_if(filter);
833        }
834
835        let mut results = search_q
836            .execute()
837            .await
838            .map_err(|e| StoreError::Query(format!("Failed to execute search: {e}")))?;
839
840        let mut search_results = Vec::new();
841
842        while let Some(batch) = results
843            .try_next()
844            .await
845            .map_err(|e| StoreError::Query(format!("Failed to fetch results: {e}")))?
846        {
847            search_results.extend(batch_to_search_results(&batch)?);
848        }
849
850        debug!("Found {} results", search_results.len());
851        Ok(search_results)
852    }
853
854    async fn hybrid_search(&self, query: SearchQuery) -> Result<Vec<SearchResult>, StoreError> {
855        // If no text query provided, fall back to vector-only search
856        let query_text = match &query.text {
857            Some(text) if !text.is_empty() => text.clone(),
858            _ => return self.search(query).await,
859        };
860
861        debug!(
862            "Performing hybrid search with text: '{}' limit {} metric {:?} filters {}",
863            query_text,
864            query.limit,
865            query.metric,
866            query.filters.len()
867        );
868
869        let table = self.get_chunks_table().await?;
870        let filter_sql = combine_predicates(
871            self.chunk_filter_sql(&query.filters).await?,
872            query.scope_prefix.as_deref().and_then(scope_prefix_sql),
873        );
874
875        // Build hybrid query combining FTS and vector search
876        let fts_query = FullTextSearchQuery::new(query_text);
877
878        let mut search_q = table
879            .query()
880            .full_text_search(fts_query)
881            .nearest_to(query.embedding.clone())
882            .map_err(|e| StoreError::Query(format!("Failed to create hybrid query: {e}")))?
883            .distance_type(distance_type_from_metric(query.metric))
884            .limit(query.limit);
885
886        if !Self::ann_index_covers_metric(query.metric) {
887            search_q = search_q.bypass_vector_index();
888        }
889
890        if let Some(ref filter) = filter_sql {
891            debug!("Applying hybrid search filter: {filter}");
892            search_q = search_q.only_if(filter);
893        }
894
895        let mut results = search_q
896            .execute_hybrid(QueryExecutionOptions::default())
897            .await
898            .map_err(|e| StoreError::Query(format!("Failed to execute hybrid search: {e}")))?;
899
900        let mut search_results = Vec::new();
901
902        while let Some(batch) = results
903            .try_next()
904            .await
905            .map_err(|e| StoreError::Query(format!("Failed to fetch hybrid results: {e}")))?
906        {
907            search_results.extend(batch_to_search_results(&batch)?);
908        }
909
910        debug!("Hybrid search found {} results", search_results.len());
911        Ok(search_results)
912    }
913
914    async fn delete_by_file_path(&self, path: &Path) -> Result<u64, StoreError> {
915        let path_str = path.to_string_lossy().to_string();
916        debug!("Deleting chunks for file: {}", path_str);
917
918        let table = self.get_chunks_table().await?;
919
920        table
921            .delete(&format!("file_path = '{}'", path_str.replace('\'', "''")))
922            .await
923            .map_err(|e| StoreError::Delete(format!("Failed to delete chunks: {e}")))?;
924
925        // Also delete from files table
926        let files_table = self.get_files_table().await?;
927        files_table
928            .delete(&format!("path = '{}'", path_str.replace('\'', "''")))
929            .await
930            .map_err(|e| StoreError::Delete(format!("Failed to delete file record: {e}")))?;
931
932        Ok(1) // LanceDB doesn't return count, we assume success
933    }
934
935    async fn update_file_path(&self, from: &Path, to: &Path) -> Result<u64, StoreError> {
936        // LanceDB doesn't support UPDATE directly, so we read-delete-insert
937        debug!("Updating file path from {:?} to {:?}", from, to);
938
939        // 1. Get all chunks for the old path
940        let mut chunks = self.get_chunks_for_file(from).await?;
941        if chunks.is_empty() {
942            debug!("No chunks found for path {:?}", from);
943            return Ok(0);
944        }
945
946        let chunk_count = chunks.len() as u64;
947
948        // 2. Update the file_path and directory-scope fields in each chunk
949        for chunk in &mut chunks {
950            let root = DirectoryScope::infer_root(&chunk.file_path, &chunk.dir_path);
951            let scope = DirectoryScope::from_paths(to, root.as_deref());
952            chunk.file_path = to.to_path_buf();
953            chunk.dir_path = scope.dir_path;
954            chunk.dir_depth = scope.dir_depth;
955            chunk.path_components = scope.path_components;
956        }
957
958        // 3. Delete old chunks
959        self.delete_by_file_path(from).await?;
960
961        // 4. Insert updated chunks
962        self.upsert_chunks(&chunks).await?;
963
964        // 5. Also update file record if exists
965        if let Ok(Some(mut file_record)) = self.get_file(from).await {
966            file_record.path = to.to_path_buf();
967            self.upsert_file(&file_record).await?;
968        }
969
970        info!("Updated {} chunks from {:?} to {:?}", chunk_count, from, to);
971        Ok(chunk_count)
972    }
973
974    async fn get_chunks_for_file(&self, path: &Path) -> Result<Vec<Chunk>, StoreError> {
975        let path_str = path.to_string_lossy().to_string();
976        debug!("Getting chunks for file: {}", path_str);
977
978        let table = self.get_chunks_table().await?;
979
980        let mut results = table
981            .query()
982            .only_if(format!("file_path = '{}'", path_str.replace('\'', "''")))
983            .execute()
984            .await
985            .map_err(|e| StoreError::Query(format!("Failed to query chunks: {e}")))?;
986
987        let mut chunks = Vec::new();
988
989        while let Some(batch) = results
990            .try_next()
991            .await
992            .map_err(|e| StoreError::Query(format!("Failed to fetch chunks: {e}")))?
993        {
994            chunks.extend(batch_to_chunks(&batch)?);
995        }
996
997        Ok(chunks)
998    }
999
1000    async fn get_file(&self, path: &Path) -> Result<Option<FileRecord>, StoreError> {
1001        let path_str = path.to_string_lossy().to_string();
1002        debug!("Getting file record: {}", path_str);
1003
1004        let table = self.get_files_table().await?;
1005
1006        let mut results = table
1007            .query()
1008            .only_if(format!("path = '{}'", path_str.replace('\'', "''")))
1009            .limit(1)
1010            .execute()
1011            .await
1012            .map_err(|e| StoreError::Query(format!("Failed to query file: {e}")))?;
1013
1014        if let Some(batch) = results
1015            .try_next()
1016            .await
1017            .map_err(|e| StoreError::Query(format!("Failed to fetch file: {e}")))?
1018        {
1019            let records = batch_to_file_records(&batch)?;
1020            return Ok(records.into_iter().next());
1021        }
1022
1023        Ok(None)
1024    }
1025
1026    async fn upsert_file(&self, record: &FileRecord) -> Result<(), StoreError> {
1027        debug!("Upserting file record: {:?}", record.path);
1028
1029        let path_str = record.path.to_string_lossy().to_string();
1030
1031        // Delete existing file record only (not chunks!)
1032        let files_table = self.get_files_table().await?;
1033        let _ = files_table
1034            .delete(&format!("path = '{}'", path_str.replace('\'', "''")))
1035            .await;
1036
1037        // Insert new record
1038        let batch = self.file_to_batch(record)?;
1039        let schema = batch.schema();
1040
1041        let batches = RecordBatchIterator::new(vec![Ok(batch)], schema);
1042
1043        files_table
1044            .add(Box::new(batches) as Box<dyn RecordBatchReader + Send>)
1045            .execute()
1046            .await
1047            .map_err(|e| StoreError::Insert(format!("Failed to insert file record: {e}")))?;
1048
1049        Ok(())
1050    }
1051
1052    async fn stats(&self) -> Result<StoreStats, StoreError> {
1053        let chunks_table = self.get_chunks_table().await?;
1054        let files_table = self.get_files_table().await?;
1055
1056        // Count chunks - use exact same pattern as get_chunks_for_file
1057        let mut chunk_count = 0u64;
1058        let mut results = chunks_table
1059            .query()
1060            .only_if("file_path LIKE '%'")
1061            .execute()
1062            .await
1063            .map_err(|e| StoreError::Query(format!("Failed to query chunks: {e}")))?;
1064
1065        while let Some(batch) = results
1066            .try_next()
1067            .await
1068            .map_err(|e| StoreError::Query(format!("Failed to count chunks: {e}")))?
1069        {
1070            chunk_count += batch.num_rows() as u64;
1071        }
1072
1073        // Count files - use filter
1074        let mut file_count = 0u64;
1075        let mut results = files_table
1076            .query()
1077            .only_if("size_bytes >= 0")
1078            .execute()
1079            .await
1080            .map_err(|e| StoreError::Query(format!("Failed to query files: {e}")))?;
1081
1082        while let Some(batch) = results
1083            .try_next()
1084            .await
1085            .map_err(|e| StoreError::Query(format!("Failed to count files: {e}")))?
1086        {
1087            file_count += batch.num_rows() as u64;
1088        }
1089
1090        // Calculate actual index size from disk
1091        let index_size_bytes = calculate_dir_size(&self.db_path);
1092
1093        Ok(StoreStats {
1094            total_chunks: chunk_count,
1095            total_files: file_count,
1096            index_size_bytes,
1097            last_updated: Some(Utc::now()),
1098        })
1099    }
1100
1101    async fn get_all_chunks(&self) -> Result<Vec<Chunk>, StoreError> {
1102        debug!("Getting all chunks");
1103
1104        let table = self.get_chunks_table().await?;
1105
1106        let mut results = table
1107            .query()
1108            .only_if("file_path LIKE '%'")
1109            .execute()
1110            .await
1111            .map_err(|e| StoreError::Query(format!("Failed to query all chunks: {e}")))?;
1112
1113        let mut chunks = Vec::new();
1114
1115        while let Some(batch) = results
1116            .try_next()
1117            .await
1118            .map_err(|e| StoreError::Query(format!("Failed to fetch chunks: {e}")))?
1119        {
1120            chunks.extend(batch_to_chunks(&batch)?);
1121        }
1122
1123        debug!("Retrieved {} chunks", chunks.len());
1124        Ok(chunks)
1125    }
1126
1127    async fn get_all_files(&self) -> Result<Vec<FileRecord>, StoreError> {
1128        debug!("Getting all file records");
1129
1130        let table = self.get_files_table().await?;
1131
1132        let mut results = table
1133            .query()
1134            .only_if("size_bytes >= 0")
1135            .execute()
1136            .await
1137            .map_err(|e| StoreError::Query(format!("Failed to query all files: {e}")))?;
1138
1139        let mut records = Vec::new();
1140
1141        while let Some(batch) = results
1142            .try_next()
1143            .await
1144            .map_err(|e| StoreError::Query(format!("Failed to fetch files: {e}")))?
1145        {
1146            records.extend(batch_to_file_records(&batch)?);
1147        }
1148
1149        debug!("Retrieved {} file records", records.len());
1150        Ok(records)
1151    }
1152}
1153
1154// ============================================================================
1155// Helper functions
1156// ============================================================================
1157
1158/// Calculate the total size of a directory recursively.
1159fn calculate_dir_size(path: &Path) -> u64 {
1160    if !path.exists() {
1161        return 0;
1162    }
1163
1164    let mut total_size = 0u64;
1165
1166    if let Ok(entries) = std::fs::read_dir(path) {
1167        for entry in entries.flatten() {
1168            let entry_path = entry.path();
1169            if entry_path.is_file() {
1170                if let Ok(metadata) = entry.metadata() {
1171                    total_size += metadata.len();
1172                }
1173            } else if entry_path.is_dir() {
1174                total_size += calculate_dir_size(&entry_path);
1175            }
1176        }
1177    }
1178
1179    total_size
1180}
1181
1182/// Map [`DistanceMetric`] to the `LanceDB` distance type used at query time.
1183fn distance_type_from_metric(metric: DistanceMetric) -> DistanceType {
1184    match metric {
1185        DistanceMetric::Cosine => DistanceType::Cosine,
1186        DistanceMetric::L2 => DistanceType::L2,
1187        DistanceMetric::Dot => DistanceType::Dot,
1188    }
1189}
1190
1191/// Escape a string for use inside a single-quoted SQL literal.
1192fn escape_sql_literal(value: &str) -> String {
1193    value.replace('\'', "''")
1194}
1195
1196/// Escape `%` and `_` so they are treated as literals in `LIKE` patterns.
1197fn escape_like_literal(value: &str) -> String {
1198    value
1199        .replace('\\', "\\\\")
1200        .replace('%', "\\%")
1201        .replace('_', "\\_")
1202}
1203
1204/// Convert a glob (`*`, `**`, `?`) into an anchored regular expression.
1205///
1206/// `*` and `?` do not cross `/`. `**` matches across directories; `**/` also
1207/// matches zero intervening segments without collapsing the following name.
1208fn glob_to_regex(glob: &str) -> String {
1209    let mut regex = String::from("^");
1210    let chars: Vec<char> = glob.chars().collect();
1211    let mut i = 0;
1212
1213    while i < chars.len() {
1214        match chars[i] {
1215            '*' => {
1216                if i + 1 < chars.len() && chars[i + 1] == '*' {
1217                    i += 2;
1218                    if i < chars.len() && chars[i] == '/' {
1219                        i += 1;
1220                        regex.push_str("(?:.*/)?");
1221                    } else {
1222                        regex.push_str(".*");
1223                    }
1224                } else {
1225                    regex.push_str("[^/]*");
1226                    i += 1;
1227                }
1228            }
1229            '?' => {
1230                regex.push_str("[^/]");
1231                i += 1;
1232            }
1233            c => {
1234                regex.push_str(&regex_escape_char(c));
1235                i += 1;
1236            }
1237        }
1238    }
1239
1240    regex.push('$');
1241    regex
1242}
1243
1244fn regex_escape_char(c: char) -> String {
1245    if matches!(
1246        c,
1247        '.' | '+' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' | '\\'
1248    ) {
1249        format!("\\{c}")
1250    } else {
1251        c.to_string()
1252    }
1253}
1254
1255/// Convert one [`SearchFilter`] into a chunks-table SQL predicate.
1256///
1257/// Date filters are omitted here; they are resolved against `files.modified_at`.
1258fn filter_to_sql(filter: &SearchFilter) -> Option<String> {
1259    Some(match filter {
1260        SearchFilter::PathPrefix(prefix) => {
1261            let pattern = escape_sql_literal(&format!("{}%", escape_like_literal(prefix)));
1262            format!("file_path LIKE '{pattern}' ESCAPE '\\'")
1263        }
1264        SearchFilter::PathGlob(glob) => {
1265            let pattern = escape_sql_literal(&glob_to_regex(glob));
1266            format!("regexp_like(file_path, '{pattern}')")
1267        }
1268        SearchFilter::MimeType(value) => type_or_mime_sql(value),
1269        SearchFilter::Language(lang) => {
1270            let escaped = escape_sql_literal(&lang.to_lowercase());
1271            format!("(LOWER(language) = '{escaped}' OR LOWER(content_type) = 'code:{escaped}')")
1272        }
1273        SearchFilter::ModifiedAfter(_) | SearchFilter::ModifiedBefore(_) => return None,
1274        SearchFilter::MinDepth(depth) => format!("depth >= {depth}"),
1275        SearchFilter::MaxDepth(depth) => format!("depth <= {depth}"),
1276    })
1277}
1278
1279/// Files-table predicates for source modification time (inclusive).
1280fn file_date_filters_to_sql(filters: &[SearchFilter]) -> Option<String> {
1281    let clauses: Vec<String> = filters
1282        .iter()
1283        .filter_map(|filter| match filter {
1284            SearchFilter::ModifiedAfter(ts) => {
1285                let escaped = escape_sql_literal(&ts.to_rfc3339());
1286                Some(format!("modified_at >= '{escaped}'"))
1287            }
1288            SearchFilter::ModifiedBefore(ts) => {
1289                let escaped = escape_sql_literal(&ts.to_rfc3339());
1290                Some(format!("modified_at <= '{escaped}'"))
1291            }
1292            _ => None,
1293        })
1294        .collect();
1295
1296    if clauses.is_empty() {
1297        None
1298    } else {
1299        Some(clauses.join(" AND "))
1300    }
1301}
1302
1303fn combine_predicates(left: Option<String>, right: Option<String>) -> Option<String> {
1304    match (left, right) {
1305        (Some(a), Some(b)) => Some(format!("{a} AND {b}")),
1306        (Some(a), None) | (None, Some(a)) => Some(a),
1307        (None, None) => None,
1308    }
1309}
1310
1311/// `type:` / `mime:` values may name a content-type alias or a MIME type.
1312fn type_or_mime_sql(value: &str) -> String {
1313    let lowered = value.to_lowercase();
1314    let escaped = escape_sql_literal(&lowered);
1315
1316    if lowered.contains('/') {
1317        return format!("LOWER(file_mime_type) = '{escaped}'");
1318    }
1319
1320    match lowered.as_str() {
1321        "code" => "(LOWER(content_type) LIKE 'code:%' OR LOWER(content_type) = 'code')".to_string(),
1322        "text" => "LOWER(content_type) = 'text'".to_string(),
1323        "markdown" | "md" => "LOWER(content_type) = 'markdown'".to_string(),
1324        "pdf" => "(LOWER(content_type) LIKE 'pdf:%' OR LOWER(content_type) = 'pdf')".to_string(),
1325        "image" | "image_caption" => "LOWER(content_type) = 'image_caption'".to_string(),
1326        _ => format!(
1327            "(LOWER(content_type) = '{escaped}' OR LOWER(content_type) LIKE '{escaped}:%' OR LOWER(file_mime_type) = '{escaped}')"
1328        ),
1329    }
1330}
1331
1332/// Pre-filter for directory-scoped search (exact dir, subdirectory, or suffix).
1333///
1334/// Used with IVF-PQ ANN via `only_if`; this is not a replacement for ANN.
1335/// Suffix `LIKE` clauses cover schema-v1 rows backfilled from absolute `file_path`.
1336fn scope_prefix_sql(scope: &str) -> Option<String> {
1337    let scope = DirectoryScope::normalize_prefix(scope);
1338    if scope.is_empty() || scope == "." {
1339        return None;
1340    }
1341    let exact = escape_sql_literal(&scope);
1342    let prefix = escape_sql_literal(&format!("{}/%", escape_like_literal(&scope)));
1343    let suffix = escape_sql_literal(&format!("%/{}", escape_like_literal(&scope)));
1344    let mid = escape_sql_literal(&format!("%/{}/%", escape_like_literal(&scope)));
1345    Some(format!(
1346        "dir_path = '{exact}' OR dir_path LIKE '{prefix}' ESCAPE '\\' \
1347         OR dir_path LIKE '{suffix}' ESCAPE '\\' OR dir_path LIKE '{mid}' ESCAPE '\\'"
1348    ))
1349}
1350
1351/// Combine [`SearchQuery`] filters into a single SQL predicate, if any.
1352fn filters_to_sql(filters: &[SearchFilter]) -> Option<String> {
1353    if filters.is_empty() {
1354        return None;
1355    }
1356
1357    let clauses: Vec<String> = filters.iter().filter_map(filter_to_sql).collect();
1358    if clauses.is_empty() {
1359        None
1360    } else {
1361        Some(clauses.join(" AND "))
1362    }
1363}
1364
1365fn content_type_to_string(ct: &ContentType) -> String {
1366    match ct {
1367        ContentType::Text => "text".to_string(),
1368        ContentType::Code { language, .. } => format!("code:{language}"),
1369        ContentType::ImageCaption => "image_caption".to_string(),
1370        ContentType::PdfPage { page_num } => format!("pdf:{page_num}"),
1371        ContentType::Markdown => "markdown".to_string(),
1372    }
1373}
1374
1375fn string_to_content_type(s: &str) -> ContentType {
1376    if s == "text" {
1377        ContentType::Text
1378    } else if s == "markdown" {
1379        ContentType::Markdown
1380    } else if s == "image_caption" {
1381        ContentType::ImageCaption
1382    } else if let Some(lang) = s.strip_prefix("code:") {
1383        ContentType::Code {
1384            language: lang.to_string(),
1385            symbol: None,
1386        }
1387    } else if let Some(page) = s.strip_prefix("pdf:") {
1388        ContentType::PdfPage {
1389            page_num: page.parse().unwrap_or(1),
1390        }
1391    } else {
1392        ContentType::Text
1393    }
1394}
1395
1396fn status_to_string(status: &FileStatus) -> String {
1397    match status {
1398        FileStatus::Pending => "pending".to_string(),
1399        FileStatus::Indexing => "indexing".to_string(),
1400        FileStatus::Indexed => "indexed".to_string(),
1401        FileStatus::Error => "error".to_string(),
1402        FileStatus::Deleted => "deleted".to_string(),
1403    }
1404}
1405
1406fn string_to_status(s: &str) -> FileStatus {
1407    match s {
1408        "pending" => FileStatus::Pending,
1409        "indexing" => FileStatus::Indexing,
1410        "indexed" => FileStatus::Indexed,
1411        "error" => FileStatus::Error,
1412        "deleted" => FileStatus::Deleted,
1413        _ => FileStatus::Pending,
1414    }
1415}
1416
1417fn build_vector_array(
1418    embeddings: &[Option<Vec<Option<f32>>>],
1419    dim: usize,
1420) -> Result<ArrayRef, StoreError> {
1421    use arrow_array::builder::{FixedSizeListBuilder, Float32Builder};
1422
1423    let mut builder = FixedSizeListBuilder::new(Float32Builder::new(), dim as i32);
1424
1425    for emb in embeddings {
1426        if let Some(values) = emb {
1427            let values_builder = builder.values();
1428            for &v in values {
1429                values_builder.append_option(v);
1430            }
1431            builder.append(true);
1432        } else {
1433            // Append zeros for missing embeddings
1434            let values_builder = builder.values();
1435            for _ in 0..dim {
1436                values_builder.append_value(0.0);
1437            }
1438            builder.append(true);
1439        }
1440    }
1441
1442    Ok(Arc::new(builder.finish()))
1443}
1444
1445fn batch_to_search_results(batch: &RecordBatch) -> Result<Vec<SearchResult>, StoreError> {
1446    let mut results = Vec::new();
1447
1448    let chunk_ids = batch
1449        .column_by_name("chunk_id")
1450        .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1451    let file_paths = batch
1452        .column_by_name("file_path")
1453        .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1454    let contents = batch
1455        .column_by_name("content")
1456        .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1457    let start_bytes = batch
1458        .column_by_name("start_byte")
1459        .and_then(|c| c.as_any().downcast_ref::<UInt64Array>());
1460    let end_bytes = batch
1461        .column_by_name("end_byte")
1462        .and_then(|c| c.as_any().downcast_ref::<UInt64Array>());
1463    let start_lines = batch
1464        .column_by_name("start_line")
1465        .and_then(|c| c.as_any().downcast_ref::<UInt32Array>());
1466    let end_lines = batch
1467        .column_by_name("end_line")
1468        .and_then(|c| c.as_any().downcast_ref::<UInt32Array>());
1469    let distances = batch
1470        .column_by_name("_distance")
1471        .and_then(|c| c.as_any().downcast_ref::<Float32Array>());
1472
1473    let (Some(chunk_ids), Some(file_paths), Some(contents), Some(start_bytes), Some(end_bytes)) =
1474        (chunk_ids, file_paths, contents, start_bytes, end_bytes)
1475    else {
1476        return Err(StoreError::Query("Missing required columns".to_string()));
1477    };
1478
1479    for i in 0..batch.num_rows() {
1480        let chunk_id = chunk_ids.value(i);
1481        let file_path = file_paths.value(i);
1482        let content = contents.value(i);
1483        let start = start_bytes.value(i);
1484        let end = end_bytes.value(i);
1485
1486        let line_range = match (start_lines, end_lines) {
1487            (Some(sl), Some(el)) if !sl.is_null(i) && !el.is_null(i) => {
1488                Some(sl.value(i)..el.value(i))
1489            }
1490            _ => None,
1491        };
1492
1493        let score = distances.map_or(0.0, |d| 1.0 - d.value(i));
1494
1495        results.push(SearchResult {
1496            chunk_id: Uuid::parse_str(chunk_id).unwrap_or_default(),
1497            file_path: PathBuf::from(file_path),
1498            content: content.to_string(),
1499            score,
1500            byte_range: start..end,
1501            line_range,
1502            metadata: HashMap::new(),
1503        });
1504    }
1505
1506    Ok(results)
1507}
1508
1509fn batch_to_chunks(batch: &RecordBatch) -> Result<Vec<Chunk>, StoreError> {
1510    let mut chunks = Vec::new();
1511
1512    // Similar to batch_to_search_results but returns full Chunk structs
1513    // Simplified for now - full implementation would parse all fields
1514
1515    let chunk_ids = batch
1516        .column_by_name("chunk_id")
1517        .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1518    let file_ids = batch
1519        .column_by_name("file_id")
1520        .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1521    let file_paths = batch
1522        .column_by_name("file_path")
1523        .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1524    let contents = batch
1525        .column_by_name("content")
1526        .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1527    let content_types = batch
1528        .column_by_name("content_type")
1529        .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1530    let chunk_indices = batch
1531        .column_by_name("chunk_index")
1532        .and_then(|c| c.as_any().downcast_ref::<UInt32Array>());
1533    let start_bytes = batch
1534        .column_by_name("start_byte")
1535        .and_then(|c| c.as_any().downcast_ref::<UInt64Array>());
1536    let end_bytes = batch
1537        .column_by_name("end_byte")
1538        .and_then(|c| c.as_any().downcast_ref::<UInt64Array>());
1539    let depths = batch
1540        .column_by_name("depth")
1541        .and_then(|c| c.as_any().downcast_ref::<UInt8Array>());
1542    let mime_types = batch
1543        .column_by_name("mime_type")
1544        .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1545    let start_lines = batch
1546        .column_by_name("start_line")
1547        .and_then(|c| c.as_any().downcast_ref::<UInt32Array>());
1548    let end_lines = batch
1549        .column_by_name("end_line")
1550        .and_then(|c| c.as_any().downcast_ref::<UInt32Array>());
1551    let embeddings = batch
1552        .column_by_name("embedding")
1553        .and_then(|c| c.as_any().downcast_ref::<FixedSizeListArray>());
1554    let dir_paths = batch
1555        .column_by_name("dir_path")
1556        .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1557    let dir_depths = batch
1558        .column_by_name("dir_depth")
1559        .and_then(|c| c.as_any().downcast_ref::<UInt16Array>());
1560    let path_components_arr = batch
1561        .column_by_name("path_components")
1562        .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1563
1564    let (
1565        Some(chunk_ids),
1566        Some(file_ids),
1567        Some(file_paths),
1568        Some(contents),
1569        Some(content_types),
1570        Some(chunk_indices),
1571        Some(start_bytes),
1572        Some(end_bytes),
1573        Some(depths),
1574    ) = (
1575        chunk_ids,
1576        file_ids,
1577        file_paths,
1578        contents,
1579        content_types,
1580        chunk_indices,
1581        start_bytes,
1582        end_bytes,
1583        depths,
1584    )
1585    else {
1586        return Err(StoreError::Query(
1587            "Missing required columns in chunks".to_string(),
1588        ));
1589    };
1590
1591    for i in 0..batch.num_rows() {
1592        let mime_type = mime_types.and_then(|m| {
1593            if m.is_null(i) {
1594                None
1595            } else {
1596                Some(m.value(i).to_string())
1597            }
1598        });
1599
1600        // Parse line range from start_line and end_line columns
1601        let line_range = match (start_lines, end_lines) {
1602            (Some(starts), Some(ends)) if !starts.is_null(i) && !ends.is_null(i) => {
1603                Some(starts.value(i)..ends.value(i))
1604            }
1605            _ => None,
1606        };
1607
1608        // Parse embedding vector
1609        let embedding = embeddings.and_then(|emb_array| {
1610            if emb_array.is_null(i) {
1611                None
1612            } else {
1613                let values = emb_array.value(i);
1614                values
1615                    .as_any()
1616                    .downcast_ref::<Float32Array>()
1617                    .map(|arr| arr.values().to_vec())
1618            }
1619        });
1620
1621        chunks.push(Chunk {
1622            id: Uuid::parse_str(chunk_ids.value(i)).unwrap_or_default(),
1623            file_id: Uuid::parse_str(file_ids.value(i)).unwrap_or_default(),
1624            file_path: PathBuf::from(file_paths.value(i)),
1625            content: contents.value(i).to_string(),
1626            content_type: string_to_content_type(content_types.value(i)),
1627            mime_type,
1628            chunk_index: chunk_indices.value(i),
1629            byte_range: start_bytes.value(i)..end_bytes.value(i),
1630            line_range,
1631            parent_chunk_id: None,
1632            depth: depths.value(i),
1633            embedding,
1634            dir_path: dir_paths.map_or_else(
1635                || DirectoryScope::from_file_path(&PathBuf::from(file_paths.value(i))).dir_path,
1636                |arr| {
1637                    if arr.is_null(i) {
1638                        String::new()
1639                    } else {
1640                        arr.value(i).to_string()
1641                    }
1642                },
1643            ),
1644            dir_depth: dir_depths.map_or(0, |arr| if arr.is_null(i) { 0 } else { arr.value(i) }),
1645            path_components: path_components_arr.map_or_else(String::new, |arr| {
1646                if arr.is_null(i) {
1647                    String::new()
1648                } else {
1649                    arr.value(i).to_string()
1650                }
1651            }),
1652            metadata: ChunkMetadata::default(),
1653        });
1654    }
1655
1656    Ok(chunks)
1657}
1658
1659fn batch_to_file_records(batch: &RecordBatch) -> Result<Vec<FileRecord>, StoreError> {
1660    let mut records = Vec::new();
1661
1662    let file_ids = batch
1663        .column_by_name("file_id")
1664        .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1665    let paths = batch
1666        .column_by_name("path")
1667        .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1668    let sizes = batch
1669        .column_by_name("size_bytes")
1670        .and_then(|c| c.as_any().downcast_ref::<UInt64Array>());
1671    let mime_types = batch
1672        .column_by_name("mime_type")
1673        .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1674    let hashes = batch
1675        .column_by_name("content_hash")
1676        .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1677    let modified_ats = batch
1678        .column_by_name("modified_at")
1679        .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1680    let chunk_counts = batch
1681        .column_by_name("chunk_count")
1682        .and_then(|c| c.as_any().downcast_ref::<UInt32Array>());
1683    let statuses = batch
1684        .column_by_name("status")
1685        .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1686    let indexed_ats = batch
1687        .column_by_name("indexed_at")
1688        .and_then(|c| c.as_any().downcast_ref::<StringArray>());
1689
1690    let (
1691        Some(file_ids),
1692        Some(paths),
1693        Some(sizes),
1694        Some(mime_types),
1695        Some(hashes),
1696        Some(modified_ats),
1697        Some(chunk_counts),
1698        Some(statuses),
1699    ) = (
1700        file_ids,
1701        paths,
1702        sizes,
1703        mime_types,
1704        hashes,
1705        modified_ats,
1706        chunk_counts,
1707        statuses,
1708    )
1709    else {
1710        return Err(StoreError::Query(
1711            "Missing required columns in files".to_string(),
1712        ));
1713    };
1714
1715    for i in 0..batch.num_rows() {
1716        let modified_at = chrono::DateTime::parse_from_rfc3339(modified_ats.value(i))
1717            .map_or_else(|_| Utc::now(), |dt| dt.with_timezone(&Utc));
1718
1719        // Parse indexed_at timestamp
1720        let indexed_at = indexed_ats.and_then(|arr| {
1721            if arr.is_null(i) {
1722                None
1723            } else {
1724                chrono::DateTime::parse_from_rfc3339(arr.value(i))
1725                    .map(|dt| dt.with_timezone(&Utc))
1726                    .ok()
1727            }
1728        });
1729
1730        records.push(FileRecord {
1731            id: Uuid::parse_str(file_ids.value(i)).unwrap_or_default(),
1732            path: PathBuf::from(paths.value(i)),
1733            size_bytes: sizes.value(i),
1734            mime_type: mime_types.value(i).to_string(),
1735            content_hash: hashes.value(i).to_string(),
1736            modified_at,
1737            indexed_at,
1738            chunk_count: chunk_counts.value(i),
1739            status: string_to_status(statuses.value(i)),
1740            error_message: None,
1741        });
1742    }
1743
1744    Ok(records)
1745}
1746
1747#[cfg(test)]
1748mod tests {
1749    use super::*;
1750    use crate::schema::{CHUNKS_SCHEMA_VERSION, SCHEMA_SIDECAR_FILENAME, SchemaSidecar};
1751    use ragfs_core::{DistanceMetric, SearchFilter};
1752    use std::collections::HashMap;
1753    use tempfile::tempdir;
1754
1755    const TEST_DIM: usize = 384;
1756
1757    fn create_test_chunk(
1758        file_path: &Path,
1759        content: &str,
1760        embedding: Vec<f32>,
1761        chunk_index: u32,
1762    ) -> Chunk {
1763        Chunk {
1764            id: Uuid::new_v4(),
1765            file_id: Uuid::new_v4(),
1766            file_path: file_path.to_path_buf(),
1767            content: content.to_string(),
1768            content_type: ContentType::Text,
1769            mime_type: Some("text/plain".to_string()),
1770            chunk_index,
1771            byte_range: 0..content.len() as u64,
1772            line_range: Some(0..1),
1773            parent_chunk_id: None,
1774            depth: 0,
1775            embedding: Some(embedding),
1776            dir_path: DirectoryScope::from_file_path(file_path).dir_path,
1777            dir_depth: DirectoryScope::from_file_path(file_path).dir_depth,
1778            path_components: DirectoryScope::from_file_path(file_path).path_components,
1779            metadata: ChunkMetadata {
1780                indexed_at: Some(Utc::now()),
1781                embedding_model: Some("test-model".to_string()),
1782                token_count: None,
1783                extra: HashMap::new(),
1784            },
1785        }
1786    }
1787
1788    fn create_random_embedding(dim: usize) -> Vec<f32> {
1789        (0..dim).map(|i| (i as f32 * 0.001).sin()).collect()
1790    }
1791
1792    fn create_test_file_record(path: &Path) -> FileRecord {
1793        FileRecord {
1794            id: Uuid::new_v4(),
1795            path: path.to_path_buf(),
1796            size_bytes: 1024,
1797            mime_type: "text/plain".to_string(),
1798            content_hash: "abc123".to_string(),
1799            modified_at: Utc::now(),
1800            indexed_at: Some(Utc::now()),
1801            chunk_count: 1,
1802            status: FileStatus::Indexed,
1803            error_message: None,
1804        }
1805    }
1806
1807    #[tokio::test]
1808    async fn test_init_creates_tables() {
1809        let temp = tempdir().unwrap();
1810        let db_path = temp.path().join("test.lance");
1811        let store = LanceStore::new(db_path.clone(), TEST_DIM);
1812
1813        let result = store.init().await;
1814        assert!(result.is_ok(), "Init failed: {:?}", result.err());
1815
1816        // Verify tables exist
1817        let conn = store.get_connection().await.unwrap();
1818        let tables = conn.table_names().execute().await.unwrap();
1819        assert!(tables.contains(&"chunks".to_string()));
1820        assert!(tables.contains(&"files".to_string()));
1821    }
1822
1823    #[tokio::test]
1824    async fn test_init_idempotent() {
1825        let temp = tempdir().unwrap();
1826        let db_path = temp.path().join("test.lance");
1827        let store = LanceStore::new(db_path.clone(), TEST_DIM);
1828
1829        // Init twice should succeed
1830        store.init().await.unwrap();
1831        let result = store.init().await;
1832        assert!(result.is_ok());
1833    }
1834
1835    #[tokio::test]
1836    async fn test_init_rejects_embedding_dim_mismatch() {
1837        let temp = tempdir().unwrap();
1838        let db_path = temp.path().join("test.lance");
1839        LanceStore::new(db_path.clone(), TEST_DIM)
1840            .init()
1841            .await
1842            .unwrap();
1843
1844        let err = LanceStore::new(db_path, 768).init().await.unwrap_err();
1845        let msg = err.to_string();
1846        assert!(
1847            matches!(err, StoreError::Schema(_)),
1848            "expected Schema, got {err:?}"
1849        );
1850        assert!(
1851            msg.contains("384") && msg.contains("768"),
1852            "mismatch message should name both dimensions: {msg}"
1853        );
1854    }
1855
1856    #[tokio::test]
1857    async fn test_upsert_and_get_chunks() {
1858        let temp = tempdir().unwrap();
1859        let db_path = temp.path().join("test.lance");
1860        let store = LanceStore::new(db_path.clone(), TEST_DIM);
1861        store.init().await.unwrap();
1862
1863        let file_path = PathBuf::from("/test/file.txt");
1864        let embedding = create_random_embedding(TEST_DIM);
1865        let chunk = create_test_chunk(&file_path, "Hello world", embedding, 0);
1866
1867        // Upsert
1868        let result = store.upsert_chunks(&[chunk]).await;
1869        assert!(result.is_ok(), "Upsert failed: {:?}", result.err());
1870
1871        // Get chunks back
1872        let chunks = store.get_chunks_for_file(&file_path).await.unwrap();
1873        assert_eq!(chunks.len(), 1);
1874        assert_eq!(chunks[0].content, "Hello world");
1875    }
1876
1877    #[tokio::test]
1878    async fn test_upsert_multiple_chunks() {
1879        let temp = tempdir().unwrap();
1880        let db_path = temp.path().join("test.lance");
1881        let store = LanceStore::new(db_path.clone(), TEST_DIM);
1882        store.init().await.unwrap();
1883
1884        let file_path = PathBuf::from("/test/multi.txt");
1885        let chunks: Vec<Chunk> = (0..5)
1886            .map(|i| {
1887                create_test_chunk(
1888                    &file_path,
1889                    &format!("Chunk content {i}"),
1890                    create_random_embedding(TEST_DIM),
1891                    i,
1892                )
1893            })
1894            .collect();
1895
1896        store.upsert_chunks(&chunks).await.unwrap();
1897
1898        let retrieved = store.get_chunks_for_file(&file_path).await.unwrap();
1899        assert_eq!(retrieved.len(), 5);
1900    }
1901
1902    #[tokio::test]
1903    async fn test_upsert_empty_chunks() {
1904        let temp = tempdir().unwrap();
1905        let db_path = temp.path().join("test.lance");
1906        let store = LanceStore::new(db_path.clone(), TEST_DIM);
1907        store.init().await.unwrap();
1908
1909        // Empty vec should succeed
1910        let result = store.upsert_chunks(&[]).await;
1911        assert!(result.is_ok());
1912    }
1913
1914    #[tokio::test]
1915    async fn test_search_returns_results() {
1916        let temp = tempdir().unwrap();
1917        let db_path = temp.path().join("test.lance");
1918        let store = LanceStore::new(db_path.clone(), TEST_DIM);
1919        store.init().await.unwrap();
1920
1921        // Insert some chunks
1922        let file_path = PathBuf::from("/test/search.txt");
1923        let embedding = create_random_embedding(TEST_DIM);
1924        let chunk = create_test_chunk(&file_path, "Authentication logic", embedding.clone(), 0);
1925        store.upsert_chunks(&[chunk]).await.unwrap();
1926
1927        // Search with same embedding should find it
1928        let query = SearchQuery {
1929            text: Some("auth".to_string()),
1930            embedding: embedding.clone(),
1931            limit: 10,
1932            filters: vec![],
1933            metric: DistanceMetric::Cosine,
1934            scope_prefix: None,
1935        };
1936
1937        let results = store.search(query).await.unwrap();
1938        assert!(!results.is_empty());
1939        assert_eq!(results[0].content, "Authentication logic");
1940    }
1941
1942    #[tokio::test]
1943    async fn test_search_respects_limit() {
1944        let temp = tempdir().unwrap();
1945        let db_path = temp.path().join("test.lance");
1946        let store = LanceStore::new(db_path.clone(), TEST_DIM);
1947        store.init().await.unwrap();
1948
1949        // Insert 10 chunks
1950        let file_path = PathBuf::from("/test/limit.txt");
1951        let chunks: Vec<Chunk> = (0..10)
1952            .map(|i| {
1953                create_test_chunk(
1954                    &file_path,
1955                    &format!("Content {i}"),
1956                    create_random_embedding(TEST_DIM),
1957                    i,
1958                )
1959            })
1960            .collect();
1961        store.upsert_chunks(&chunks).await.unwrap();
1962
1963        // Search with limit 3
1964        let query = SearchQuery {
1965            text: Some("test".to_string()),
1966            embedding: create_random_embedding(TEST_DIM),
1967            limit: 3,
1968            filters: vec![],
1969            metric: DistanceMetric::Cosine,
1970            scope_prefix: None,
1971        };
1972
1973        let results = store.search(query).await.unwrap();
1974        assert!(results.len() <= 3);
1975    }
1976
1977    #[tokio::test]
1978    async fn test_delete_by_file_path() {
1979        let temp = tempdir().unwrap();
1980        let db_path = temp.path().join("test.lance");
1981        let store = LanceStore::new(db_path.clone(), TEST_DIM);
1982        store.init().await.unwrap();
1983
1984        let file_path = PathBuf::from("/test/delete.txt");
1985        let chunk = create_test_chunk(
1986            &file_path,
1987            "To be deleted",
1988            create_random_embedding(TEST_DIM),
1989            0,
1990        );
1991        store.upsert_chunks(&[chunk]).await.unwrap();
1992
1993        // Verify it exists
1994        let chunks = store.get_chunks_for_file(&file_path).await.unwrap();
1995        assert_eq!(chunks.len(), 1);
1996
1997        // Delete
1998        store.delete_by_file_path(&file_path).await.unwrap();
1999
2000        // Verify it's gone
2001        let chunks = store.get_chunks_for_file(&file_path).await.unwrap();
2002        assert_eq!(chunks.len(), 0);
2003    }
2004
2005    #[tokio::test]
2006    async fn test_upsert_and_get_file_record() {
2007        let temp = tempdir().unwrap();
2008        let db_path = temp.path().join("test.lance");
2009        let store = LanceStore::new(db_path.clone(), TEST_DIM);
2010        store.init().await.unwrap();
2011
2012        let file_path = PathBuf::from("/test/record.txt");
2013        let record = create_test_file_record(&file_path);
2014
2015        // Upsert
2016        store.upsert_file(&record).await.unwrap();
2017
2018        // Get
2019        let retrieved = store.get_file(&file_path).await.unwrap();
2020        assert!(retrieved.is_some());
2021        let retrieved = retrieved.unwrap();
2022        assert_eq!(retrieved.path, file_path);
2023        assert_eq!(retrieved.mime_type, "text/plain");
2024    }
2025
2026    #[tokio::test]
2027    async fn test_get_nonexistent_file() {
2028        let temp = tempdir().unwrap();
2029        let db_path = temp.path().join("test.lance");
2030        let store = LanceStore::new(db_path.clone(), TEST_DIM);
2031        store.init().await.unwrap();
2032
2033        let result = store
2034            .get_file(&PathBuf::from("/nonexistent"))
2035            .await
2036            .unwrap();
2037        assert!(result.is_none());
2038    }
2039
2040    #[tokio::test]
2041    async fn test_stats() {
2042        let temp = tempdir().unwrap();
2043        let db_path = temp.path().join("test.lance");
2044        let store = LanceStore::new(db_path.clone(), TEST_DIM);
2045        store.init().await.unwrap();
2046
2047        // Initially empty
2048        let stats = store.stats().await.unwrap();
2049        assert_eq!(stats.total_chunks, 0);
2050        assert_eq!(stats.total_files, 0);
2051
2052        // Add chunk
2053        let file_path = PathBuf::from("/test/stats.txt");
2054        let chunk = create_test_chunk(
2055            &file_path,
2056            "Stats test",
2057            create_random_embedding(TEST_DIM),
2058            0,
2059        );
2060        store.upsert_chunks(&[chunk]).await.unwrap();
2061
2062        // Add file record (should NOT delete chunks)
2063        let record = create_test_file_record(&file_path);
2064        store.upsert_file(&record).await.unwrap();
2065
2066        // Verify chunks still exist after upsert_file
2067        let chunks = store.get_chunks_for_file(&file_path).await.unwrap();
2068        assert_eq!(
2069            chunks.len(),
2070            1,
2071            "Chunks should still exist after upsert_file"
2072        );
2073
2074        // Verify file exists
2075        let file = store.get_file(&file_path).await.unwrap();
2076        assert!(file.is_some(), "File should be retrievable");
2077
2078        // Check stats
2079        let stats = store.stats().await.unwrap();
2080        assert_eq!(stats.total_chunks, 1);
2081        assert_eq!(stats.total_files, 1);
2082        assert!(
2083            stats.index_size_bytes > 0,
2084            "index_size_bytes should be > 0, got {}",
2085            stats.index_size_bytes
2086        );
2087    }
2088
2089    #[tokio::test]
2090    async fn test_content_type_conversion() {
2091        assert_eq!(content_type_to_string(&ContentType::Text), "text");
2092        assert_eq!(content_type_to_string(&ContentType::Markdown), "markdown");
2093        assert_eq!(
2094            content_type_to_string(&ContentType::Code {
2095                language: "rust".to_string(),
2096                symbol: None
2097            }),
2098            "code:rust"
2099        );
2100        assert_eq!(
2101            content_type_to_string(&ContentType::PdfPage { page_num: 5 }),
2102            "pdf:5"
2103        );
2104
2105        assert!(matches!(string_to_content_type("text"), ContentType::Text));
2106        assert!(matches!(
2107            string_to_content_type("markdown"),
2108            ContentType::Markdown
2109        ));
2110        assert!(matches!(
2111            string_to_content_type("code:python"),
2112            ContentType::Code { language, .. } if language == "python"
2113        ));
2114    }
2115
2116    #[tokio::test]
2117    async fn test_file_status_conversion() {
2118        assert_eq!(status_to_string(&FileStatus::Pending), "pending");
2119        assert_eq!(status_to_string(&FileStatus::Indexed), "indexed");
2120        assert_eq!(status_to_string(&FileStatus::Error), "error");
2121
2122        assert!(matches!(string_to_status("pending"), FileStatus::Pending));
2123        assert!(matches!(string_to_status("indexed"), FileStatus::Indexed));
2124        assert!(matches!(string_to_status("error"), FileStatus::Error));
2125        assert!(matches!(string_to_status("unknown"), FileStatus::Pending));
2126    }
2127
2128    #[tokio::test]
2129    async fn test_chunks_with_code_content_type() {
2130        let temp = tempdir().unwrap();
2131        let db_path = temp.path().join("test.lance");
2132        let store = LanceStore::new(db_path.clone(), TEST_DIM);
2133        store.init().await.unwrap();
2134
2135        let file_path = PathBuf::from("/test/code.rs");
2136        let chunk = Chunk {
2137            id: Uuid::new_v4(),
2138            file_id: Uuid::new_v4(),
2139            file_path: file_path.clone(),
2140            content: "fn main() {}".to_string(),
2141            content_type: ContentType::Code {
2142                language: "rust".to_string(),
2143                symbol: None,
2144            },
2145            mime_type: Some("text/x-rust".to_string()),
2146            chunk_index: 0,
2147            byte_range: 0..12,
2148            line_range: Some(0..1),
2149            parent_chunk_id: None,
2150            depth: 0,
2151            embedding: Some(create_random_embedding(TEST_DIM)),
2152            dir_path: DirectoryScope::from_file_path(&file_path).dir_path,
2153            dir_depth: DirectoryScope::from_file_path(&file_path).dir_depth,
2154            path_components: DirectoryScope::from_file_path(&file_path).path_components,
2155            metadata: ChunkMetadata::default(),
2156        };
2157
2158        store.upsert_chunks(&[chunk]).await.unwrap();
2159
2160        let chunks = store.get_chunks_for_file(&file_path).await.unwrap();
2161        assert_eq!(chunks.len(), 1);
2162        assert!(matches!(
2163            &chunks[0].content_type,
2164            ContentType::Code { language, .. } if language == "rust"
2165        ));
2166    }
2167
2168    #[tokio::test]
2169    async fn test_get_all_files_empty() {
2170        let temp = tempdir().unwrap();
2171        let db_path = temp.path().join("test.lance");
2172        let store = LanceStore::new(db_path.clone(), TEST_DIM);
2173        store.init().await.unwrap();
2174
2175        let files = store.get_all_files().await.unwrap();
2176        assert!(files.is_empty());
2177    }
2178
2179    #[tokio::test]
2180    async fn test_delete_nonexistent_file() {
2181        let temp = tempdir().unwrap();
2182        let db_path = temp.path().join("test.lance");
2183        let store = LanceStore::new(db_path.clone(), TEST_DIM);
2184        store.init().await.unwrap();
2185
2186        let path = PathBuf::from("/nonexistent/file.txt");
2187        // Deleting a nonexistent file should not error
2188        let result = store.delete_by_file_path(&path).await;
2189        assert!(result.is_ok());
2190    }
2191
2192    #[test]
2193    fn test_ann_threshold() {
2194        assert!(!LanceStore::should_build_ann_index(0));
2195        assert!(!LanceStore::should_build_ann_index(255));
2196        assert!(LanceStore::should_build_ann_index(256));
2197        assert!(LanceStore::should_build_ann_index(10_000));
2198    }
2199
2200    #[test]
2201    fn test_should_refresh_ann_index_after_post_threshold_appends() {
2202        // Repeated-batch ingest: first batch crosses 256, later batches are unindexed.
2203        assert!(LanceStore::should_build_ann_index(300));
2204        assert!(!LanceStore::should_refresh_ann_index(0));
2205        assert!(!LanceStore::should_refresh_ann_index(255));
2206        assert!(LanceStore::should_refresh_ann_index(256));
2207        assert!(LanceStore::should_refresh_ann_index(700));
2208        assert!(LanceStore::should_refresh_ann_index(9_700));
2209    }
2210
2211    #[test]
2212    fn test_ann_index_covers_cosine_only() {
2213        assert!(LanceStore::ann_index_covers_metric(DistanceMetric::Cosine));
2214        assert!(!LanceStore::ann_index_covers_metric(DistanceMetric::L2));
2215        assert!(!LanceStore::ann_index_covers_metric(DistanceMetric::Dot));
2216        assert!(LanceStore::ann_index_is_cosine(Some(DistanceType::Cosine)));
2217        assert!(!LanceStore::ann_index_is_cosine(Some(DistanceType::L2)));
2218        assert!(!LanceStore::ann_index_is_cosine(Some(DistanceType::Dot)));
2219        assert!(!LanceStore::ann_index_is_cosine(None));
2220    }
2221
2222    #[tokio::test]
2223    async fn test_small_table_skips_ann_and_still_searches() {
2224        let temp = tempdir().unwrap();
2225        let db_path = temp.path().join("test.lance");
2226        let store = LanceStore::new(db_path, TEST_DIM);
2227        store.init().await.unwrap();
2228
2229        let file_path = PathBuf::from("/test/small.txt");
2230        let chunk = create_test_chunk(
2231            &file_path,
2232            "hello ann",
2233            create_random_embedding(TEST_DIM),
2234            0,
2235        );
2236        store.upsert_chunks(&[chunk]).await.unwrap();
2237        store.ensure_vector_index().await.unwrap();
2238
2239        let results = store
2240            .search(SearchQuery {
2241                text: None,
2242                embedding: create_random_embedding(TEST_DIM),
2243                limit: 5,
2244                filters: vec![],
2245                metric: DistanceMetric::Cosine,
2246                scope_prefix: None,
2247            })
2248            .await
2249            .unwrap();
2250        assert!(!results.is_empty());
2251    }
2252
2253    #[tokio::test]
2254    async fn test_replaces_preexisting_l2_vector_index() {
2255        let temp = tempdir().unwrap();
2256        let db_path = temp.path().join("test.lance");
2257        let store = LanceStore::new(db_path, TEST_DIM);
2258        store.init().await.unwrap();
2259
2260        let file_path = PathBuf::from("/test/l2.txt");
2261        let chunks: Vec<Chunk> = (0..16)
2262            .map(|i| {
2263                create_test_chunk(
2264                    &file_path,
2265                    &format!("legacy l2 chunk {i}"),
2266                    create_random_embedding(TEST_DIM),
2267                    i,
2268                )
2269            })
2270            .collect();
2271        store.upsert_chunks(&chunks).await.unwrap();
2272
2273        let table = store.get_chunks_table().await.unwrap();
2274        let created = table
2275            .create_index(
2276                &["vector"],
2277                Index::IvfPq(
2278                    IvfPqIndexBuilder::default()
2279                        .distance_type(DistanceType::L2)
2280                        .num_partitions(1)
2281                        .sample_rate(4),
2282                ),
2283            )
2284            .execute()
2285            .await;
2286        if created.is_err() {
2287            // Fixture only: Lance may refuse a tiny L2 index. Cosine search must still work.
2288            let results = store
2289                .search(SearchQuery {
2290                    text: None,
2291                    embedding: create_random_embedding(TEST_DIM),
2292                    limit: 5,
2293                    filters: vec![],
2294                    metric: DistanceMetric::Cosine,
2295                    scope_prefix: None,
2296                })
2297                .await
2298                .unwrap();
2299            assert!(!results.is_empty());
2300            return;
2301        }
2302
2303        let name = LanceStore::vector_index_name(&table)
2304            .await
2305            .expect("L2 fixture index");
2306        let before = table.index_stats(&name).await.unwrap().unwrap();
2307        assert_eq!(before.distance_type, Some(DistanceType::L2));
2308
2309        store.ensure_vector_index().await.unwrap();
2310
2311        if let Some(after_name) = LanceStore::vector_index_name(&table).await {
2312            let after = table.index_stats(&after_name).await.unwrap().unwrap();
2313            assert!(
2314                LanceStore::ann_index_is_cosine(after.distance_type),
2315                "mismatched L2 index must be replaced with cosine"
2316            );
2317        } else {
2318            // Below MIN_ANN_ROWS: drop L2 and stay on exact scan.
2319        }
2320
2321        let results = store
2322            .search(SearchQuery {
2323                text: None,
2324                embedding: create_random_embedding(TEST_DIM),
2325                limit: 5,
2326                filters: vec![],
2327                metric: DistanceMetric::Cosine,
2328                scope_prefix: None,
2329            })
2330            .await
2331            .unwrap();
2332        assert!(!results.is_empty());
2333    }
2334
2335    #[tokio::test]
2336    async fn test_get_file_not_found() {
2337        let temp = tempdir().unwrap();
2338        let db_path = temp.path().join("test.lance");
2339        let store = LanceStore::new(db_path.clone(), TEST_DIM);
2340        store.init().await.unwrap();
2341
2342        let path = PathBuf::from("/nonexistent/file.txt");
2343        let result = store.get_file(&path).await.unwrap();
2344        assert!(result.is_none());
2345    }
2346
2347    fn create_chunk_with_meta(
2348        file_path: &Path,
2349        content: &str,
2350        embedding: Vec<f32>,
2351        content_type: ContentType,
2352        mime_type: Option<String>,
2353        depth: u8,
2354    ) -> Chunk {
2355        Chunk {
2356            id: Uuid::new_v4(),
2357            file_id: Uuid::new_v4(),
2358            file_path: file_path.to_path_buf(),
2359            content: content.to_string(),
2360            content_type,
2361            mime_type,
2362            chunk_index: 0,
2363            byte_range: 0..content.len() as u64,
2364            line_range: Some(0..1),
2365            parent_chunk_id: None,
2366            depth,
2367            embedding: Some(embedding),
2368            dir_path: DirectoryScope::from_file_path(file_path).dir_path,
2369            dir_depth: DirectoryScope::from_file_path(file_path).dir_depth,
2370            path_components: DirectoryScope::from_file_path(file_path).path_components,
2371            metadata: ChunkMetadata {
2372                indexed_at: Some(Utc::now()),
2373                embedding_model: Some("test-model".to_string()),
2374                token_count: None,
2375                extra: HashMap::new(),
2376            },
2377        }
2378    }
2379
2380    async fn seeded_filter_store() -> (tempfile::TempDir, LanceStore, Vec<f32>) {
2381        let temp = tempdir().unwrap();
2382        let db_path = temp.path().join("test.lance");
2383        let store = LanceStore::new(db_path, TEST_DIM);
2384        store.init().await.unwrap();
2385
2386        let embedding = create_random_embedding(TEST_DIM);
2387        let chunks = vec![
2388            create_chunk_with_meta(
2389                Path::new("src/lib.rs"),
2390                "fn rust_auth() {}",
2391                embedding.clone(),
2392                ContentType::Code {
2393                    language: "rust".to_string(),
2394                    symbol: None,
2395                },
2396                Some("text/x-rust".to_string()),
2397                0,
2398            ),
2399            create_chunk_with_meta(
2400                Path::new("src/app.py"),
2401                "def python_auth(): pass",
2402                embedding.clone(),
2403                ContentType::Code {
2404                    language: "python".to_string(),
2405                    symbol: None,
2406                },
2407                Some("text/x-python".to_string()),
2408                1,
2409            ),
2410            create_chunk_with_meta(
2411                Path::new("docs/readme.md"),
2412                "authentication notes",
2413                embedding.clone(),
2414                ContentType::Markdown,
2415                Some("text/markdown".to_string()),
2416                3,
2417            ),
2418            create_chunk_with_meta(
2419                Path::new("notes/plain.txt"),
2420                "plain authentication text",
2421                embedding.clone(),
2422                ContentType::Text,
2423                Some("text/plain".to_string()),
2424                2,
2425            ),
2426        ];
2427        store.upsert_chunks(&chunks).await.unwrap();
2428        (temp, store, embedding)
2429    }
2430
2431    fn search_query(
2432        embedding: Vec<f32>,
2433        filters: Vec<SearchFilter>,
2434        metric: DistanceMetric,
2435    ) -> SearchQuery {
2436        SearchQuery {
2437            text: Some("authentication".to_string()),
2438            embedding,
2439            limit: 10,
2440            filters,
2441            metric,
2442            scope_prefix: None,
2443        }
2444    }
2445
2446    #[test]
2447    fn test_filters_to_sql_language_path_type_depth() {
2448        let sql = filters_to_sql(&[
2449            SearchFilter::Language("Rust".to_string()),
2450            SearchFilter::PathPrefix("src/".to_string()),
2451            SearchFilter::MimeType("code".to_string()),
2452            SearchFilter::MaxDepth(2),
2453        ])
2454        .unwrap();
2455
2456        assert!(sql.contains("LOWER(language) = 'rust'"));
2457        assert!(sql.contains("LOWER(content_type) = 'code:rust'"));
2458        assert!(sql.contains("file_path LIKE 'src/%' ESCAPE '\\'"));
2459        assert!(sql.contains("LOWER(content_type) LIKE 'code:%'"));
2460        assert!(sql.contains("depth <= 2"));
2461        assert!(sql.contains(" AND "));
2462    }
2463
2464    #[test]
2465    fn test_filters_to_sql_path_glob_and_mime() {
2466        let sql = filters_to_sql(&[
2467            SearchFilter::PathGlob("src/**/*.rs".to_string()),
2468            SearchFilter::MimeType("text/x-rust".to_string()),
2469            SearchFilter::MinDepth(1),
2470        ])
2471        .unwrap();
2472
2473        assert!(sql.contains("regexp_like(file_path, '^src/(?:.*/)?[^/]*\\.rs$')"));
2474        assert!(sql.contains("LOWER(file_mime_type) = 'text/x-rust'"));
2475        assert!(sql.contains("depth >= 1"));
2476    }
2477
2478    #[test]
2479    fn test_filters_to_sql_escapes_quotes() {
2480        let sql = filters_to_sql(&[SearchFilter::PathPrefix("o'brien".to_string())]).unwrap();
2481        assert!(sql.contains("o''brien"));
2482    }
2483
2484    #[test]
2485    fn test_filters_to_sql_empty() {
2486        assert!(filters_to_sql(&[]).is_none());
2487    }
2488
2489    #[test]
2490    fn test_glob_to_regex_preserves_path_segments() {
2491        assert_eq!(glob_to_regex("src/*.rs"), r"^src/[^/]*\.rs$");
2492        assert_eq!(glob_to_regex("src/**/mod.rs"), r"^src/(?:.*/)?mod\.rs$");
2493        assert_eq!(glob_to_regex("src/**"), r"^src/.*$");
2494        assert_eq!(glob_to_regex("file?.txt"), r"^file[^/]\.txt$");
2495        assert!(!glob_to_regex("src/*.rs").contains(".*"));
2496        assert!(!glob_to_regex("src/**/mod.rs").contains("%mod"));
2497    }
2498
2499    #[test]
2500    fn test_file_date_filters_use_modified_at() {
2501        let after = chrono::DateTime::parse_from_rfc3339("2024-01-01T00:00:00+00:00")
2502            .unwrap()
2503            .with_timezone(&Utc);
2504        let sql = file_date_filters_to_sql(&[SearchFilter::ModifiedAfter(after)]).unwrap();
2505        assert!(sql.contains("modified_at >= "));
2506        assert!(!sql.contains("indexed_at"));
2507        assert!(filters_to_sql(&[SearchFilter::ModifiedAfter(after)]).is_none());
2508    }
2509
2510    #[test]
2511    fn test_distance_type_from_metric() {
2512        assert_eq!(
2513            distance_type_from_metric(DistanceMetric::Cosine),
2514            DistanceType::Cosine
2515        );
2516        assert_eq!(
2517            distance_type_from_metric(DistanceMetric::L2),
2518            DistanceType::L2
2519        );
2520        assert_eq!(
2521            distance_type_from_metric(DistanceMetric::Dot),
2522            DistanceType::Dot
2523        );
2524    }
2525
2526    #[tokio::test]
2527    async fn test_search_filters_by_language() {
2528        let (_temp, store, embedding) = seeded_filter_store().await;
2529        let results = store
2530            .search(search_query(
2531                embedding,
2532                vec![SearchFilter::Language("rust".to_string())],
2533                DistanceMetric::Cosine,
2534            ))
2535            .await
2536            .unwrap();
2537
2538        assert_eq!(results.len(), 1);
2539        assert_eq!(results[0].file_path, PathBuf::from("src/lib.rs"));
2540    }
2541
2542    #[tokio::test]
2543    async fn test_search_filters_by_path_prefix() {
2544        let (_temp, store, embedding) = seeded_filter_store().await;
2545        let results = store
2546            .search(search_query(
2547                embedding,
2548                vec![SearchFilter::PathPrefix("src/".to_string())],
2549                DistanceMetric::Cosine,
2550            ))
2551            .await
2552            .unwrap();
2553
2554        let paths: Vec<_> = results.iter().map(|r| r.file_path.clone()).collect();
2555        assert_eq!(results.len(), 2);
2556        assert!(paths.contains(&PathBuf::from("src/lib.rs")));
2557        assert!(paths.contains(&PathBuf::from("src/app.py")));
2558        assert!(!paths.contains(&PathBuf::from("docs/readme.md")));
2559    }
2560
2561    #[tokio::test]
2562    async fn test_search_filters_by_path_glob() {
2563        let (_temp, store, embedding) = seeded_filter_store().await;
2564        let results = store
2565            .search(search_query(
2566                embedding,
2567                vec![SearchFilter::PathGlob("src/**".to_string())],
2568                DistanceMetric::Cosine,
2569            ))
2570            .await
2571            .unwrap();
2572
2573        let paths: Vec<_> = results.iter().map(|r| r.file_path.clone()).collect();
2574        assert_eq!(results.len(), 2);
2575        assert!(paths.contains(&PathBuf::from("src/lib.rs")));
2576        assert!(paths.contains(&PathBuf::from("src/app.py")));
2577    }
2578
2579    #[tokio::test]
2580    async fn test_search_filters_by_type_code() {
2581        let (_temp, store, embedding) = seeded_filter_store().await;
2582        let results = store
2583            .search(search_query(
2584                embedding,
2585                vec![SearchFilter::MimeType("code".to_string())],
2586                DistanceMetric::Cosine,
2587            ))
2588            .await
2589            .unwrap();
2590
2591        let paths: Vec<_> = results.iter().map(|r| r.file_path.clone()).collect();
2592        assert_eq!(results.len(), 2);
2593        assert!(paths.contains(&PathBuf::from("src/lib.rs")));
2594        assert!(paths.contains(&PathBuf::from("src/app.py")));
2595        assert!(!paths.contains(&PathBuf::from("notes/plain.txt")));
2596    }
2597
2598    #[tokio::test]
2599    async fn test_search_filters_by_mime_type() {
2600        let (_temp, store, embedding) = seeded_filter_store().await;
2601        let results = store
2602            .search(search_query(
2603                embedding,
2604                vec![SearchFilter::MimeType("text/plain".to_string())],
2605                DistanceMetric::Cosine,
2606            ))
2607            .await
2608            .unwrap();
2609
2610        assert_eq!(results.len(), 1);
2611        assert_eq!(results[0].file_path, PathBuf::from("notes/plain.txt"));
2612    }
2613
2614    #[tokio::test]
2615    async fn test_search_filters_by_max_depth() {
2616        let (_temp, store, embedding) = seeded_filter_store().await;
2617        let results = store
2618            .search(search_query(
2619                embedding,
2620                vec![SearchFilter::MaxDepth(1)],
2621                DistanceMetric::Cosine,
2622            ))
2623            .await
2624            .unwrap();
2625
2626        let paths: Vec<_> = results.iter().map(|r| r.file_path.clone()).collect();
2627        assert_eq!(results.len(), 2);
2628        assert!(paths.contains(&PathBuf::from("src/lib.rs")));
2629        assert!(paths.contains(&PathBuf::from("src/app.py")));
2630        assert!(!paths.contains(&PathBuf::from("docs/readme.md")));
2631        assert!(!paths.contains(&PathBuf::from("notes/plain.txt")));
2632    }
2633
2634    #[tokio::test]
2635    async fn test_search_combines_filters() {
2636        let (_temp, store, embedding) = seeded_filter_store().await;
2637        let results = store
2638            .search(search_query(
2639                embedding,
2640                vec![
2641                    SearchFilter::PathPrefix("src/".to_string()),
2642                    SearchFilter::Language("python".to_string()),
2643                    SearchFilter::MaxDepth(2),
2644                ],
2645                DistanceMetric::Cosine,
2646            ))
2647            .await
2648            .unwrap();
2649
2650        assert_eq!(results.len(), 1);
2651        assert_eq!(results[0].file_path, PathBuf::from("src/app.py"));
2652    }
2653
2654    #[tokio::test]
2655    async fn test_search_applies_l2_and_dot_metrics() {
2656        let (_temp, store, embedding) = seeded_filter_store().await;
2657        let filters = vec![SearchFilter::Language("rust".to_string())];
2658
2659        for metric in [
2660            DistanceMetric::L2,
2661            DistanceMetric::Dot,
2662            DistanceMetric::Cosine,
2663        ] {
2664            let results = store
2665                .search(search_query(embedding.clone(), filters.clone(), metric))
2666                .await
2667                .unwrap();
2668            assert_eq!(
2669                results.len(),
2670                1,
2671                "metric {metric:?} should still honor language filter"
2672            );
2673            assert_eq!(results[0].file_path, PathBuf::from("src/lib.rs"));
2674        }
2675    }
2676
2677    #[tokio::test]
2678    async fn test_hybrid_search_applies_filters_and_metric() {
2679        let (_temp, store, embedding) = seeded_filter_store().await;
2680        let results = store
2681            .hybrid_search(search_query(
2682                embedding,
2683                vec![
2684                    SearchFilter::Language("rust".to_string()),
2685                    SearchFilter::MimeType("code".to_string()),
2686                    SearchFilter::MaxDepth(1),
2687                ],
2688                DistanceMetric::Cosine,
2689            ))
2690            .await
2691            .unwrap();
2692
2693        assert_eq!(results.len(), 1);
2694        assert_eq!(results[0].file_path, PathBuf::from("src/lib.rs"));
2695        assert!(results[0].content.contains("rust_auth"));
2696    }
2697
2698    async fn seeded_glob_boundary_store() -> (tempfile::TempDir, LanceStore, Vec<f32>) {
2699        let temp = tempdir().unwrap();
2700        let store = LanceStore::new(temp.path().join("test.lance"), TEST_DIM);
2701        store.init().await.unwrap();
2702        let embedding = create_random_embedding(TEST_DIM);
2703        store
2704            .upsert_chunks(&[
2705                create_test_chunk(Path::new("src/lib.rs"), "lib", embedding.clone(), 0),
2706                create_test_chunk(
2707                    Path::new("src/nested/file.rs"),
2708                    "nested",
2709                    embedding.clone(),
2710                    0,
2711                ),
2712                create_test_chunk(Path::new("src/mod.rs"), "mod file", embedding.clone(), 0),
2713                create_test_chunk(Path::new("src/notmod.rs"), "not mod", embedding.clone(), 0),
2714            ])
2715            .await
2716            .unwrap();
2717        (temp, store, embedding)
2718    }
2719
2720    #[tokio::test]
2721    async fn test_search_path_glob_star_does_not_cross_slash() {
2722        let (_temp, store, embedding) = seeded_glob_boundary_store().await;
2723        let results = store
2724            .search(search_query(
2725                embedding,
2726                vec![SearchFilter::PathGlob("src/*.rs".to_string())],
2727                DistanceMetric::Cosine,
2728            ))
2729            .await
2730            .unwrap();
2731
2732        let paths: Vec<_> = results.iter().map(|r| r.file_path.clone()).collect();
2733        assert!(paths.contains(&PathBuf::from("src/lib.rs")));
2734        assert!(paths.contains(&PathBuf::from("src/mod.rs")));
2735        assert!(paths.contains(&PathBuf::from("src/notmod.rs")));
2736        assert!(!paths.contains(&PathBuf::from("src/nested/file.rs")));
2737    }
2738
2739    #[tokio::test]
2740    async fn test_search_path_glob_doublestar_keeps_separator() {
2741        let (_temp, store, embedding) = seeded_glob_boundary_store().await;
2742        let results = store
2743            .search(search_query(
2744                embedding,
2745                vec![SearchFilter::PathGlob("src/**/mod.rs".to_string())],
2746                DistanceMetric::Cosine,
2747            ))
2748            .await
2749            .unwrap();
2750
2751        let paths: Vec<_> = results.iter().map(|r| r.file_path.clone()).collect();
2752        assert_eq!(results.len(), 1);
2753        assert!(paths.contains(&PathBuf::from("src/mod.rs")));
2754        assert!(!paths.contains(&PathBuf::from("src/notmod.rs")));
2755        assert!(!paths.contains(&PathBuf::from("src/lib.rs")));
2756    }
2757
2758    #[tokio::test]
2759    async fn test_search_filters_by_source_modified_at() {
2760        let temp = tempdir().unwrap();
2761        let store = LanceStore::new(temp.path().join("test.lance"), TEST_DIM);
2762        store.init().await.unwrap();
2763        let embedding = create_random_embedding(TEST_DIM);
2764
2765        let old_path = PathBuf::from("old.txt");
2766        let new_path = PathBuf::from("new.txt");
2767        store
2768            .upsert_chunks(&[
2769                create_test_chunk(&old_path, "old file", embedding.clone(), 0),
2770                create_test_chunk(&new_path, "new file", embedding.clone(), 0),
2771            ])
2772            .await
2773            .unwrap();
2774
2775        let old_modified = chrono::DateTime::parse_from_rfc3339("2020-01-01T00:00:00+00:00")
2776            .unwrap()
2777            .with_timezone(&Utc);
2778        let new_modified = chrono::DateTime::parse_from_rfc3339("2024-06-01T00:00:00+00:00")
2779            .unwrap()
2780            .with_timezone(&Utc);
2781        let cutoff = chrono::DateTime::parse_from_rfc3339("2022-01-01T00:00:00+00:00")
2782            .unwrap()
2783            .with_timezone(&Utc);
2784
2785        let mut old_record = create_test_file_record(&old_path);
2786        old_record.modified_at = old_modified;
2787        let mut new_record = create_test_file_record(&new_path);
2788        new_record.modified_at = new_modified;
2789        store.upsert_file(&old_record).await.unwrap();
2790        store.upsert_file(&new_record).await.unwrap();
2791
2792        let results = store
2793            .search(search_query(
2794                embedding,
2795                vec![SearchFilter::ModifiedAfter(cutoff)],
2796                DistanceMetric::Cosine,
2797            ))
2798            .await
2799            .unwrap();
2800
2801        assert_eq!(results.len(), 1);
2802        assert_eq!(results[0].file_path, new_path);
2803    }
2804
2805    fn create_chunk_with_scope(
2806        file_path: &Path,
2807        root: Option<&Path>,
2808        content: &str,
2809        embedding: Vec<f32>,
2810    ) -> Chunk {
2811        let scope = DirectoryScope::from_paths(file_path, root);
2812        let mut chunk = create_test_chunk(file_path, content, embedding, 0);
2813        chunk.dir_path = scope.dir_path;
2814        chunk.dir_depth = scope.dir_depth;
2815        chunk.path_components = scope.path_components;
2816        chunk
2817    }
2818
2819    async fn seeded_scope_store() -> (tempfile::TempDir, LanceStore, Vec<f32>) {
2820        let temp = tempdir().unwrap();
2821        let db_path = temp.path().join("test.lance");
2822        let store = LanceStore::new(db_path, TEST_DIM);
2823        store.init().await.unwrap();
2824
2825        let root = Path::new("/project");
2826        let embedding = create_random_embedding(TEST_DIM);
2827        let chunks = vec![
2828            create_chunk_with_scope(
2829                Path::new("/project/src/auth/login.rs"),
2830                Some(root),
2831                "login handler",
2832                embedding.clone(),
2833            ),
2834            create_chunk_with_scope(
2835                Path::new("/project/src/auth/oauth/token.rs"),
2836                Some(root),
2837                "oauth token",
2838                embedding.clone(),
2839            ),
2840            create_chunk_with_scope(
2841                Path::new("/project/src/db.rs"),
2842                Some(root),
2843                "database pool",
2844                embedding.clone(),
2845            ),
2846            create_chunk_with_scope(
2847                Path::new("/project/docs/readme.md"),
2848                Some(root),
2849                "project readme",
2850                embedding.clone(),
2851            ),
2852        ];
2853        store.upsert_chunks(&chunks).await.unwrap();
2854        (temp, store, embedding)
2855    }
2856
2857    fn scoped_query(embedding: Vec<f32>, scope: &str) -> SearchQuery {
2858        SearchQuery {
2859            text: None,
2860            embedding,
2861            limit: 10,
2862            filters: vec![],
2863            metric: DistanceMetric::Cosine,
2864            scope_prefix: Some(scope.to_string()),
2865        }
2866    }
2867
2868    #[test]
2869    fn test_scope_prefix_sql_exact_or_subdirectory() {
2870        let sql = scope_prefix_sql("src/auth/").unwrap();
2871        assert!(sql.contains("dir_path = 'src/auth'"));
2872        assert!(sql.contains("dir_path LIKE 'src/auth/%'"));
2873        assert!(sql.contains("dir_path LIKE '%/src/auth'"));
2874        assert!(sql.contains("dir_path LIKE '%/src/auth/%'"));
2875        assert!(scope_prefix_sql("").is_none());
2876        assert!(scope_prefix_sql(".").is_none());
2877    }
2878
2879    #[test]
2880    fn test_scope_prefix_sql_escapes_quotes() {
2881        let sql = scope_prefix_sql("o'brien").unwrap();
2882        assert!(sql.contains("o''brien"));
2883    }
2884
2885    #[tokio::test]
2886    async fn test_scoped_search_stores_relative_dir_path() {
2887        let (_temp, store, _) = seeded_scope_store().await;
2888        let chunks = store
2889            .get_chunks_for_file(Path::new("/project/src/auth/login.rs"))
2890            .await
2891            .unwrap();
2892        assert_eq!(chunks.len(), 1);
2893        assert_eq!(chunks[0].dir_path, "src/auth");
2894        assert_eq!(chunks[0].dir_depth, 2);
2895        assert_eq!(chunks[0].path_components, "src,auth,login.rs");
2896        assert!(!chunks[0].dir_path.starts_with('/'));
2897    }
2898
2899    #[tokio::test]
2900    async fn test_scoped_search_exact_directory() {
2901        let (_temp, store, embedding) = seeded_scope_store().await;
2902        let results = store
2903            .search(scoped_query(embedding, "src/auth"))
2904            .await
2905            .unwrap();
2906        let paths: Vec<_> = results
2907            .iter()
2908            .map(|r| r.file_path.to_string_lossy().to_string())
2909            .collect();
2910        assert!(paths.iter().any(|p| p.ends_with("src/auth/login.rs")));
2911        assert!(paths.iter().any(|p| p.ends_with("src/auth/oauth/token.rs")));
2912        assert!(!paths.iter().any(|p| p.ends_with("src/db.rs")));
2913        assert!(!paths.iter().any(|p| p.ends_with("docs/readme.md")));
2914    }
2915
2916    #[tokio::test]
2917    async fn test_scoped_search_includes_subdirectory() {
2918        let (_temp, store, embedding) = seeded_scope_store().await;
2919        let results = store.search(scoped_query(embedding, "src")).await.unwrap();
2920        let paths: Vec<_> = results
2921            .iter()
2922            .map(|r| r.file_path.to_string_lossy().to_string())
2923            .collect();
2924        assert!(paths.iter().any(|p| p.ends_with("src/auth/login.rs")));
2925        assert!(paths.iter().any(|p| p.ends_with("src/auth/oauth/token.rs")));
2926        assert!(paths.iter().any(|p| p.ends_with("src/db.rs")));
2927        assert!(!paths.iter().any(|p| p.ends_with("docs/readme.md")));
2928    }
2929
2930    #[tokio::test]
2931    async fn test_scoped_search_excludes_outside_directory() {
2932        let (_temp, store, embedding) = seeded_scope_store().await;
2933        let results = store.search(scoped_query(embedding, "docs")).await.unwrap();
2934        assert_eq!(results.len(), 1);
2935        assert!(
2936            results[0]
2937                .file_path
2938                .to_string_lossy()
2939                .ends_with("docs/readme.md")
2940        );
2941    }
2942
2943    fn v1_chunks_schema(dim: usize) -> Schema {
2944        Schema::new(vec![
2945            Field::new("chunk_id", DataType::Utf8, false),
2946            Field::new("file_id", DataType::Utf8, false),
2947            Field::new("file_path", DataType::Utf8, false),
2948            Field::new("content", DataType::Utf8, false),
2949            Field::new("content_type", DataType::Utf8, false),
2950            Field::new("chunk_index", DataType::UInt32, false),
2951            Field::new("start_byte", DataType::UInt64, false),
2952            Field::new("end_byte", DataType::UInt64, false),
2953            Field::new("start_line", DataType::UInt32, true),
2954            Field::new("end_line", DataType::UInt32, true),
2955            Field::new("parent_chunk_id", DataType::Utf8, true),
2956            Field::new("depth", DataType::UInt8, false),
2957            Field::new(
2958                "vector",
2959                DataType::FixedSizeList(
2960                    Arc::new(Field::new("item", DataType::Float32, true)),
2961                    dim as i32,
2962                ),
2963                false,
2964            ),
2965            Field::new("embedding_model", DataType::Utf8, true),
2966            Field::new("indexed_at", DataType::Utf8, false),
2967            Field::new("file_mime_type", DataType::Utf8, true),
2968            Field::new("language", DataType::Utf8, true),
2969            Field::new("symbol_type", DataType::Utf8, true),
2970            Field::new("symbol_name", DataType::Utf8, true),
2971        ])
2972    }
2973
2974    async fn seed_v1_chunks_table(
2975        db_path: &Path,
2976        file_path: &str,
2977        content: &str,
2978        embedding: &[f32],
2979    ) {
2980        let conn = connect(db_path.to_str().unwrap()).execute().await.unwrap();
2981        let schema = Arc::new(v1_chunks_schema(TEST_DIM));
2982        conn.create_empty_table(CHUNKS_TABLE, schema.clone())
2983            .execute()
2984            .await
2985            .unwrap();
2986
2987        let embeddings = vec![Some(embedding.iter().copied().map(Some).collect())];
2988        let vector = build_vector_array(&embeddings, TEST_DIM).unwrap();
2989        let batch = RecordBatch::try_new(
2990            schema,
2991            vec![
2992                Arc::new(StringArray::from(vec![Uuid::new_v4().to_string()])),
2993                Arc::new(StringArray::from(vec![Uuid::new_v4().to_string()])),
2994                Arc::new(StringArray::from(vec![file_path.to_string()])),
2995                Arc::new(StringArray::from(vec![content.to_string()])),
2996                Arc::new(StringArray::from(vec!["text".to_string()])),
2997                Arc::new(UInt32Array::from(vec![0u32])),
2998                Arc::new(UInt64Array::from(vec![0u64])),
2999                Arc::new(UInt64Array::from(vec![content.len() as u64])),
3000                Arc::new(UInt32Array::from(vec![Some(0u32)])),
3001                Arc::new(UInt32Array::from(vec![Some(1u32)])),
3002                Arc::new(StringArray::from(vec![None::<String>])),
3003                Arc::new(UInt8Array::from(vec![0u8])),
3004                vector,
3005                Arc::new(StringArray::from(vec![Some("test-model".to_string())])),
3006                Arc::new(StringArray::from(vec![Utc::now().to_rfc3339()])),
3007                Arc::new(StringArray::from(vec![Some("text/plain".to_string())])),
3008                Arc::new(StringArray::from(vec![None::<String>])),
3009                Arc::new(StringArray::from(vec![None::<String>])),
3010                Arc::new(StringArray::from(vec![None::<String>])),
3011            ],
3012        )
3013        .unwrap();
3014
3015        let table = conn.open_table(CHUNKS_TABLE).execute().await.unwrap();
3016        let schema = batch.schema();
3017        let batches = RecordBatchIterator::new(vec![Ok(batch)], schema);
3018        table
3019            .add(Box::new(batches) as Box<dyn RecordBatchReader + Send>)
3020            .execute()
3021            .await
3022            .unwrap();
3023    }
3024
3025    #[tokio::test]
3026    async fn test_schema_v2_migrates_existing_index() {
3027        let temp = tempdir().unwrap();
3028        let db_path = temp.path().join("test.lance");
3029        let file_path = "/project/src/auth/login.rs";
3030        let embedding = create_random_embedding(TEST_DIM);
3031        seed_v1_chunks_table(&db_path, file_path, "login handler", &embedding).await;
3032
3033        assert!(!temp.path().join(SCHEMA_SIDECAR_FILENAME).exists());
3034
3035        let store = LanceStore::new(db_path, TEST_DIM);
3036        store.init().await.unwrap();
3037
3038        let sidecar: SchemaSidecar = serde_json::from_slice(
3039            &std::fs::read(temp.path().join(SCHEMA_SIDECAR_FILENAME)).unwrap(),
3040        )
3041        .unwrap();
3042        assert_eq!(sidecar.chunks_schema_version, CHUNKS_SCHEMA_VERSION);
3043        assert_eq!(sidecar.embedding_dim, Some(TEST_DIM));
3044
3045        let chunks = store
3046            .get_chunks_for_file(Path::new(file_path))
3047            .await
3048            .unwrap();
3049        assert_eq!(chunks.len(), 1);
3050        assert_eq!(chunks[0].dir_path, "project/src/auth");
3051        assert_eq!(chunks[0].dir_depth, 3);
3052        assert_eq!(chunks[0].path_components, "project,src,auth,login.rs");
3053
3054        let results = store
3055            .search(scoped_query(embedding, "src/auth"))
3056            .await
3057            .unwrap();
3058        assert_eq!(results.len(), 1);
3059        assert!(
3060            results[0]
3061                .file_path
3062                .to_string_lossy()
3063                .ends_with("src/auth/login.rs")
3064        );
3065
3066        // Second init is idempotent (sidecar already v2, columns present).
3067        store.init().await.unwrap();
3068        let again = store
3069            .get_chunks_for_file(Path::new(file_path))
3070            .await
3071            .unwrap();
3072        assert_eq!(again[0].dir_path, "project/src/auth");
3073    }
3074
3075    #[tokio::test]
3076    async fn test_new_index_writes_schema_v2_sidecar() {
3077        let temp = tempdir().unwrap();
3078        let db_path = temp.path().join("test.lance");
3079        let store = LanceStore::new(db_path, TEST_DIM).with_embedding_model("thenlper/gte-small");
3080        store.init().await.unwrap();
3081
3082        let sidecar: SchemaSidecar = serde_json::from_slice(
3083            &std::fs::read(temp.path().join(SCHEMA_SIDECAR_FILENAME)).unwrap(),
3084        )
3085        .unwrap();
3086        assert_eq!(sidecar.chunks_schema_version, CHUNKS_SCHEMA_VERSION);
3087        assert_eq!(sidecar.embedding_dim, Some(TEST_DIM));
3088        assert_eq!(
3089            sidecar.embedding_model.as_deref(),
3090            Some("thenlper/gte-small")
3091        );
3092    }
3093}