Skip to main content

rullst_orm/scout/
mock.rs

1use std::collections::BTreeMap;
2use std::sync::{Arc, RwLock};
3
4use async_trait::async_trait;
5use serde_json::Value;
6
7use super::{SearchEngine, document_with_id, validate_index, validate_query};
8use crate::Error;
9
10type Documents = BTreeMap<String, BTreeMap<i32, Value>>;
11
12/// Deterministic in-memory Scout backend for tests and offline development.
13#[derive(Clone, Default)]
14pub struct MockSearchEngine {
15    documents: Arc<RwLock<Documents>>,
16}
17
18impl MockSearchEngine {
19    /// Creates an empty deterministic search store.
20    pub fn new() -> Self {
21        Self::default()
22    }
23
24    fn read_documents(&self) -> Result<std::sync::RwLockReadGuard<'_, Documents>, Error> {
25        self.documents
26            .read()
27            .map_err(|_| Error::Internal("Scout mock read lock is poisoned".to_string()))
28    }
29
30    fn write_documents(&self) -> Result<std::sync::RwLockWriteGuard<'_, Documents>, Error> {
31        self.documents
32            .write()
33            .map_err(|_| Error::Internal("Scout mock write lock is poisoned".to_string()))
34    }
35}
36
37#[async_trait]
38impl SearchEngine for MockSearchEngine {
39    async fn update(&self, table: &str, id: i32, payload: Value) -> Result<(), Error> {
40        validate_index(table)?;
41        let payload = document_with_id(id, payload)?;
42        self.write_documents()?
43            .entry(table.to_string())
44            .or_default()
45            .insert(id, payload);
46        Ok(())
47    }
48
49    async fn delete(&self, table: &str, id: i32) -> Result<(), Error> {
50        validate_index(table)?;
51        if id <= 0 {
52            return Err(Error::Validation(
53                "Scout document id must be positive".to_string(),
54            ));
55        }
56        if let Some(index) = self.write_documents()?.get_mut(table) {
57            index.remove(&id);
58        }
59        Ok(())
60    }
61
62    async fn search(&self, table: &str, query: &str) -> Result<Vec<i32>, Error> {
63        validate_index(table)?;
64        validate_query(query)?;
65        let normalized = query.to_lowercase();
66        let documents = self.read_documents()?;
67        let Some(index) = documents.get(table) else {
68            return Ok(Vec::new());
69        };
70        let mut ids = Vec::new();
71        for (id, payload) in index {
72            if normalized.is_empty() || payload.to_string().to_lowercase().contains(&normalized) {
73                ids.push(*id);
74            }
75        }
76        Ok(ids)
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[tokio::test]
85    async fn mock_search_is_deterministic_and_bounded() {
86        let engine = MockSearchEngine::new();
87        engine
88            .update("articles", 2, serde_json::json!({"title": "Rust"}))
89            .await
90            .expect("insert second document");
91        engine
92            .update("articles", 1, serde_json::json!({"title": "Rullst"}))
93            .await
94            .expect("insert first document");
95        assert_eq!(
96            engine.search("articles", "rust").await.expect("search"),
97            vec![2]
98        );
99        assert_eq!(
100            engine.search("articles", "").await.expect("list all"),
101            vec![1, 2]
102        );
103        engine.delete("articles", 1).await.expect("delete document");
104        assert_eq!(
105            engine.search("articles", "").await.expect("list remaining"),
106            vec![2]
107        );
108        assert!(
109            engine
110                .update("Bad Index", 3, serde_json::json!({}))
111                .await
112                .is_err()
113        );
114        assert!(
115            engine
116                .update("articles", 3, serde_json::json!({"id": 4}))
117                .await
118                .is_err()
119        );
120        assert!(engine.search("articles", "\n").await.is_err());
121    }
122}