1use 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#[cfg(feature = "qdrant")]
17pub mod qdrant;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct VectorIndex {
22 pub id: String,
24 pub name: String,
26 pub index_type: IndexType,
28 pub dimensions: usize,
30 pub vector_count: usize,
32 pub metadata: HashMap<String, String>,
34 pub created_at: chrono::DateTime<chrono::Utc>,
36 pub updated_at: chrono::DateTime<chrono::Utc>,
38}
39
40impl VectorIndex {
41 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 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 pub fn get_metadata(&self, key: &str) -> Option<&String> {
64 self.metadata.get(key)
65 }
66
67 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 pub fn update_vector_count(&mut self, count: usize) {
78 self.vector_count = count;
79 self.updated_at = chrono::Utc::now();
80 }
81
82 pub fn estimated_size_bytes(&self) -> u64 {
84 (self.vector_count * self.dimensions * 4 + 1024) as u64
86 }
87
88 pub fn is_empty(&self) -> bool {
90 self.vector_count == 0
91 }
92
93 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#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct IndexStats {
112 pub id: String,
114 pub name: String,
116 pub index_type: IndexType,
118 pub dimensions: usize,
120 pub vector_count: usize,
122 pub estimated_size_bytes: u64,
124 pub metadata_count: usize,
126 pub created_at: chrono::DateTime<chrono::Utc>,
128 pub updated_at: chrono::DateTime<chrono::Utc>,
130}
131
132#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
134pub enum IndexType {
135 #[default]
137 Flat,
138 IVF,
140 HNSW,
142 PQ,
144 Custom(String),
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct SearchParams {
151 pub top_k: usize,
153 pub threshold: f32,
155 pub search_method: SearchMethod,
157 pub include_metadata: bool,
159 pub document_filter: Option<String>,
161 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
180pub enum SearchMethod {
181 #[default]
183 Cosine,
184 Euclidean,
186 DotProduct,
188 Manhattan,
190}
191
192#[async_trait::async_trait]
194pub trait DocumentStorage: Send + Sync {
195 async fn store_chunks(&self, chunks: Vec<DocumentChunk>) -> Result<()>;
197
198 async fn search_similar(
200 &self,
201 query_embedding: &[f32],
202 top_k: usize,
203 ) -> Result<Vec<DocumentChunk>>;
204
205 async fn search_with_params(
207 &self,
208 query_embedding: &[f32],
209 params: SearchParams,
210 ) -> Result<Vec<DocumentChunk>>;
211
212 async fn get_chunk(&self, chunk_id: &str) -> Result<Option<DocumentChunk>>;
214
215 async fn delete_chunk(&self, chunk_id: &str) -> Result<bool>;
217
218 async fn get_chunks_by_document(&self, document_id: &str) -> Result<Vec<DocumentChunk>>;
220
221 async fn delete_document(&self, document_id: &str) -> Result<usize>;
223
224 async fn get_stats(&self) -> Result<StorageStats>;
226
227 async fn list_documents(&self) -> Result<Vec<String>>;
229
230 async fn get_total_chunks(&self) -> Result<usize>;
232
233 async fn clear(&self) -> Result<()>;
235
236 async fn optimize(&self) -> Result<()>;
238
239 async fn create_backup(&self, path: &str) -> Result<()>;
241
242 async fn restore_backup(&self, path: &str) -> Result<()>;
244
245 async fn health_check(&self) -> Result<StorageHealth>;
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct StorageStats {
252 pub total_documents: usize,
254 pub total_chunks: usize,
256 pub index_size_bytes: u64,
258 pub last_updated: chrono::DateTime<chrono::Utc>,
260 pub backend_type: String,
262 pub available_space_bytes: u64,
264 pub used_space_bytes: u64,
266}
267
268#[derive(Debug, Clone, Serialize, Deserialize)]
270pub struct StorageHealth {
271 pub status: HealthStatus,
273 pub checked_at: chrono::DateTime<chrono::Utc>,
275 pub details: HashMap<String, String>,
277 pub metrics: Option<StorageMetrics>,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
283pub enum HealthStatus {
284 Healthy,
286 Warning,
288 Unhealthy,
290 Unavailable,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct StorageMetrics {
297 pub average_search_time_ms: f64,
299 pub average_insert_time_ms: f64,
301 pub fragmentation_ratio: f32,
303 pub cache_hit_rate: f32,
305 pub memory_usage_bytes: u64,
307 pub disk_usage_bytes: u64,
309}
310
311pub struct InMemoryStorage {
313 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 pub fn new() -> Self {
323 Self::new_with_backend_type("memory")
324 }
325
326 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 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 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; 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 similarities.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
410
411 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?; if let Some(document_filter) = ¶ms.document_filter {
431 results.retain(|chunk| chunk.document_id == *document_filter);
432 }
433
434 if let Some(metadata_filter) = ¶ms.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 results.retain(|chunk| {
444 let similarity = self.cosine_similarity(query_embedding, &chunk.embedding);
445 similarity >= params.threshold
446 });
447
448 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 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 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 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 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 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 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 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
640pub struct StorageFactory;
642
643impl StorageFactory {
644 pub fn create_memory() -> Box<dyn DocumentStorage> {
647 Box::new(InMemoryStorage::new())
648 }
649
650 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 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 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
713pub struct PersistentFileStorage {
721 inner: InMemoryStorage,
722 snapshot_path: std::path::PathBuf,
723}
724
725#[derive(Debug, Serialize, Deserialize)]
726struct StorageSnapshot {
727 version: u32,
729 chunks: HashMap<String, DocumentChunk>,
730 vectors: Vec<(String, Vec<f32>)>,
731}
732
733impl PersistentFileStorage {
734 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 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 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 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 }
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 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 {
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 {
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 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}