1use std::sync::Mutex;
11
12use async_trait::async_trait;
13
14use super::{EmbeddingProvider, MemoryEntry, VectorStore};
15use crate::error::Result;
16
17pub struct NoopEmbedding;
26
27#[async_trait]
28impl EmbeddingProvider for NoopEmbedding {
29 async fn embed(&self, _text: &str) -> Vec<f32> {
30 vec![]
31 }
32}
33
34pub struct NoopVectorStore {
45 entries: Mutex<Vec<MemoryEntry>>,
46}
47
48impl NoopVectorStore {
49 pub fn new() -> Self {
50 Self {
51 entries: Mutex::new(Vec::new()),
52 }
53 }
54}
55
56impl Default for NoopVectorStore {
57 fn default() -> Self {
58 Self::new()
59 }
60}
61
62#[async_trait]
63impl VectorStore for NoopVectorStore {
64 async fn upsert(&self, entry: &MemoryEntry, _vector: Vec<f32>) -> Result<()> {
65 #[allow(clippy::unwrap_used, reason = "mutex poison is unrecoverable")]
66 let mut entries = self.entries.lock().unwrap();
67 if let Some(existing) = entries.iter_mut().find(|e| e.id == entry.id) {
68 *existing = entry.clone();
69 } else {
70 entries.push(entry.clone());
71 }
72 Ok(())
73 }
74
75 async fn search(
76 &self,
77 _query_vec: Vec<f32>,
78 query_text: &str,
79 limit: usize,
80 ) -> Result<Vec<MemoryEntry>> {
81 #[allow(clippy::unwrap_used, reason = "mutex poison is unrecoverable")]
82 let entries = self.entries.lock().unwrap();
83 let q = query_text.to_lowercase();
84 let matches: Vec<MemoryEntry> = entries
85 .iter()
86 .filter(|e| {
87 e.text.to_lowercase().contains(&q)
88 || e.tags.iter().any(|t| t.to_lowercase().contains(&q))
89 })
90 .take(limit)
91 .cloned()
92 .collect();
93 Ok(matches)
94 }
95
96 async fn remove(&self, id: &str) -> Result<()> {
97 #[allow(clippy::unwrap_used, reason = "mutex poison is unrecoverable")]
98 let mut entries = self.entries.lock().unwrap();
99 entries.retain(|e| e.id != id);
100 Ok(())
101 }
102
103 async fn list_all(&self) -> Result<Vec<MemoryEntry>> {
104 #[allow(clippy::unwrap_used, reason = "mutex poison is unrecoverable")]
105 let entries = self.entries.lock().unwrap();
106 Ok(entries.clone())
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[tokio::test]
115 async fn noop_embedding_returns_empty() {
116 let emb = NoopEmbedding;
117 assert!(emb.embed("hello world").await.is_empty());
118 }
119
120 #[tokio::test]
121 async fn noop_store_upsert_and_search() {
122 let store = NoopVectorStore::new();
123
124 let entry = MemoryEntry {
125 id: "N1".into(),
126 text: "Rust is a systems language".into(),
127 tags: vec!["rust".into()],
128 ts: "2026-01-01T00:00:00Z".into(),
129 };
130 store.upsert(&entry, vec![]).await.unwrap();
131
132 let results = store.search(vec![], "systems", 5).await.unwrap();
133 assert_eq!(results.len(), 1);
134 assert_eq!(results[0].id, "N1");
135
136 let no_results = store.search(vec![], "python", 5).await.unwrap();
137 assert!(no_results.is_empty());
138 }
139
140 #[tokio::test]
141 async fn noop_store_overwrite_on_same_id() {
142 let store = NoopVectorStore::new();
143
144 let e1 = MemoryEntry {
145 id: "N1".into(),
146 text: "original".into(),
147 tags: vec![],
148 ts: "2026-01-01T00:00:00Z".into(),
149 };
150 let e2 = MemoryEntry {
151 id: "N1".into(),
152 text: "updated".into(),
153 tags: vec![],
154 ts: "2026-01-02T00:00:00Z".into(),
155 };
156 store.upsert(&e1, vec![]).await.unwrap();
157 store.upsert(&e2, vec![]).await.unwrap();
158
159 let all = store.list_all().await.unwrap();
160 assert_eq!(all.len(), 1);
161 assert_eq!(all[0].text, "updated");
162 }
163
164 #[tokio::test]
165 async fn noop_store_remove_by_id() {
166 let store = NoopVectorStore::new();
167 let e = MemoryEntry {
168 id: "N1".into(),
169 text: "to be deleted".into(),
170 tags: vec![],
171 ts: "2026-01-01T00:00:00Z".into(),
172 };
173 store.upsert(&e, vec![]).await.unwrap();
174 store.remove("N1").await.unwrap();
175 assert!(store.list_all().await.unwrap().is_empty());
176 }
177
178 #[tokio::test]
179 async fn noop_store_search_matches_by_tag() {
180 let store = NoopVectorStore::new();
182 let e = MemoryEntry {
183 id: "T1".into(),
184 text: "irrelevant text".into(),
185 tags: vec!["RustLang".into()],
186 ts: "2026-01-01T00:00:00Z".into(),
187 };
188 store.upsert(&e, vec![]).await.unwrap();
189 let results = store.search(vec![], "rustlang", 5).await.unwrap();
191 assert_eq!(results.len(), 1, "tag match must be returned");
192 assert_eq!(results[0].id, "T1");
193 }
194
195 #[tokio::test]
196 async fn noop_store_search_respects_limit() {
197 let store = NoopVectorStore::new();
199 for i in 0..5 {
200 let e = MemoryEntry {
201 id: format!("E{i}"),
202 text: "common keyword".into(),
203 tags: vec![],
204 ts: "2026-01-01T00:00:00Z".into(),
205 };
206 store.upsert(&e, vec![]).await.unwrap();
207 }
208 let results = store.search(vec![], "common", 3).await.unwrap();
209 assert_eq!(results.len(), 3, "search must be capped at the limit");
210 }
211
212 #[tokio::test]
213 async fn noop_store_list_all_returns_all_entries() {
214 let store = NoopVectorStore::new();
216 for i in 0..3 {
217 let e = MemoryEntry {
218 id: format!("L{i}"),
219 text: format!("entry {i}"),
220 tags: vec![],
221 ts: "2026-01-01T00:00:00Z".into(),
222 };
223 store.upsert(&e, vec![]).await.unwrap();
224 }
225 let all = store.list_all().await.unwrap();
226 assert_eq!(all.len(), 3);
227 }
228}