Skip to main content

mockforge_data/rag/
storage.rs

1//! Document storage and vector indexing
2//!
3//! This module provides storage backends for documents and their vector embeddings,
4//! supporting various indexing strategies and similarity search algorithms.
5
6use crate::rag::engine::DocumentChunk;
7use crate::Result;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::sync::Arc;
11use tokio::sync::RwLock;
12
13type VectorStore = Arc<RwLock<Vec<(String, Vec<f32>)>>>;
14
15/// Qdrant-backed persistent vector storage (requires the `qdrant` feature).
16#[cfg(feature = "qdrant")]
17pub mod qdrant;
18
19/// Vector index for similarity search
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct VectorIndex {
22    /// Index ID
23    pub id: String,
24    /// Index name
25    pub name: String,
26    /// Index type
27    pub index_type: IndexType,
28    /// Vector dimensions
29    pub dimensions: usize,
30    /// Number of vectors indexed
31    pub vector_count: usize,
32    /// Index metadata
33    pub metadata: HashMap<String, String>,
34    /// Creation timestamp
35    pub created_at: chrono::DateTime<chrono::Utc>,
36    /// Last updated timestamp
37    pub updated_at: chrono::DateTime<chrono::Utc>,
38}
39
40impl VectorIndex {
41    /// Create a new vector index
42    pub fn new(id: String, name: String, index_type: IndexType, dimensions: usize) -> Self {
43        let now = chrono::Utc::now();
44        Self {
45            id,
46            name,
47            index_type,
48            dimensions,
49            vector_count: 0,
50            metadata: HashMap::new(),
51            created_at: now,
52            updated_at: now,
53        }
54    }
55
56    /// Add metadata to index
57    pub fn add_metadata(&mut self, key: String, value: String) {
58        self.metadata.insert(key, value);
59        self.updated_at = chrono::Utc::now();
60    }
61
62    /// Get metadata value
63    pub fn get_metadata(&self, key: &str) -> Option<&String> {
64        self.metadata.get(key)
65    }
66
67    /// Remove metadata
68    pub fn remove_metadata(&mut self, key: &str) -> Option<String> {
69        let result = self.metadata.remove(key);
70        if result.is_some() {
71            self.updated_at = chrono::Utc::now();
72        }
73        result
74    }
75
76    /// Update vector count
77    pub fn update_vector_count(&mut self, count: usize) {
78        self.vector_count = count;
79        self.updated_at = chrono::Utc::now();
80    }
81
82    /// Get index size estimate in bytes
83    pub fn estimated_size_bytes(&self) -> u64 {
84        // Rough estimate: each vector takes ~4 bytes per dimension + overhead
85        (self.vector_count * self.dimensions * 4 + 1024) as u64
86    }
87
88    /// Check if index is empty
89    pub fn is_empty(&self) -> bool {
90        self.vector_count == 0
91    }
92
93    /// Get index statistics
94    pub fn stats(&self) -> IndexStats {
95        IndexStats {
96            id: self.id.clone(),
97            name: self.name.clone(),
98            index_type: self.index_type.clone(),
99            dimensions: self.dimensions,
100            vector_count: self.vector_count,
101            estimated_size_bytes: self.estimated_size_bytes(),
102            metadata_count: self.metadata.len(),
103            created_at: self.created_at,
104            updated_at: self.updated_at,
105        }
106    }
107}
108
109/// Index statistics
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct IndexStats {
112    /// Index ID
113    pub id: String,
114    /// Index name
115    pub name: String,
116    /// Index type
117    pub index_type: IndexType,
118    /// Vector dimensions
119    pub dimensions: usize,
120    /// Number of vectors
121    pub vector_count: usize,
122    /// Estimated size in bytes
123    pub estimated_size_bytes: u64,
124    /// Number of metadata entries
125    pub metadata_count: usize,
126    /// Creation timestamp
127    pub created_at: chrono::DateTime<chrono::Utc>,
128    /// Last updated timestamp
129    pub updated_at: chrono::DateTime<chrono::Utc>,
130}
131
132/// Index type enumeration
133#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
134pub enum IndexType {
135    /// Flat index - brute force search
136    #[default]
137    Flat,
138    /// IVF (Inverted File) index - for large datasets
139    IVF,
140    /// HNSW (Hierarchical Navigable Small World) index - for high performance
141    HNSW,
142    /// PQ (Product Quantization) index - for memory efficiency
143    PQ,
144    /// Custom index type
145    Custom(String),
146}
147
148/// Search parameters for vector search
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct SearchParams {
151    /// Number of results to return
152    pub top_k: usize,
153    /// Similarity threshold (0.0 to 1.0)
154    pub threshold: f32,
155    /// Search method to use
156    pub search_method: SearchMethod,
157    /// Include metadata in results
158    pub include_metadata: bool,
159    /// Filter by document ID
160    pub document_filter: Option<String>,
161    /// Filter by metadata
162    pub metadata_filter: Option<HashMap<String, String>>,
163}
164
165impl Default for SearchParams {
166    fn default() -> Self {
167        Self {
168            top_k: 10,
169            threshold: 0.7,
170            search_method: SearchMethod::Cosine,
171            include_metadata: true,
172            document_filter: None,
173            metadata_filter: None,
174        }
175    }
176}
177
178/// Search method enumeration
179#[derive(Debug, Clone, Default, Serialize, Deserialize)]
180pub enum SearchMethod {
181    /// Cosine similarity
182    #[default]
183    Cosine,
184    /// Euclidean distance
185    Euclidean,
186    /// Dot product
187    DotProduct,
188    /// Manhattan distance
189    Manhattan,
190}
191
192/// Storage backend trait for documents and vectors
193#[async_trait::async_trait]
194pub trait DocumentStorage: Send + Sync {
195    /// Store document chunks
196    async fn store_chunks(&self, chunks: Vec<DocumentChunk>) -> Result<()>;
197
198    /// Search for similar chunks
199    async fn search_similar(
200        &self,
201        query_embedding: &[f32],
202        top_k: usize,
203    ) -> Result<Vec<DocumentChunk>>;
204
205    /// Search with custom parameters
206    async fn search_with_params(
207        &self,
208        query_embedding: &[f32],
209        params: SearchParams,
210    ) -> Result<Vec<DocumentChunk>>;
211
212    /// Get chunk by ID
213    async fn get_chunk(&self, chunk_id: &str) -> Result<Option<DocumentChunk>>;
214
215    /// Delete chunk by ID
216    async fn delete_chunk(&self, chunk_id: &str) -> Result<bool>;
217
218    /// Get chunks by document ID
219    async fn get_chunks_by_document(&self, document_id: &str) -> Result<Vec<DocumentChunk>>;
220
221    /// Delete all chunks for a document
222    async fn delete_document(&self, document_id: &str) -> Result<usize>;
223
224    /// Get storage statistics
225    async fn get_stats(&self) -> Result<StorageStats>;
226
227    /// List all document IDs
228    async fn list_documents(&self) -> Result<Vec<String>>;
229
230    /// Get total number of chunks
231    async fn get_total_chunks(&self) -> Result<usize>;
232
233    /// Clear all data
234    async fn clear(&self) -> Result<()>;
235
236    /// Optimize storage (rebuild indexes, compact data)
237    async fn optimize(&self) -> Result<()>;
238
239    /// Create backup
240    async fn create_backup(&self, path: &str) -> Result<()>;
241
242    /// Restore from backup
243    async fn restore_backup(&self, path: &str) -> Result<()>;
244
245    /// Check storage health
246    async fn health_check(&self) -> Result<StorageHealth>;
247}
248
249/// Storage statistics
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct StorageStats {
252    /// Total number of documents
253    pub total_documents: usize,
254    /// Total number of chunks
255    pub total_chunks: usize,
256    /// Index size in bytes
257    pub index_size_bytes: u64,
258    /// Last updated timestamp
259    pub last_updated: chrono::DateTime<chrono::Utc>,
260    /// Storage backend type
261    pub backend_type: String,
262    /// Available disk space in bytes
263    pub available_space_bytes: u64,
264    /// Used space in bytes
265    pub used_space_bytes: u64,
266}
267
268/// Storage health information
269#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct StorageHealth {
271    /// Overall health status
272    pub status: HealthStatus,
273    /// Health check timestamp
274    pub checked_at: chrono::DateTime<chrono::Utc>,
275    /// Detailed health information
276    pub details: HashMap<String, String>,
277    /// Performance metrics
278    pub metrics: Option<StorageMetrics>,
279}
280
281/// Health status enumeration
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
283pub enum HealthStatus {
284    /// Storage is healthy
285    Healthy,
286    /// Storage has warnings
287    Warning,
288    /// Storage is unhealthy
289    Unhealthy,
290    /// Storage is unavailable
291    Unavailable,
292}
293
294/// Storage performance metrics
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct StorageMetrics {
297    /// Average search time in milliseconds
298    pub average_search_time_ms: f64,
299    /// Average insert time in milliseconds
300    pub average_insert_time_ms: f64,
301    /// Index fragmentation percentage (0.0 to 1.0)
302    pub fragmentation_ratio: f32,
303    /// Cache hit rate (0.0 to 1.0)
304    pub cache_hit_rate: f32,
305    /// Memory usage in bytes
306    pub memory_usage_bytes: u64,
307    /// Disk usage in bytes
308    pub disk_usage_bytes: u64,
309}
310
311/// In-memory storage implementation for development and testing
312pub struct InMemoryStorage {
313    // pub(super) so the file-backed wrapper in this module can rehydrate
314    // these from disk without going through the async trait surface.
315    pub(super) chunks: Arc<RwLock<HashMap<String, DocumentChunk>>>,
316    pub(super) vectors: VectorStore,
317    pub(super) stats: Arc<RwLock<StorageStats>>,
318}
319
320impl InMemoryStorage {
321    /// Create a new in-memory storage
322    pub fn new() -> Self {
323        Self::new_with_backend_type("memory")
324    }
325
326    /// Create in-memory storage with a specific backend label.
327    /// This is used when a persistent backend is configured but running with an in-memory fallback.
328    pub fn new_with_backend_type(backend_type: &str) -> Self {
329        let now = chrono::Utc::now();
330        Self {
331            chunks: Arc::new(RwLock::new(HashMap::new())),
332            vectors: Arc::new(RwLock::new(Vec::new())),
333            stats: Arc::new(RwLock::new(StorageStats {
334                total_documents: 0,
335                total_chunks: 0,
336                index_size_bytes: 0,
337                last_updated: now,
338                backend_type: backend_type.to_string(),
339                available_space_bytes: u64::MAX,
340                used_space_bytes: 0,
341            })),
342        }
343    }
344
345    /// Calculate cosine similarity
346    fn cosine_similarity(&self, a: &[f32], b: &[f32]) -> f32 {
347        if a.len() != b.len() {
348            return 0.0;
349        }
350
351        let dot_product: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
352        let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
353        let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
354
355        if norm_a == 0.0 || norm_b == 0.0 {
356            0.0
357        } else {
358            dot_product / (norm_a * norm_b)
359        }
360    }
361}
362
363impl Default for InMemoryStorage {
364    fn default() -> Self {
365        Self::new()
366    }
367}
368
369#[async_trait::async_trait]
370impl DocumentStorage for InMemoryStorage {
371    async fn store_chunks(&self, chunks: Vec<DocumentChunk>) -> Result<()> {
372        let mut chunks_map = self.chunks.write().await;
373        let mut vectors = self.vectors.write().await;
374        let mut stats = self.stats.write().await;
375
376        for chunk in chunks {
377            chunks_map.insert(chunk.id.clone(), chunk.clone());
378
379            // Store vector for similarity search
380            vectors.push((chunk.id.clone(), chunk.embedding.clone()));
381
382            stats.total_chunks += 1;
383        }
384
385        stats.last_updated = chrono::Utc::now();
386        stats.index_size_bytes = (stats.total_chunks * 1536 * 4) as u64; // Rough estimate
387        stats.used_space_bytes = stats.index_size_bytes;
388
389        Ok(())
390    }
391
392    async fn search_similar(
393        &self,
394        query_embedding: &[f32],
395        top_k: usize,
396    ) -> Result<Vec<DocumentChunk>> {
397        let vectors = self.vectors.read().await;
398        let chunks = self.chunks.read().await;
399
400        let mut similarities: Vec<(String, f32)> = vectors
401            .iter()
402            .map(|(chunk_id, embedding)| {
403                let similarity = self.cosine_similarity(query_embedding, embedding);
404                (chunk_id.clone(), similarity)
405            })
406            .collect();
407
408        // Sort by similarity (descending)
409        similarities.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
410
411        // Take top-k results
412        let mut results = Vec::new();
413        for (chunk_id, _) in similarities.iter().take(top_k) {
414            if let Some(chunk) = chunks.get(chunk_id) {
415                results.push(chunk.clone());
416            }
417        }
418
419        Ok(results)
420    }
421
422    async fn search_with_params(
423        &self,
424        query_embedding: &[f32],
425        params: SearchParams,
426    ) -> Result<Vec<DocumentChunk>> {
427        let mut results = self.search_similar(query_embedding, params.top_k * 2).await?; // Get more candidates
428
429        // Apply filters
430        if let Some(document_filter) = &params.document_filter {
431            results.retain(|chunk| chunk.document_id == *document_filter);
432        }
433
434        if let Some(metadata_filter) = &params.metadata_filter {
435            results.retain(|chunk| {
436                metadata_filter.iter().all(|(key, value)| {
437                    chunk.get_metadata(key).map(|v| v == value).unwrap_or(false)
438                })
439            });
440        }
441
442        // Apply threshold filter
443        results.retain(|chunk| {
444            let similarity = self.cosine_similarity(query_embedding, &chunk.embedding);
445            similarity >= params.threshold
446        });
447
448        // Sort by similarity
449        results.sort_by(|a, b| {
450            let sim_a = self.cosine_similarity(query_embedding, &a.embedding);
451            let sim_b = self.cosine_similarity(query_embedding, &b.embedding);
452            sim_b.partial_cmp(&sim_a).unwrap_or(std::cmp::Ordering::Equal)
453        });
454
455        // Take top-k
456        results.truncate(params.top_k);
457
458        Ok(results)
459    }
460
461    async fn get_chunk(&self, chunk_id: &str) -> Result<Option<DocumentChunk>> {
462        let chunks = self.chunks.read().await;
463        Ok(chunks.get(chunk_id).cloned())
464    }
465
466    async fn delete_chunk(&self, chunk_id: &str) -> Result<bool> {
467        let mut chunks = self.chunks.write().await;
468        let mut vectors = self.vectors.write().await;
469        let mut stats = self.stats.write().await;
470
471        let chunk_removed = chunks.remove(chunk_id).is_some();
472        let _vector_removed = vectors.retain(|(id, _)| id != chunk_id);
473
474        if chunk_removed {
475            stats.total_chunks = stats.total_chunks.saturating_sub(1);
476            stats.last_updated = chrono::Utc::now();
477            stats.index_size_bytes = (stats.total_chunks * 1536 * 4) as u64;
478            stats.used_space_bytes = stats.index_size_bytes;
479        }
480
481        Ok(chunk_removed)
482    }
483
484    async fn get_chunks_by_document(&self, document_id: &str) -> Result<Vec<DocumentChunk>> {
485        let chunks = self.chunks.read().await;
486        let results = chunks
487            .values()
488            .filter(|chunk| chunk.document_id == document_id)
489            .cloned()
490            .collect();
491        Ok(results)
492    }
493
494    async fn delete_document(&self, document_id: &str) -> Result<usize> {
495        let mut chunks = self.chunks.write().await;
496        let mut vectors = self.vectors.write().await;
497        let mut stats = self.stats.write().await;
498
499        let initial_count = chunks.len();
500        chunks.retain(|_, chunk| chunk.document_id != document_id);
501        vectors.retain(|(id, _)| {
502            // Remove vectors for chunks that were removed
503            chunks.contains_key(id)
504        });
505
506        let removed_count = initial_count - chunks.len();
507        if removed_count > 0 {
508            stats.total_chunks = stats.total_chunks.saturating_sub(removed_count);
509            stats.last_updated = chrono::Utc::now();
510            stats.index_size_bytes = (stats.total_chunks * 1536 * 4) as u64;
511            stats.used_space_bytes = stats.index_size_bytes;
512        }
513
514        Ok(removed_count)
515    }
516
517    async fn get_stats(&self) -> Result<StorageStats> {
518        let stats = self.stats.read().await;
519        Ok(stats.clone())
520    }
521
522    async fn list_documents(&self) -> Result<Vec<String>> {
523        let chunks = self.chunks.read().await;
524        let documents: std::collections::HashSet<String> =
525            chunks.values().map(|chunk| chunk.document_id.clone()).collect();
526        Ok(documents.into_iter().collect())
527    }
528
529    async fn get_total_chunks(&self) -> Result<usize> {
530        let stats = self.stats.read().await;
531        Ok(stats.total_chunks)
532    }
533
534    async fn clear(&self) -> Result<()> {
535        let mut chunks = self.chunks.write().await;
536        let mut vectors = self.vectors.write().await;
537        let mut stats = self.stats.write().await;
538
539        chunks.clear();
540        vectors.clear();
541
542        stats.total_documents = 0;
543        stats.total_chunks = 0;
544        stats.index_size_bytes = 0;
545        stats.used_space_bytes = 0;
546        stats.last_updated = chrono::Utc::now();
547
548        Ok(())
549    }
550
551    async fn optimize(&self) -> Result<()> {
552        // In-memory storage doesn't need optimization
553        Ok(())
554    }
555
556    async fn create_backup(&self, path: &str) -> Result<()> {
557        let chunks = self.chunks.read().await;
558        let vectors = self.vectors.read().await;
559
560        let backup_data = serde_json::json!({
561            "version": 1,
562            "created_at": chrono::Utc::now().to_rfc3339(),
563            "chunks": chunks.values().collect::<Vec<_>>(),
564            "vectors": vectors.iter().collect::<Vec<_>>(),
565        });
566
567        let json_bytes = serde_json::to_vec_pretty(&backup_data)?;
568        std::fs::write(path, json_bytes)?;
569
570        Ok(())
571    }
572
573    async fn restore_backup(&self, path: &str) -> Result<()> {
574        let json_bytes = std::fs::read(path)?;
575        let backup_data: serde_json::Value = serde_json::from_slice(&json_bytes)?;
576
577        // Clear current data
578        self.clear().await?;
579
580        let mut chunks_map = self.chunks.write().await;
581        let mut vectors = self.vectors.write().await;
582        let mut stats = self.stats.write().await;
583
584        // Restore chunks
585        if let Some(chunks_arr) = backup_data.get("chunks").and_then(|v| v.as_array()) {
586            for chunk_val in chunks_arr {
587                if let Ok(chunk) = serde_json::from_value::<DocumentChunk>(chunk_val.clone()) {
588                    chunks_map.insert(chunk.id.clone(), chunk);
589                }
590            }
591        }
592
593        // Restore vectors
594        if let Some(vectors_arr) = backup_data.get("vectors").and_then(|v| v.as_array()) {
595            for vector_val in vectors_arr {
596                if let Ok(vector) = serde_json::from_value::<(String, Vec<f32>)>(vector_val.clone())
597                {
598                    vectors.push(vector);
599                }
600            }
601        }
602
603        // Update stats
604        let doc_ids: std::collections::HashSet<String> =
605            chunks_map.values().map(|c| c.document_id.clone()).collect();
606        stats.total_documents = doc_ids.len();
607        stats.total_chunks = chunks_map.len();
608        stats.index_size_bytes = (stats.total_chunks * 1536 * 4) as u64;
609        stats.used_space_bytes = stats.index_size_bytes;
610        stats.last_updated = chrono::Utc::now();
611
612        Ok(())
613    }
614
615    async fn health_check(&self) -> Result<StorageHealth> {
616        let chunks = self.chunks.read().await;
617        let vectors = self.vectors.read().await;
618
619        let mut details = HashMap::new();
620        details.insert("chunk_count".to_string(), chunks.len().to_string());
621        details.insert("vector_count".to_string(), vectors.len().to_string());
622        details.insert("memory_usage".to_string(), "unknown".to_string());
623
624        let status = if chunks.len() == vectors.len() {
625            HealthStatus::Healthy
626        } else {
627            details.insert("error".to_string(), "Chunk/vector count mismatch".to_string());
628            HealthStatus::Unhealthy
629        };
630
631        Ok(StorageHealth {
632            status,
633            checked_at: chrono::Utc::now(),
634            details,
635            metrics: None,
636        })
637    }
638}
639
640/// Storage factory for creating different storage backends
641pub struct StorageFactory;
642
643impl StorageFactory {
644    /// Create in-memory storage. Ephemeral — chunks + vectors live only
645    /// for the process lifetime. Intended for tests and OSS quick-start.
646    pub fn create_memory() -> Box<dyn DocumentStorage> {
647        Box::new(InMemoryStorage::new())
648    }
649
650    /// Create file-backed storage that persists chunks + vectors to a JSON
651    /// snapshot inside `path/`. Closes the "RAG forgets everything on
652    /// restart" half of #669 — embeddings survive process restarts now.
653    ///
654    /// Layout:
655    ///   `<path>/storage.json`  — single-file snapshot (atomic write via tmp)
656    ///
657    /// Tradeoff: rewrites the whole snapshot on every `store_chunks` call.
658    /// Fine for thousand-chunk-scale corpora that fit in RAM (the
659    /// embedded RAG use case); a real vector database is a better fit
660    /// for million-chunk catalogs — see `create_vector_db` below.
661    pub fn create_file(path: &str) -> Result<Box<dyn DocumentStorage>> {
662        if path.trim().is_empty() {
663            return Err(crate::Error::generic("File storage path cannot be empty"));
664        }
665
666        std::fs::create_dir_all(path)?;
667        let dir = std::path::PathBuf::from(path);
668        Ok(Box::new(PersistentFileStorage::new(dir)?))
669    }
670
671    /// Create database storage. Not yet implemented — the connection
672    /// string would route to a sqlx-backed implementation. Returns a
673    /// labelled in-memory store for now and warns rather than failing
674    /// silently. Tracked in #669 follow-up.
675    pub fn create_database(connection_string: &str) -> Result<Box<dyn DocumentStorage>> {
676        if connection_string.trim().is_empty() {
677            return Err(crate::Error::generic("Database connection string cannot be empty"));
678        }
679        tracing::warn!(
680            "create_database falls back to in-memory storage; \
681             sqlx-backed backend is tracked in #669 follow-up"
682        );
683        Ok(Box::new(InMemoryStorage::new_with_backend_type("database")))
684    }
685
686    /// Create vector-database storage. Real vector-DB integrations
687    /// (Qdrant, LanceDB, pgvector) belong behind crate feature flags so
688    /// the heavy client/transitive deps don't land in every consumer.
689    /// Until one of those features is enabled, this returns a clear
690    /// error rather than silently falling back to ephemeral memory —
691    /// the silent fallback was exactly what the audit (#669) flagged.
692    pub fn create_vector_db(config: HashMap<String, String>) -> Result<Box<dyn DocumentStorage>> {
693        if config.is_empty() {
694            return Err(crate::Error::generic("Vector database configuration cannot be empty"));
695        }
696
697        let provider = config.get("provider").map(|s| s.as_str()).unwrap_or("<unspecified>");
698
699        #[cfg(feature = "qdrant")]
700        if provider == "qdrant" {
701            return Ok(Box::new(qdrant::QdrantStorage::new(&config)?));
702        }
703
704        Err(crate::Error::generic(format!(
705            "vector-db backend '{provider}' not compiled in. \
706             Enable the `qdrant` feature on mockforge-data for Qdrant \
707             (LanceDB is not implemented), \
708             or use `create_file()` for persistent local storage."
709        )))
710    }
711}
712
713/// File-backed `DocumentStorage` that snapshots an in-memory store to a
714/// JSON file on every write. Construction reads any prior snapshot back.
715///
716/// Implementation note: delegates all read paths to the wrapped
717/// `InMemoryStorage` so cosine-similarity search etc. stays identical.
718/// Only `store_chunks` / `delete_documents` / `clear_all` rewrite the
719/// snapshot — read-only ops stay zero-IO.
720pub struct PersistentFileStorage {
721    inner: InMemoryStorage,
722    snapshot_path: std::path::PathBuf,
723}
724
725#[derive(Debug, Serialize, Deserialize)]
726struct StorageSnapshot {
727    /// Schema version; bump if the on-disk shape changes.
728    version: u32,
729    chunks: HashMap<String, DocumentChunk>,
730    vectors: Vec<(String, Vec<f32>)>,
731}
732
733impl PersistentFileStorage {
734    /// Create a persistent file storage at `<dir>/storage.json`. Reads
735    /// any existing snapshot at construction time.
736    pub fn new(dir: std::path::PathBuf) -> Result<Self> {
737        std::fs::create_dir_all(&dir)?;
738        let snapshot_path = dir.join("storage.json");
739        let inner = InMemoryStorage::new_with_backend_type("file");
740
741        if snapshot_path.exists() {
742            let raw = std::fs::read_to_string(&snapshot_path).map_err(crate::Error::from)?;
743            let snapshot: StorageSnapshot = serde_json::from_str(&raw)
744                .map_err(|e| crate::Error::generic(format!("malformed snapshot: {e}")))?;
745
746            // We just created `inner` and haven't shared its Arcs yet, so
747            // `try_write` must succeed. Using `try_write` (not
748            // `blocking_write`) keeps construction safe to call from
749            // inside a tokio runtime — `blocking_write` panics from
750            // worker threads of the current runtime.
751            inner
752                .chunks
753                .try_write()
754                .map(|mut g| *g = snapshot.chunks)
755                .map_err(|_| crate::Error::generic("snapshot load: chunks lock contended"))?;
756            inner
757                .vectors
758                .try_write()
759                .map(|mut g| *g = snapshot.vectors)
760                .map_err(|_| crate::Error::generic("snapshot load: vectors lock contended"))?;
761            if let Ok(mut stats) = inner.stats.try_write() {
762                stats.last_updated = chrono::Utc::now();
763            }
764            tracing::info!(
765                path = %snapshot_path.display(),
766                "loaded RAG storage snapshot"
767            );
768        }
769
770        Ok(Self {
771            inner,
772            snapshot_path,
773        })
774    }
775
776    async fn persist(&self) -> Result<()> {
777        let snapshot = StorageSnapshot {
778            version: 1,
779            chunks: self.inner.chunks.read().await.clone(),
780            vectors: self.inner.vectors.read().await.clone(),
781        };
782        let json = serde_json::to_string_pretty(&snapshot)
783            .map_err(|e| crate::Error::generic(format!("serialise snapshot: {e}")))?;
784
785        // Atomic rename — a crash mid-write leaves the previous snapshot
786        // intact rather than truncating to a half-file.
787        let tmp = self.snapshot_path.with_extension("tmp");
788        std::fs::write(&tmp, json).map_err(crate::Error::from)?;
789        std::fs::rename(&tmp, &self.snapshot_path).map_err(crate::Error::from)?;
790        Ok(())
791    }
792}
793
794#[async_trait::async_trait]
795impl DocumentStorage for PersistentFileStorage {
796    async fn store_chunks(&self, chunks: Vec<DocumentChunk>) -> Result<()> {
797        self.inner.store_chunks(chunks).await?;
798        self.persist().await?;
799        Ok(())
800    }
801
802    async fn search_similar(
803        &self,
804        query_embedding: &[f32],
805        top_k: usize,
806    ) -> Result<Vec<DocumentChunk>> {
807        self.inner.search_similar(query_embedding, top_k).await
808    }
809
810    async fn search_with_params(
811        &self,
812        query_embedding: &[f32],
813        params: SearchParams,
814    ) -> Result<Vec<DocumentChunk>> {
815        self.inner.search_with_params(query_embedding, params).await
816    }
817
818    async fn get_chunk(&self, chunk_id: &str) -> Result<Option<DocumentChunk>> {
819        self.inner.get_chunk(chunk_id).await
820    }
821
822    async fn delete_chunk(&self, chunk_id: &str) -> Result<bool> {
823        let res = self.inner.delete_chunk(chunk_id).await?;
824        if res {
825            self.persist().await?;
826        }
827        Ok(res)
828    }
829
830    async fn get_chunks_by_document(&self, document_id: &str) -> Result<Vec<DocumentChunk>> {
831        self.inner.get_chunks_by_document(document_id).await
832    }
833
834    async fn delete_document(&self, document_id: &str) -> Result<usize> {
835        let n = self.inner.delete_document(document_id).await?;
836        if n > 0 {
837            self.persist().await?;
838        }
839        Ok(n)
840    }
841
842    async fn get_stats(&self) -> Result<StorageStats> {
843        let mut stats = self.inner.get_stats().await?;
844        // Override the backend label so health/admin surfaces show
845        // "file" rather than the inner store's "file" label (which we
846        // already set, but make it explicit).
847        stats.backend_type = "file".to_string();
848        Ok(stats)
849    }
850
851    async fn list_documents(&self) -> Result<Vec<String>> {
852        self.inner.list_documents().await
853    }
854
855    async fn get_total_chunks(&self) -> Result<usize> {
856        self.inner.get_total_chunks().await
857    }
858
859    async fn clear(&self) -> Result<()> {
860        self.inner.clear().await?;
861        self.persist().await?;
862        Ok(())
863    }
864
865    async fn optimize(&self) -> Result<()> {
866        self.inner.optimize().await
867    }
868
869    async fn create_backup(&self, path: &str) -> Result<()> {
870        self.inner.create_backup(path).await
871    }
872
873    async fn restore_backup(&self, path: &str) -> Result<()> {
874        self.inner.restore_backup(path).await?;
875        self.persist().await?;
876        Ok(())
877    }
878
879    async fn health_check(&self) -> Result<StorageHealth> {
880        self.inner.health_check().await
881    }
882}
883
884#[cfg(test)]
885mod tests {
886    use super::StorageFactory;
887    use std::collections::HashMap;
888
889    #[test]
890    fn test_module_compiles() {
891        // Basic compilation test
892    }
893
894    #[tokio::test]
895    async fn test_create_file_storage_fallback_backend_type() {
896        let dir =
897            std::env::temp_dir().join(format!("mockforge-data-storage-{}", std::process::id()));
898        let _ = std::fs::remove_dir_all(&dir);
899        let storage = StorageFactory::create_file(dir.to_str().expect("path")).expect("create");
900        let stats = storage.get_stats().await.expect("stats");
901        assert_eq!(stats.backend_type, "file");
902        let _ = std::fs::remove_dir_all(&dir);
903    }
904
905    #[tokio::test]
906    async fn test_create_database_storage_fallback_backend_type() {
907        let storage =
908            StorageFactory::create_database("postgres://user:pass@localhost/db").expect("create");
909        let stats = storage.get_stats().await.expect("stats");
910        assert_eq!(stats.backend_type, "database");
911    }
912
913    #[cfg(not(feature = "qdrant"))]
914    #[tokio::test]
915    async fn test_create_vector_storage_errors_without_real_backend() {
916        // After #669: vector-db requested without `qdrant`/`lancedb` feature
917        // is now an error rather than a silent in-memory fallback.
918        let mut cfg = HashMap::new();
919        cfg.insert("provider".to_string(), "qdrant".to_string());
920        let result = StorageFactory::create_vector_db(cfg);
921        assert!(result.is_err(), "expected error, got Ok");
922        let msg = result.err().unwrap().to_string();
923        assert!(
924            msg.contains("not compiled in") || msg.contains("qdrant"),
925            "expected helpful error mentioning compile-in or qdrant, got: {msg}"
926        );
927    }
928
929    #[tokio::test]
930    async fn test_persistent_file_storage_round_trips_across_restart() {
931        use crate::rag::engine::DocumentChunk;
932
933        let dir = std::env::temp_dir().join(format!(
934            "mockforge-rag-persist-{}-{}",
935            std::process::id(),
936            chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
937        ));
938        let _ = std::fs::remove_dir_all(&dir);
939
940        let path_str = dir.to_str().expect("path");
941
942        // First "process": write a chunk + embedding.
943        {
944            let storage = StorageFactory::create_file(path_str).expect("create");
945            let chunk = DocumentChunk {
946                id: "chunk-1".to_string(),
947                document_id: "doc-1".to_string(),
948                content: "hello rag".to_string(),
949                embedding: vec![0.1, 0.2, 0.3],
950                metadata: HashMap::new(),
951                position: 0,
952                length: 9,
953            };
954            storage.store_chunks(vec![chunk]).await.expect("store");
955        }
956
957        // Second "process": read the same path. Inner state should
958        // rehydrate from disk; the chunk should still be queryable.
959        {
960            let storage = StorageFactory::create_file(path_str).expect("reopen");
961            let stats = storage.get_stats().await.expect("stats");
962            assert_eq!(stats.backend_type, "file");
963            let chunk = storage.get_chunk("chunk-1").await.expect("query");
964            assert!(chunk.is_some(), "persisted chunk should survive restart");
965            let chunk = chunk.unwrap();
966            assert_eq!(chunk.content, "hello rag");
967            assert_eq!(chunk.embedding, vec![0.1, 0.2, 0.3]);
968        }
969
970        let _ = std::fs::remove_dir_all(&dir);
971    }
972
973    #[tokio::test]
974    async fn test_persistent_file_storage_search_survives_restart() {
975        use crate::rag::engine::DocumentChunk;
976
977        let dir = std::env::temp_dir().join(format!(
978            "mockforge-rag-search-{}-{}",
979            std::process::id(),
980            chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
981        ));
982        let _ = std::fs::remove_dir_all(&dir);
983        let path_str = dir.to_str().expect("path");
984
985        {
986            let storage = StorageFactory::create_file(path_str).expect("create");
987            let chunks = vec![
988                DocumentChunk {
989                    id: "a".to_string(),
990                    document_id: "doc".to_string(),
991                    content: "apple".to_string(),
992                    embedding: vec![1.0, 0.0, 0.0],
993                    metadata: HashMap::new(),
994                    position: 0,
995                    length: 5,
996                },
997                DocumentChunk {
998                    id: "b".to_string(),
999                    document_id: "doc".to_string(),
1000                    content: "banana".to_string(),
1001                    embedding: vec![0.0, 1.0, 0.0],
1002                    metadata: HashMap::new(),
1003                    position: 5,
1004                    length: 6,
1005                },
1006            ];
1007            storage.store_chunks(chunks).await.expect("store");
1008        }
1009
1010        // Reopen — cosine-similarity search should still return
1011        // the right chunk for a vector pointing at it.
1012        let storage = StorageFactory::create_file(path_str).expect("reopen");
1013        let hits = storage.search_similar(&[1.0, 0.0, 0.0], 1).await.expect("search");
1014        assert_eq!(hits.len(), 1);
1015        assert_eq!(hits[0].id, "a");
1016
1017        let _ = std::fs::remove_dir_all(&dir);
1018    }
1019}