1use std::path::Path;
2use std::sync::Arc;
3
4pub(crate) mod fts_index;
6pub mod turso_searcher;
8pub mod turso_storage;
10
11pub use turso_searcher::TursoSearcher;
12pub use turso_storage::TursoStorage;
13
14pub async fn open_storage(
20 db_path: &Path,
21 max_entries: usize,
22) -> crate::Result<(TursoStorage, TursoSearcher)> {
23 let storage = TursoStorage::open(db_path, max_entries).await?;
24 let db = Arc::clone(&storage.db);
25 let fts = Arc::clone(&storage.fts);
26 let searcher = TursoSearcher::new(db, fts);
27 Ok((storage, searcher))
28}
29
30#[cfg(test)]
31mod tests {
32 use std::path::Path;
33
34 use crate::engine::MATCH_ALL_QUERY;
35 use crate::entry::{kind, ContextEntry};
36 use crate::storage::open_storage;
37 use crate::traits::{ContextStorage, Searcher};
38
39 fn make_entry(id: &str, content: &str, timestamp: i64, kind: &str) -> ContextEntry {
40 ContextEntry {
41 id: id.into(),
42 content: content.into(),
43 timestamp,
44 kind: kind.to_owned(),
45 scope: None,
46 session_id: None,
47 token_count: None,
48 metadata: None,
49 }
50 }
51
52 fn make_scoped_entry(id: &str, content: &str, timestamp: i64, scope: &str) -> ContextEntry {
53 ContextEntry {
54 id: id.into(),
55 content: content.into(),
56 timestamp,
57 kind: kind::MANUAL.to_owned(),
58 scope: Some(scope.to_owned()),
59 session_id: None,
60 token_count: None,
61 metadata: None,
62 }
63 }
64
65 #[tokio::test]
66 async fn test_save_and_count() {
67 let (storage, _) = open_storage(Path::new(":memory:"), 100).await.unwrap();
68 let entry = make_entry("e1", "hello world", 1000, kind::MANUAL);
69 storage.save(&entry).await.unwrap();
70 assert_eq!(storage.count().await.unwrap(), 1);
71 }
72
73 #[tokio::test]
74 async fn test_save_and_get_top_k() {
75 let (storage, _) = open_storage(Path::new(":memory:"), 100).await.unwrap();
76 storage
77 .save(&make_entry("e1", "first", 100, kind::MANUAL))
78 .await
79 .unwrap();
80 storage
81 .save(&make_entry("e2", "second", 200, kind::SNAPSHOT))
82 .await
83 .unwrap();
84 storage
85 .save(&make_entry("e3", "third", 300, kind::SUMMARY))
86 .await
87 .unwrap();
88
89 let top2 = storage.get_top_k(2).await.unwrap();
90 assert_eq!(top2.len(), 2);
91 assert_eq!(top2[0].id, "e3"); assert_eq!(top2[1].id, "e2");
93 }
94
95 #[tokio::test]
96 async fn test_save_and_get_all() {
97 let (storage, _) = open_storage(Path::new(":memory:"), 100).await.unwrap();
98 storage
99 .save(&make_entry("e1", "first", 100, kind::MANUAL))
100 .await
101 .unwrap();
102 storage
103 .save(&make_entry("e2", "second", 200, kind::MANUAL))
104 .await
105 .unwrap();
106 storage
107 .save(&make_entry("e3", "third", 300, kind::MANUAL))
108 .await
109 .unwrap();
110
111 let all = storage.get_all().await.unwrap();
112 assert_eq!(all.len(), 3);
113 assert_eq!(all[0].id, "e3");
114 assert_eq!(all[1].id, "e2");
115 assert_eq!(all[2].id, "e1");
116 }
117
118 #[tokio::test]
119 async fn test_delete() {
120 let (storage, _) = open_storage(Path::new(":memory:"), 100).await.unwrap();
121 storage
122 .save(&make_entry("e1", "hello", 1000, kind::MANUAL))
123 .await
124 .unwrap();
125
126 assert!(storage.delete("e1").await.unwrap());
127 assert!(!storage.delete("nonexistent").await.unwrap());
128 assert_eq!(storage.count().await.unwrap(), 0);
129 }
130
131 #[tokio::test]
132 async fn test_clear() {
133 let (storage, _) = open_storage(Path::new(":memory:"), 100).await.unwrap();
134 storage
135 .save(&make_entry("e1", "a", 100, kind::MANUAL))
136 .await
137 .unwrap();
138 storage
139 .save(&make_entry("e2", "b", 200, kind::MANUAL))
140 .await
141 .unwrap();
142 storage
143 .save(&make_entry("e3", "c", 300, kind::MANUAL))
144 .await
145 .unwrap();
146
147 let cleared = storage.clear().await.unwrap();
148 assert_eq!(cleared, 3);
149 assert_eq!(storage.count().await.unwrap(), 0);
150 }
151
152 #[tokio::test]
153 async fn test_clear_scope() {
154 let (storage, _) = open_storage(Path::new(":memory:"), 100).await.unwrap();
155 storage
156 .save(&make_scoped_entry("e1", "a", 100, "scope-a"))
157 .await
158 .unwrap();
159 storage
160 .save(&make_scoped_entry("e2", "b", 200, "scope-a"))
161 .await
162 .unwrap();
163 storage
164 .save(&make_scoped_entry("e3", "c", 300, "scope-b"))
165 .await
166 .unwrap();
167
168 let cleared = storage.clear_scope("scope-a").await.unwrap();
169 assert_eq!(cleared, 2);
170 assert_eq!(storage.count().await.unwrap(), 1);
171
172 let all = storage.get_all().await.unwrap();
173 assert_eq!(all[0].id, "e3");
174 }
175
176 #[tokio::test]
177 async fn test_clear_scope_no_match() {
178 let (storage, _) = open_storage(Path::new(":memory:"), 100).await.unwrap();
179 storage
180 .save(&make_scoped_entry("e1", "a", 100, "scope-a"))
181 .await
182 .unwrap();
183
184 let cleared = storage.clear_scope("scope-z").await.unwrap();
185 assert_eq!(cleared, 0);
186 assert_eq!(storage.count().await.unwrap(), 1);
187 }
188
189 #[tokio::test]
190 async fn test_lru_eviction() {
191 let (storage, _) = open_storage(Path::new(":memory:"), 2).await.unwrap();
192 storage
193 .save(&make_entry("e1", "oldest", 100, kind::MANUAL))
194 .await
195 .unwrap();
196 storage
197 .save(&make_entry("e2", "middle", 200, kind::MANUAL))
198 .await
199 .unwrap();
200 storage
201 .save(&make_entry("e3", "newest", 300, kind::MANUAL))
202 .await
203 .unwrap();
204
205 assert_eq!(storage.count().await.unwrap(), 2);
206
207 let all = storage.get_all().await.unwrap();
208 let ids: Vec<&str> = all.iter().map(|e| e.id.as_str()).collect();
209 assert!(
210 !ids.contains(&"e1"),
211 "oldest entry should have been evicted"
212 );
213 assert!(ids.contains(&"e2"));
214 assert!(ids.contains(&"e3"));
215 }
216
217 #[tokio::test]
218 async fn test_fts_search() {
219 let (storage, searcher) = open_storage(Path::new(":memory:"), 100).await.unwrap();
220 storage
221 .save(&make_entry(
222 "e1",
223 "rust programming language",
224 100,
225 kind::MANUAL,
226 ))
227 .await
228 .unwrap();
229 storage
230 .save(&make_entry("e2", "python scripting", 200, kind::MANUAL))
231 .await
232 .unwrap();
233 storage
234 .save(&make_entry("e3", "rust borrow checker", 300, kind::MANUAL))
235 .await
236 .unwrap();
237
238 let results = searcher.search("rust", None, 5).await.unwrap();
239 assert_eq!(results.len(), 2);
240 assert!(
241 results[0].score >= results[1].score,
242 "results should be ordered by descending score"
243 );
244 }
245
246 #[tokio::test]
247 async fn test_fts_search_no_results() {
248 let (storage, searcher) = open_storage(Path::new(":memory:"), 100).await.unwrap();
249 storage
250 .save(&make_entry("e1", "hello world", 100, kind::MANUAL))
251 .await
252 .unwrap();
253
254 let results = searcher.search("nonexistent", None, 5).await.unwrap();
255 assert!(results.is_empty());
256 }
257
258 #[tokio::test]
259 async fn test_fts_search_scoped() {
260 let (storage, searcher) = open_storage(Path::new(":memory:"), 100).await.unwrap();
261 storage
262 .save(&make_scoped_entry("e1", "rust programming", 100, "a"))
263 .await
264 .unwrap();
265 storage
266 .save(&make_scoped_entry("e2", "rust borrow checker", 200, "b"))
267 .await
268 .unwrap();
269
270 let results_a = searcher.search("rust", Some("a"), 5).await.unwrap();
271 assert_eq!(results_a.len(), 1);
272 assert_eq!(results_a[0].entry.id, "e1");
273
274 let results_b = searcher.search("rust", Some("b"), 5).await.unwrap();
275 assert_eq!(results_b.len(), 1);
276 assert_eq!(results_b[0].entry.id, "e2");
277
278 let results_all = searcher.search("rust", None, 5).await.unwrap();
279 assert_eq!(results_all.len(), 2);
280 }
281
282 #[tokio::test]
283 async fn test_new_entry_with_scope_and_metadata() {
284 let (storage, _) = open_storage(Path::new(":memory:"), 100).await.unwrap();
285
286 let metadata = serde_json::json!({"runtime": "codex", "model": "gpt-5.3-codex"});
287 let entry = ContextEntry {
288 id: "v3-entry".into(),
289 content: "entry with scope and metadata".into(),
290 timestamp: 1_700_000_100,
291 kind: kind::SUMMARY.to_owned(),
292 scope: Some("project:homelab-rs".into()),
293 session_id: Some("session-123".into()),
294 token_count: Some(6),
295 metadata: Some(metadata.clone()),
296 };
297
298 storage.save(&entry).await.unwrap();
299
300 let all = storage.get_all().await.unwrap();
301 assert_eq!(all.len(), 1);
302 let got = &all[0];
303 assert_eq!(got.id, entry.id);
304 assert_eq!(got.content, entry.content);
305 assert_eq!(got.timestamp, entry.timestamp);
306 assert_eq!(got.kind, entry.kind);
307 assert_eq!(got.scope, entry.scope);
308 assert_eq!(got.session_id, entry.session_id);
309 assert_eq!(got.token_count, entry.token_count);
310 assert_eq!(got.metadata, Some(metadata));
311 }
312
313 #[tokio::test]
314 async fn test_insert_or_replace() {
315 let (storage, _) = open_storage(Path::new(":memory:"), 100).await.unwrap();
316 storage
317 .save(&make_entry("e1", "original content", 100, kind::MANUAL))
318 .await
319 .unwrap();
320 storage
321 .save(&make_entry("e1", "updated content", 200, kind::SUMMARY))
322 .await
323 .unwrap();
324
325 assert_eq!(storage.count().await.unwrap(), 1);
326
327 let all = storage.get_all().await.unwrap();
328 assert_eq!(all[0].content, "updated content");
329 }
330
331 #[tokio::test]
332 async fn test_search_match_all_query() {
333 let (storage, searcher) = open_storage(Path::new(":memory:"), 100).await.unwrap();
334 storage
335 .save(&make_entry("e1", "first entry", 100, kind::MANUAL))
336 .await
337 .unwrap();
338 storage
339 .save(&make_entry("e2", "second entry", 200, kind::SNAPSHOT))
340 .await
341 .unwrap();
342 storage
343 .save(&make_entry("e3", "third entry", 300, kind::SUMMARY))
344 .await
345 .unwrap();
346
347 let results = searcher.search(MATCH_ALL_QUERY, None, 10).await.unwrap();
348 assert_eq!(results.len(), 3);
349
350 assert_eq!(results[0].entry.id, "e3");
352 assert_eq!(results[1].entry.id, "e2");
353 assert_eq!(results[2].entry.id, "e1");
354
355 for r in &results {
357 assert!((r.score - 1.0).abs() < f64::EPSILON);
358 }
359 }
360
361 #[tokio::test]
362 async fn test_search_match_all_query_scoped() {
363 let (storage, searcher) = open_storage(Path::new(":memory:"), 100).await.unwrap();
364 storage
365 .save(&make_scoped_entry("e1", "first entry", 100, "a"))
366 .await
367 .unwrap();
368 storage
369 .save(&make_scoped_entry("e2", "second entry", 200, "b"))
370 .await
371 .unwrap();
372
373 let results_a = searcher
374 .search(MATCH_ALL_QUERY, Some("a"), 10)
375 .await
376 .unwrap();
377 assert_eq!(results_a.len(), 1);
378 assert_eq!(results_a[0].entry.id, "e1");
379
380 let results_all = searcher.search(MATCH_ALL_QUERY, None, 10).await.unwrap();
381 assert_eq!(results_all.len(), 2);
382 }
383
384 #[tokio::test]
385 async fn search_with_fts5_operator_characters_does_not_error() {
386 let (storage, searcher) = open_storage(Path::new(":memory:"), 100).await.unwrap();
387 storage
388 .save(&make_entry("e1", "marco polo", 100, kind::MANUAL))
389 .await
390 .unwrap();
391
392 let results = searcher
395 .search(r#"if I say "marco"."#, None, 10)
396 .await
397 .unwrap();
398
399 assert!(
400 results.iter().any(|r| r.entry.id == "e1"),
401 "expected 'marco polo' entry to be found despite FTS5 operator characters in the query"
402 );
403 }
404
405 #[tokio::test]
406 async fn test_fts_search_scores_are_nonzero() {
407 let (storage, searcher) = open_storage(Path::new(":memory:"), 100).await.unwrap();
408 storage
409 .save(&make_entry(
410 "e1",
411 "rust programming language",
412 100,
413 kind::MANUAL,
414 ))
415 .await
416 .unwrap();
417 storage
418 .save(&make_entry(
419 "e2",
420 "python scripting tools",
421 200,
422 kind::MANUAL,
423 ))
424 .await
425 .unwrap();
426
427 let results = searcher.search("rust", None, 5).await.unwrap();
428 assert!(!results.is_empty());
429 assert!(
430 results[0].score > 0.0,
431 "expected non-zero BM25 score from tantivy, got {}",
432 results[0].score
433 );
434 }
435
436 #[tokio::test]
437 async fn search_or_joins_terms_for_message_length_queries() {
438 let (storage, searcher) = open_storage(Path::new(":memory:"), 100).await.unwrap();
439 storage
440 .save(&make_entry("e1", "alpha one", 100, kind::MANUAL))
441 .await
442 .unwrap();
443 storage
444 .save(&make_entry("e2", "beta two", 200, kind::MANUAL))
445 .await
446 .unwrap();
447
448 let results = searcher.search("alpha beta gamma", None, 10).await.unwrap();
451
452 let ids: Vec<&str> = results.iter().map(|r| r.entry.id.as_str()).collect();
453 assert!(ids.contains(&"e1"), "expected 'alpha one' entry in results");
454 assert!(ids.contains(&"e2"), "expected 'beta two' entry in results");
455 }
456}