1use std::path::Path;
2use std::sync::Arc;
3
4use r2d2::{CustomizeConnection, Pool};
5use r2d2_sqlite::SqliteConnectionManager;
6use rusqlite::TransactionBehavior;
7
8use crate::entry::ContextEntry;
9use crate::storage::schema::{migrate, row_to_entry};
10use crate::traits::ContextStorage;
11
12pub mod schema;
14pub mod searcher;
16
17pub use searcher::SqliteSearcher;
18
19pub fn open_storage(
26 db_path: &Path,
27 max_entries: usize,
28) -> crate::Result<(SqliteStorage, SqliteSearcher)> {
29 let storage = SqliteStorage::open(db_path, max_entries)?;
30 let searcher = SqliteSearcher::new(storage.pool());
31 Ok((storage, searcher))
32}
33
34#[derive(Debug)]
35struct PragmaCustomizer;
36
37impl CustomizeConnection<rusqlite::Connection, rusqlite::Error> for PragmaCustomizer {
38 fn on_acquire(
39 &self,
40 conn: &mut rusqlite::Connection,
41 ) -> std::result::Result<(), rusqlite::Error> {
42 conn.execute_batch(
48 "PRAGMA busy_timeout=5000; PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;",
49 )?;
50 Ok(())
51 }
52}
53
54pub struct SqliteStorage {
56 pool: Arc<Pool<SqliteConnectionManager>>,
57 max_entries: usize,
58}
59
60impl SqliteStorage {
61 pub fn open(db_path: &Path, max_entries: usize) -> crate::Result<Self> {
71 let manager = SqliteConnectionManager::file(db_path);
72 let mut builder = Pool::builder().connection_customizer(Box::new(PragmaCustomizer));
73
74 if db_path == Path::new(":memory:") {
77 builder = builder.max_size(1);
78 } else {
79 builder = builder.max_size(4);
80 }
81
82 let pool = builder.build(manager)?;
83
84 let conn = pool.get()?;
85 migrate(&conn)?;
86
87 Ok(Self {
88 pool: Arc::new(pool),
89 max_entries,
90 })
91 }
92
93 #[must_use]
96 pub fn pool(&self) -> Arc<Pool<SqliteConnectionManager>> {
97 Arc::clone(&self.pool)
98 }
99
100 pub fn schema_version(&self) -> crate::Result<i64> {
106 let conn = self.pool.get()?;
107 let version = conn.query_row(
108 "SELECT COALESCE(MAX(version), 0) FROM schema_version",
109 [],
110 |row| row.get(0),
111 )?;
112 Ok(version)
113 }
114
115 pub fn checkpoint(&self) -> crate::Result<()> {
123 let conn = self.pool.get()?;
124 conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?;
125 Ok(())
126 }
127}
128
129impl ContextStorage for SqliteStorage {
130 fn save(&self, entry: &ContextEntry) -> crate::Result<()> {
131 let mut conn = self.pool.get()?;
132
133 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
134
135 let exists: bool = tx.query_row(
138 "SELECT EXISTS(SELECT 1 FROM entries WHERE id = ?1)",
139 [&entry.id],
140 |r| r.get(0),
141 )?;
142
143 if !exists {
144 let current_count: i64 =
145 tx.query_row("SELECT COUNT(*) FROM entries", [], |r| r.get(0))?;
146
147 let current_count = usize::try_from(current_count).unwrap_or(usize::MAX);
148 if current_count >= self.max_entries {
149 tx.execute(
150 "DELETE FROM entries WHERE id = (\
151 SELECT id FROM entries ORDER BY timestamp ASC LIMIT 1)",
152 [],
153 )?;
154 }
155 }
156
157 let metadata_json = entry
158 .metadata
159 .as_ref()
160 .map(serde_json::to_string)
161 .transpose()
162 .map_err(|e| crate::Error::InvalidEntry(format!("metadata is not valid JSON: {e}")))?;
163
164 tx.execute(
165 "INSERT OR REPLACE INTO entries (
166 id,
167 content,
168 timestamp,
169 kind,
170 scope,
171 session_id,
172 token_count,
173 metadata
174 ) VALUES (
175 ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8
176 )",
177 rusqlite::params![
178 entry.id,
179 entry.content,
180 entry.timestamp,
181 entry.kind,
182 entry.scope,
183 entry.session_id,
184 entry
185 .token_count
186 .map(|v| i64::try_from(v).unwrap_or(i64::MAX)),
187 metadata_json,
188 ],
189 )?;
190
191 tx.commit()?;
192
193 Ok(())
194 }
195
196 fn get_top_k(&self, k: usize) -> crate::Result<Vec<ContextEntry>> {
197 let conn = self.pool.get()?;
198 let mut stmt = conn.prepare(
199 "SELECT
200 id,
201 content,
202 timestamp,
203 kind,
204 scope,
205 session_id,
206 token_count,
207 metadata
208 FROM entries
209 ORDER BY timestamp DESC
210 LIMIT ?1",
211 )?;
212
213 let entries = stmt
214 .query_map([i64::try_from(k).unwrap_or(i64::MAX)], row_to_entry)?
215 .collect::<std::result::Result<Vec<_>, _>>()?;
216
217 Ok(entries)
218 }
219
220 fn get_all(&self) -> crate::Result<Vec<ContextEntry>> {
221 let conn = self.pool.get()?;
222 let mut stmt = conn.prepare(
223 "SELECT
224 id,
225 content,
226 timestamp,
227 kind,
228 scope,
229 session_id,
230 token_count,
231 metadata
232 FROM entries
233 ORDER BY timestamp DESC",
234 )?;
235
236 let entries = stmt
237 .query_map([], row_to_entry)?
238 .collect::<std::result::Result<Vec<_>, _>>()?;
239
240 Ok(entries)
241 }
242
243 fn delete(&self, id: &str) -> crate::Result<bool> {
244 let conn = self.pool.get()?;
245 let changes = conn.execute("DELETE FROM entries WHERE id = ?1", [id])?;
246 Ok(changes > 0)
247 }
248
249 fn clear(&self) -> crate::Result<usize> {
250 let conn = self.pool.get()?;
251 let changes = conn.execute("DELETE FROM entries", [])?;
252 Ok(changes)
253 }
254
255 fn clear_scope(&self, scope: &str) -> crate::Result<usize> {
256 let conn = self.pool.get()?;
257 let changes = conn.execute("DELETE FROM entries WHERE scope = ?1", [scope])?;
258 Ok(changes)
259 }
260
261 fn count(&self) -> crate::Result<usize> {
262 let conn = self.pool.get()?;
263 let count: i64 = conn.query_row("SELECT COUNT(*) FROM entries", [], |r| r.get(0))?;
264 Ok(usize::try_from(count).unwrap_or(usize::MAX))
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use std::fs;
271 use std::path::Path;
272 use std::time::{SystemTime, UNIX_EPOCH};
273
274 use rusqlite::Connection;
275
276 use crate::engine::MATCH_ALL_QUERY;
277 use crate::entry::{kind, ContextEntry};
278 use crate::storage::{open_storage, SqliteStorage};
279 use crate::traits::{ContextStorage, Searcher};
280
281 fn temp_db_path(name: &str) -> std::path::PathBuf {
282 let nanos = SystemTime::now()
283 .duration_since(UNIX_EPOCH)
284 .expect("clock drift before unix epoch")
285 .as_nanos();
286 std::env::temp_dir().join(format!("cf-storage-{name}-{nanos}.db"))
287 }
288
289 fn make_entry(id: &str, content: &str, timestamp: i64, kind: &str) -> ContextEntry {
290 ContextEntry {
291 id: id.into(),
292 content: content.into(),
293 timestamp,
294 kind: kind.to_owned(),
295 scope: None,
296 session_id: None,
297 token_count: None,
298 metadata: None,
299 }
300 }
301
302 fn make_scoped_entry(id: &str, content: &str, timestamp: i64, scope: &str) -> ContextEntry {
303 ContextEntry {
304 id: id.into(),
305 content: content.into(),
306 timestamp,
307 kind: kind::MANUAL.to_owned(),
308 scope: Some(scope.to_owned()),
309 session_id: None,
310 token_count: None,
311 metadata: None,
312 }
313 }
314
315 #[test]
316 fn checkpoint_runs_without_error() {
317 let dir = tempfile::tempdir().unwrap();
318 let storage = SqliteStorage::open(dir.path().join("test.db").as_path(), 100).unwrap();
319 assert!(storage.checkpoint().is_ok());
320 }
321
322 #[test]
323 fn test_save_and_count() {
324 let (storage, _) = open_storage(Path::new(":memory:"), 100).unwrap();
325 let entry = make_entry("e1", "hello world", 1000, kind::MANUAL);
326 storage.save(&entry).unwrap();
327 assert_eq!(storage.count().unwrap(), 1);
328 }
329
330 #[test]
331 fn test_save_and_get_top_k() {
332 let (storage, _) = open_storage(Path::new(":memory:"), 100).unwrap();
333 storage
334 .save(&make_entry("e1", "first", 100, kind::MANUAL))
335 .unwrap();
336 storage
337 .save(&make_entry("e2", "second", 200, kind::SNAPSHOT))
338 .unwrap();
339 storage
340 .save(&make_entry("e3", "third", 300, kind::SUMMARY))
341 .unwrap();
342
343 let top2 = storage.get_top_k(2).unwrap();
344 assert_eq!(top2.len(), 2);
345 assert_eq!(top2[0].id, "e3"); assert_eq!(top2[1].id, "e2");
347 }
348
349 #[test]
350 fn test_save_and_get_all() {
351 let (storage, _) = open_storage(Path::new(":memory:"), 100).unwrap();
352 storage
353 .save(&make_entry("e1", "first", 100, kind::MANUAL))
354 .unwrap();
355 storage
356 .save(&make_entry("e2", "second", 200, kind::MANUAL))
357 .unwrap();
358 storage
359 .save(&make_entry("e3", "third", 300, kind::MANUAL))
360 .unwrap();
361
362 let all = storage.get_all().unwrap();
363 assert_eq!(all.len(), 3);
364 assert_eq!(all[0].id, "e3");
366 assert_eq!(all[1].id, "e2");
367 assert_eq!(all[2].id, "e1");
368 }
369
370 #[test]
371 fn test_delete() {
372 let (storage, _) = open_storage(Path::new(":memory:"), 100).unwrap();
373 storage
374 .save(&make_entry("e1", "hello", 1000, kind::MANUAL))
375 .unwrap();
376
377 assert!(storage.delete("e1").unwrap());
378 assert!(!storage.delete("nonexistent").unwrap());
379 assert_eq!(storage.count().unwrap(), 0);
380 }
381
382 #[test]
383 fn test_clear() {
384 let (storage, _) = open_storage(Path::new(":memory:"), 100).unwrap();
385 storage
386 .save(&make_entry("e1", "a", 100, kind::MANUAL))
387 .unwrap();
388 storage
389 .save(&make_entry("e2", "b", 200, kind::MANUAL))
390 .unwrap();
391 storage
392 .save(&make_entry("e3", "c", 300, kind::MANUAL))
393 .unwrap();
394
395 let cleared = storage.clear().unwrap();
396 assert_eq!(cleared, 3);
397 assert_eq!(storage.count().unwrap(), 0);
398 }
399
400 #[test]
401 fn test_clear_scope() {
402 let (storage, _) = open_storage(Path::new(":memory:"), 100).unwrap();
403 storage
404 .save(&make_scoped_entry("e1", "a", 100, "scope-a"))
405 .unwrap();
406 storage
407 .save(&make_scoped_entry("e2", "b", 200, "scope-a"))
408 .unwrap();
409 storage
410 .save(&make_scoped_entry("e3", "c", 300, "scope-b"))
411 .unwrap();
412
413 let cleared = storage.clear_scope("scope-a").unwrap();
414 assert_eq!(cleared, 2);
415 assert_eq!(storage.count().unwrap(), 1);
416
417 let all = storage.get_all().unwrap();
418 assert_eq!(all[0].id, "e3");
419 }
420
421 #[test]
422 fn test_clear_scope_no_match() {
423 let (storage, _) = open_storage(Path::new(":memory:"), 100).unwrap();
424 storage
425 .save(&make_scoped_entry("e1", "a", 100, "scope-a"))
426 .unwrap();
427
428 let cleared = storage.clear_scope("scope-z").unwrap();
429 assert_eq!(cleared, 0);
430 assert_eq!(storage.count().unwrap(), 1);
431 }
432
433 #[test]
434 fn test_lru_eviction() {
435 let (storage, _) = open_storage(Path::new(":memory:"), 2).unwrap();
436 storage
437 .save(&make_entry("e1", "oldest", 100, kind::MANUAL))
438 .unwrap();
439 storage
440 .save(&make_entry("e2", "middle", 200, kind::MANUAL))
441 .unwrap();
442 storage
443 .save(&make_entry("e3", "newest", 300, kind::MANUAL))
444 .unwrap();
445
446 assert_eq!(storage.count().unwrap(), 2);
447
448 let all = storage.get_all().unwrap();
449 let ids: Vec<&str> = all.iter().map(|e| e.id.as_str()).collect();
450 assert!(
451 !ids.contains(&"e1"),
452 "oldest entry should have been evicted"
453 );
454 assert!(ids.contains(&"e2"));
455 assert!(ids.contains(&"e3"));
456 }
457
458 #[test]
459 fn test_fts_search() {
460 let (storage, searcher) = open_storage(Path::new(":memory:"), 100).unwrap();
461 storage
462 .save(&make_entry(
463 "e1",
464 "rust programming language",
465 100,
466 kind::MANUAL,
467 ))
468 .unwrap();
469 storage
470 .save(&make_entry("e2", "python scripting", 200, kind::MANUAL))
471 .unwrap();
472 storage
473 .save(&make_entry("e3", "rust borrow checker", 300, kind::MANUAL))
474 .unwrap();
475
476 let results = searcher.search("rust", None, 5).unwrap();
477 assert_eq!(results.len(), 2);
478 assert!(
480 results[0].score >= results[1].score,
481 "results should be ordered by descending score"
482 );
483 }
484
485 #[test]
486 fn test_fts_search_no_results() {
487 let (storage, searcher) = open_storage(Path::new(":memory:"), 100).unwrap();
488 storage
489 .save(&make_entry("e1", "hello world", 100, kind::MANUAL))
490 .unwrap();
491
492 let results = searcher.search("nonexistent", None, 5).unwrap();
493 assert!(results.is_empty());
494 }
495
496 #[test]
497 fn test_fts_search_scoped() {
498 let (storage, searcher) = open_storage(Path::new(":memory:"), 100).unwrap();
499 storage
500 .save(&make_scoped_entry("e1", "rust programming", 100, "a"))
501 .unwrap();
502 storage
503 .save(&make_scoped_entry("e2", "rust borrow checker", 200, "b"))
504 .unwrap();
505
506 let results_a = searcher.search("rust", Some("a"), 5).unwrap();
507 assert_eq!(results_a.len(), 1);
508 assert_eq!(results_a[0].entry.id, "e1");
509
510 let results_b = searcher.search("rust", Some("b"), 5).unwrap();
511 assert_eq!(results_b.len(), 1);
512 assert_eq!(results_b[0].entry.id, "e2");
513
514 let results_all = searcher.search("rust", None, 5).unwrap();
515 assert_eq!(results_all.len(), 2);
516 }
517
518 #[test]
519 fn test_v2_migration_idempotent() {
520 let storage1 = SqliteStorage::open(Path::new(":memory:"), 100).unwrap();
521 let conn = storage1.pool().get().unwrap();
522 crate::storage::schema::migrate(&conn).unwrap();
524 }
525
526 #[test]
527 fn test_v1_to_v3_migration() {
528 let db_path = temp_db_path("v1-to-v3");
529
530 {
531 let conn = Connection::open(&db_path).unwrap();
532 conn.execute_batch(crate::storage::schema::SCHEMA_V1)
533 .unwrap();
534 conn.execute_batch(
535 "CREATE TABLE IF NOT EXISTS schema_version (id INTEGER PRIMARY KEY CHECK(id = 1), version INTEGER NOT NULL)",
536 )
537 .unwrap();
538 conn.execute(
539 "INSERT OR REPLACE INTO schema_version (id, version) VALUES (1, 1)",
540 [],
541 )
542 .unwrap();
543
544 conn.execute(
545 "INSERT INTO entries (id, content, timestamp, kind, token_count) VALUES (?1, ?2, ?3, ?4, ?5)",
546 rusqlite::params!["m1", "manual entry", 100_i64, "Manual", 2_i64],
547 )
548 .unwrap();
549 conn.execute(
550 "INSERT INTO entries (id, content, timestamp, kind, token_count) VALUES (?1, ?2, ?3, ?4, ?5)",
551 rusqlite::params!["p1", "precompact entry", 200_i64, "PreCompact", 3_i64],
552 )
553 .unwrap();
554 conn.execute(
555 "INSERT INTO entries (id, content, timestamp, kind, token_count) VALUES (?1, ?2, ?3, ?4, ?5)",
556 rusqlite::params!["a1", "auto entry", 300_i64, "Auto", 4_i64],
557 )
558 .unwrap();
559 }
560
561 let storage = SqliteStorage::open(&db_path, 100).unwrap();
562 let conn = storage.pool().get().unwrap();
563
564 let version: i64 = conn
565 .query_row(
566 "SELECT COALESCE(MAX(version), 0) FROM schema_version",
567 [],
568 |r| r.get(0),
569 )
570 .unwrap();
571 assert_eq!(version, 3);
572
573 let mut kinds: Vec<String> = conn
575 .prepare("SELECT kind FROM entries ORDER BY timestamp")
576 .unwrap()
577 .query_map([], |r| r.get(0))
578 .unwrap()
579 .collect::<rusqlite::Result<Vec<_>>>()
580 .unwrap();
581 kinds.sort();
582 assert_eq!(kinds, vec!["manual", "snapshot", "summary"]);
583
584 let tags: i64 = conn
585 .query_row("SELECT COUNT(*) FROM tags", [], |r| r.get(0))
586 .unwrap();
587 assert_eq!(tags, 0, "tags table should exist but be empty");
588
589 let entry_tags: i64 = conn
590 .query_row("SELECT COUNT(*) FROM entry_tags", [], |r| r.get(0))
591 .unwrap();
592 assert_eq!(entry_tags, 0, "entry_tags table should exist but be empty");
593
594 let runtime_configs_exists: i64 = conn
596 .query_row(
597 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='runtime_configs'",
598 [],
599 |r| r.get(0),
600 )
601 .unwrap();
602 assert_eq!(runtime_configs_exists, 0);
603
604 let _ = fs::remove_file(&db_path);
605 }
606
607 #[test]
608 fn test_v2_to_v3_migration() {
609 let db_path = temp_db_path("v2-to-v3");
610
611 {
612 let conn = Connection::open(&db_path).unwrap();
613
614 conn.execute_batch(crate::storage::schema::SCHEMA_V1)
617 .unwrap();
618 conn.execute_batch(
619 "CREATE TABLE IF NOT EXISTS schema_version (id INTEGER PRIMARY KEY CHECK(id = 1), version INTEGER NOT NULL)",
620 )
621 .unwrap();
622 conn.execute(
623 "INSERT OR REPLACE INTO schema_version (id, version) VALUES (1, 1)",
624 [],
625 )
626 .unwrap();
627 conn.execute_batch(crate::storage::schema::SCHEMA_V2)
628 .unwrap();
629
630 conn.execute(
631 "INSERT INTO entries (
632 id, content, timestamp, kind, token_count, session_id,
633 compaction_count, compaction_trigger, runtime, model, cwd,
634 git_branch, git_sha, turn_id, agent_type, agent_id
635 ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
636 rusqlite::params![
637 "a1",
638 "auto entry with runtime metadata",
639 300_i64,
640 "Auto",
641 4_i64,
642 "session-abc",
643 2_i64,
644 "matcher:threshold",
645 "codex",
646 "gpt-5.3-codex",
647 "/workspace/context-forge",
648 "feature/schema-v2",
649 "abc123def",
650 "turn-77",
651 "coder",
652 "agent-main",
653 ],
654 )
655 .unwrap();
656 conn.execute(
657 "INSERT INTO entries (id, content, timestamp, kind, token_count) VALUES (?1, ?2, ?3, ?4, ?5)",
658 rusqlite::params!["m1", "manual entry", 100_i64, "Manual", 2_i64],
659 )
660 .unwrap();
661 }
662
663 let storage = SqliteStorage::open(&db_path, 100).unwrap();
664
665 let all = storage.get_all().unwrap();
667 assert_eq!(all.len(), 2);
668
669 let auto_entry = all.iter().find(|e| e.id == "a1").unwrap();
671 let manual_entry = all.iter().find(|e| e.id == "m1").unwrap();
672 assert_eq!(auto_entry.kind, "summary");
673 assert_eq!(manual_entry.kind, "manual");
674
675 let metadata = auto_entry
677 .metadata
678 .as_ref()
679 .expect("metadata should be present for migrated v2 entry");
680 assert_eq!(metadata["runtime"], "codex");
681 assert_eq!(metadata["model"], "gpt-5.3-codex");
682 assert_eq!(metadata["cwd"], "/workspace/context-forge");
683 assert_eq!(metadata["git_branch"], "feature/schema-v2");
684 assert_eq!(metadata["git_sha"], "abc123def");
685 assert_eq!(metadata["compaction_trigger"], "matcher:threshold");
686 assert_eq!(metadata["turn_id"], "turn-77");
687 assert_eq!(metadata["agent_type"], "coder");
688 assert_eq!(metadata["agent_id"], "agent-main");
689
690 assert_eq!(auto_entry.session_id.as_deref(), Some("session-abc"));
692 assert_eq!(auto_entry.token_count, Some(4));
693
694 let (_, searcher) = open_storage(&db_path, 100).unwrap();
696 let results = searcher.search("runtime metadata", None, 10).unwrap();
697 assert!(
698 results.iter().any(|r| r.entry.id == "a1"),
699 "FTS search should still find the migrated entry's content"
700 );
701
702 let conn = storage.pool().get().unwrap();
704 let runtime_configs_exists: i64 = conn
705 .query_row(
706 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='runtime_configs'",
707 [],
708 |r| r.get(0),
709 )
710 .unwrap();
711 assert_eq!(runtime_configs_exists, 0);
712
713 let version: i64 = conn
714 .query_row(
715 "SELECT COALESCE(MAX(version), 0) FROM schema_version",
716 [],
717 |r| r.get(0),
718 )
719 .unwrap();
720 assert_eq!(version, 3);
721
722 let _ = fs::remove_file(&db_path);
723 }
724
725 #[test]
726 fn test_new_entry_with_scope_and_metadata() {
727 let (storage, _) = open_storage(Path::new(":memory:"), 100).unwrap();
728
729 let metadata = serde_json::json!({"runtime": "codex", "model": "gpt-5.3-codex"});
730 let entry = ContextEntry {
731 id: "v3-entry".into(),
732 content: "entry with scope and metadata".into(),
733 timestamp: 1_700_000_100,
734 kind: kind::SUMMARY.to_owned(),
735 scope: Some("project:homelab-rs".into()),
736 session_id: Some("session-123".into()),
737 token_count: Some(6),
738 metadata: Some(metadata.clone()),
739 };
740
741 storage.save(&entry).unwrap();
742
743 let all = storage.get_all().unwrap();
744 assert_eq!(all.len(), 1);
745 let got = &all[0];
746 assert_eq!(got.id, entry.id);
747 assert_eq!(got.content, entry.content);
748 assert_eq!(got.timestamp, entry.timestamp);
749 assert_eq!(got.kind, entry.kind);
750 assert_eq!(got.scope, entry.scope);
751 assert_eq!(got.session_id, entry.session_id);
752 assert_eq!(got.token_count, entry.token_count);
753 assert_eq!(got.metadata, Some(metadata));
754 }
755
756 #[test]
757 fn test_insert_or_replace() {
758 let (storage, _) = open_storage(Path::new(":memory:"), 100).unwrap();
759 storage
760 .save(&make_entry("e1", "original content", 100, kind::MANUAL))
761 .unwrap();
762 storage
763 .save(&make_entry("e1", "updated content", 200, kind::SUMMARY))
764 .unwrap();
765
766 assert_eq!(storage.count().unwrap(), 1);
767
768 let all = storage.get_all().unwrap();
769 assert_eq!(all[0].content, "updated content");
770 }
771
772 #[test]
773 fn test_search_match_all_query() {
774 let (storage, searcher) = open_storage(Path::new(":memory:"), 100).unwrap();
775 storage
776 .save(&make_entry("e1", "first entry", 100, kind::MANUAL))
777 .unwrap();
778 storage
779 .save(&make_entry("e2", "second entry", 200, kind::SNAPSHOT))
780 .unwrap();
781 storage
782 .save(&make_entry("e3", "third entry", 300, kind::SUMMARY))
783 .unwrap();
784
785 let results = searcher.search(MATCH_ALL_QUERY, None, 10).unwrap();
786 assert_eq!(results.len(), 3);
787
788 assert_eq!(results[0].entry.id, "e3");
790 assert_eq!(results[1].entry.id, "e2");
791 assert_eq!(results[2].entry.id, "e1");
792
793 for r in &results {
795 assert!((r.score - 1.0).abs() < f64::EPSILON);
796 }
797 }
798
799 #[test]
800 fn test_search_match_all_query_scoped() {
801 let (storage, searcher) = open_storage(Path::new(":memory:"), 100).unwrap();
802 storage
803 .save(&make_scoped_entry("e1", "first entry", 100, "a"))
804 .unwrap();
805 storage
806 .save(&make_scoped_entry("e2", "second entry", 200, "b"))
807 .unwrap();
808
809 let results_a = searcher.search(MATCH_ALL_QUERY, Some("a"), 10).unwrap();
810 assert_eq!(results_a.len(), 1);
811 assert_eq!(results_a[0].entry.id, "e1");
812
813 let results_all = searcher.search(MATCH_ALL_QUERY, None, 10).unwrap();
814 assert_eq!(results_all.len(), 2);
815 }
816
817 #[test]
818 fn search_with_fts5_operator_characters_does_not_error() {
819 let (storage, searcher) = open_storage(Path::new(":memory:"), 100).unwrap();
820 storage
821 .save(&make_entry("e1", "marco polo", 100, kind::MANUAL))
822 .unwrap();
823
824 let results = searcher.search(r#"if I say "marco"."#, None, 10).unwrap();
827
828 assert!(
829 results.iter().any(|r| r.entry.id == "e1"),
830 "expected 'marco polo' entry to be found despite FTS5 operator characters in the query"
831 );
832 }
833
834 #[test]
835 fn search_or_joins_terms_for_message_length_queries() {
836 let (storage, searcher) = open_storage(Path::new(":memory:"), 100).unwrap();
837 storage
838 .save(&make_entry("e1", "alpha one", 100, kind::MANUAL))
839 .unwrap();
840 storage
841 .save(&make_entry("e2", "beta two", 200, kind::MANUAL))
842 .unwrap();
843
844 let results = searcher.search("alpha beta gamma", None, 10).unwrap();
847
848 let ids: Vec<&str> = results.iter().map(|r| r.entry.id.as_str()).collect();
849 assert!(ids.contains(&"e1"), "expected 'alpha one' entry in results");
850 assert!(ids.contains(&"e2"), "expected 'beta two' entry in results");
851 }
852}