Skip to main content

double_o/
store.rs

1use std::path::PathBuf;
2
3use rusqlite::Connection;
4use serde::{Deserialize, Serialize};
5
6use crate::error::Error;
7use crate::util;
8
9// ---------------------------------------------------------------------------
10// Types
11// ---------------------------------------------------------------------------
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct SessionMeta {
15    pub source: String,
16    pub session: String,
17    pub command: String,
18    pub timestamp: i64,
19}
20
21#[derive(Debug)]
22pub struct SearchResult {
23    pub id: String,
24    pub content: String,
25    pub meta: Option<SessionMeta>,
26    #[allow(dead_code)] // Used by VipuneStore (behind feature flag)
27    pub similarity: Option<f64>,
28}
29
30// ---------------------------------------------------------------------------
31// Store trait
32// ---------------------------------------------------------------------------
33
34pub trait Store {
35    fn index(
36        &mut self,
37        project_id: &str,
38        content: &str,
39        meta: &SessionMeta,
40    ) -> Result<String, Error>;
41
42    fn search(
43        &mut self,
44        project_id: &str,
45        query: &str,
46        limit: usize,
47    ) -> Result<Vec<SearchResult>, Error>;
48
49    fn delete_by_session(&mut self, project_id: &str, session_id: &str) -> Result<usize, Error>;
50
51    fn cleanup_stale(&mut self, project_id: &str, max_age_secs: i64) -> Result<usize, Error>;
52}
53
54// ---------------------------------------------------------------------------
55// SqliteStore — default backend using FTS5 for text search
56// ---------------------------------------------------------------------------
57
58pub struct SqliteStore {
59    conn: Connection,
60}
61
62fn db_path() -> PathBuf {
63    dirs::data_dir()
64        .or_else(dirs::home_dir)
65        .unwrap_or_else(|| PathBuf::from("/tmp"))
66        .join(".oo")
67        .join("oo.db")
68}
69
70fn map_err(e: rusqlite::Error) -> Error {
71    Error::Store(e.to_string())
72}
73
74impl SqliteStore {
75    pub fn open() -> Result<Self, Error> {
76        Self::open_at(&db_path())
77    }
78
79    pub fn open_at(path: &std::path::Path) -> Result<Self, Error> {
80        if let Some(parent) = path.parent() {
81            std::fs::create_dir_all(parent).map_err(|e| Error::Store(e.to_string()))?;
82        }
83        let conn = Connection::open(path).map_err(map_err)?;
84        conn.execute_batch(
85            "CREATE TABLE IF NOT EXISTS entries (
86                id       TEXT PRIMARY KEY,
87                project  TEXT NOT NULL,
88                content  TEXT NOT NULL,
89                metadata TEXT,
90                created  INTEGER NOT NULL
91            );
92            CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
93                content,
94                content='entries',
95                content_rowid='rowid'
96            );
97            CREATE TRIGGER IF NOT EXISTS entries_ai AFTER INSERT ON entries BEGIN
98                INSERT INTO entries_fts(rowid, content)
99                VALUES (new.rowid, new.content);
100            END;
101            CREATE TRIGGER IF NOT EXISTS entries_ad AFTER DELETE ON entries BEGIN
102                INSERT INTO entries_fts(entries_fts, rowid, content)
103                VALUES ('delete', old.rowid, old.content);
104            END;
105            CREATE TRIGGER IF NOT EXISTS entries_au AFTER UPDATE ON entries BEGIN
106                INSERT INTO entries_fts(entries_fts, rowid, content)
107                VALUES ('delete', old.rowid, old.content);
108                INSERT INTO entries_fts(rowid, content)
109                VALUES (new.rowid, new.content);
110            END;",
111        )
112        .map_err(map_err)?;
113        Ok(Self { conn })
114    }
115}
116
117impl Store for SqliteStore {
118    fn index(
119        &mut self,
120        project_id: &str,
121        content: &str,
122        meta: &SessionMeta,
123    ) -> Result<String, Error> {
124        let id = uuid::Uuid::new_v4().to_string();
125        let meta_json = serde_json::to_string(meta).map_err(|e| Error::Store(e.to_string()))?;
126        self.conn
127            .execute(
128                "INSERT INTO entries (id, project, content, metadata, created)
129                 VALUES (?1, ?2, ?3, ?4, ?5)",
130                rusqlite::params![id, project_id, content, meta_json, meta.timestamp],
131            )
132            .map_err(map_err)?;
133        Ok(id)
134    }
135
136    fn search(
137        &mut self,
138        project_id: &str,
139        query: &str,
140        limit: usize,
141    ) -> Result<Vec<SearchResult>, Error> {
142        // Use FTS5 for full-text search, fall back to LIKE if query is too short
143        let results = if query.len() >= 2 {
144            let mut stmt = self
145                .conn
146                .prepare(
147                    "SELECT e.id, e.content, e.metadata, rank
148                     FROM entries_fts f
149                     JOIN entries e ON e.rowid = f.rowid
150                     WHERE entries_fts MATCH ?1 AND e.project = ?2
151                     ORDER BY rank
152                     LIMIT ?3",
153                )
154                .map_err(map_err)?;
155
156            // FTS5 query: strip embedded double-quotes before wrapping tokens to
157            // prevent FTS5 syntax errors from user-supplied quotes in search terms.
158            // Strip " to prevent FTS5 syntax injection. Other special chars (*, ^, -)
159            // are neutralized by phrase quoting — e.g. "foo*bar" is treated as a
160            // literal phrase match rather than a prefix search, which is safe and
161            // correct for our use-case (exact token recall).
162            let fts_query = query
163                .split_whitespace()
164                .map(|w| format!("\"{}\"", w.replace('"', "")))
165                .collect::<Vec<_>>()
166                .join(" ");
167
168            stmt.query_map(rusqlite::params![fts_query, project_id, limit], |row| {
169                let id: String = row.get(0)?;
170                let content: String = row.get(1)?;
171                let meta_json: Option<String> = row.get(2)?;
172                let rank: f64 = row.get(3)?;
173                Ok(SearchResult {
174                    id,
175                    content,
176                    meta: meta_json.as_deref().and_then(parse_meta),
177                    similarity: Some(-rank), // FTS5 rank is negative
178                })
179            })
180            .map_err(map_err)?
181            .filter_map(|r| r.ok())
182            .collect()
183        } else {
184            let mut stmt = self
185                .conn
186                .prepare(
187                    "SELECT id, content, metadata
188                     FROM entries
189                     WHERE project = ?1 AND content LIKE ?2
190                     ORDER BY created DESC
191                     LIMIT ?3",
192                )
193                .map_err(map_err)?;
194
195            let like = format!("%{query}%");
196            stmt.query_map(rusqlite::params![project_id, like, limit], |row| {
197                let id: String = row.get(0)?;
198                let content: String = row.get(1)?;
199                let meta_json: Option<String> = row.get(2)?;
200                Ok(SearchResult {
201                    id,
202                    content,
203                    meta: meta_json.as_deref().and_then(parse_meta),
204                    similarity: None,
205                })
206            })
207            .map_err(map_err)?
208            .filter_map(|r| r.ok())
209            .collect()
210        };
211
212        Ok(results)
213    }
214
215    fn delete_by_session(&mut self, project_id: &str, session_id: &str) -> Result<usize, Error> {
216        // Find entries matching this session
217        let ids: Vec<String> = {
218            let mut stmt = self
219                .conn
220                .prepare("SELECT id, metadata FROM entries WHERE project = ?1")
221                .map_err(map_err)?;
222            stmt.query_map(rusqlite::params![project_id], |row| {
223                let id: String = row.get(0)?;
224                let meta_json: Option<String> = row.get(1)?;
225                Ok((id, meta_json))
226            })
227            .map_err(map_err)?
228            .filter_map(|r| r.ok())
229            .filter(|(_, meta_json)| {
230                meta_json
231                    .as_deref()
232                    .and_then(parse_meta)
233                    .is_some_and(|m| m.source == "oo" && m.session == session_id)
234            })
235            .map(|(id, _)| id)
236            .collect()
237        };
238
239        let count = ids.len();
240        for id in &ids {
241            self.conn
242                .execute("DELETE FROM entries WHERE id = ?1", rusqlite::params![id])
243                .map_err(map_err)?;
244        }
245        Ok(count)
246    }
247
248    fn cleanup_stale(&mut self, project_id: &str, max_age_secs: i64) -> Result<usize, Error> {
249        let now = util::now_epoch();
250        let ids: Vec<String> = {
251            let mut stmt = self
252                .conn
253                .prepare("SELECT id, metadata FROM entries WHERE project = ?1")
254                .map_err(map_err)?;
255            stmt.query_map(rusqlite::params![project_id], |row| {
256                let id: String = row.get(0)?;
257                let meta_json: Option<String> = row.get(1)?;
258                Ok((id, meta_json))
259            })
260            .map_err(map_err)?
261            .filter_map(|r| r.ok())
262            .filter(|(_, meta_json)| {
263                meta_json
264                    .as_deref()
265                    .and_then(parse_meta)
266                    .is_some_and(|m| m.source == "oo" && (now - m.timestamp) > max_age_secs)
267            })
268            .map(|(id, _)| id)
269            .collect()
270        };
271
272        let count = ids.len();
273        for id in &ids {
274            self.conn
275                .execute("DELETE FROM entries WHERE id = ?1", rusqlite::params![id])
276                .map_err(map_err)?;
277        }
278        Ok(count)
279    }
280}
281
282// ---------------------------------------------------------------------------
283// VipuneStore — optional backend with semantic search
284// ---------------------------------------------------------------------------
285
286#[cfg(feature = "vipune-store")]
287pub struct VipuneStore {
288    store: vipune::MemoryStore,
289}
290
291#[cfg(feature = "vipune-store")]
292impl VipuneStore {
293    pub fn open() -> Result<Self, Error> {
294        let config = vipune::Config::load().map_err(|e| Error::Store(e.to_string()))?;
295        let store =
296            vipune::MemoryStore::new(&config.database_path, &config.embedding_model, config)
297                .map_err(|e| Error::Store(e.to_string()))?;
298        Ok(Self { store })
299    }
300}
301
302#[cfg(feature = "vipune-store")]
303impl Store for VipuneStore {
304    fn index(
305        &mut self,
306        project_id: &str,
307        content: &str,
308        meta: &SessionMeta,
309    ) -> Result<String, Error> {
310        let meta_json = serde_json::to_string(meta).map_err(|e| Error::Store(e.to_string()))?;
311        match self
312            .store
313            .add_with_conflict(project_id, content, Some(&meta_json), true)
314        {
315            Ok(vipune::AddResult::Added { id }) => Ok(id),
316            Ok(vipune::AddResult::Conflicts { .. }) => Ok(String::new()),
317            Err(e) => Err(Error::Store(e.to_string())),
318        }
319    }
320
321    fn search(
322        &mut self,
323        project_id: &str,
324        query: &str,
325        limit: usize,
326    ) -> Result<Vec<SearchResult>, Error> {
327        let memories = self
328            .store
329            .search_hybrid(project_id, query, limit, 0.3)
330            .map_err(|e| Error::Store(e.to_string()))?;
331        Ok(memories
332            .into_iter()
333            .map(|m| SearchResult {
334                id: m.id,
335                meta: m.metadata.as_deref().and_then(parse_meta),
336                content: m.content,
337                similarity: m.similarity,
338            })
339            .collect())
340    }
341
342    fn delete_by_session(&mut self, project_id: &str, session_id: &str) -> Result<usize, Error> {
343        let entries = self
344            .store
345            .list(project_id, 10_000)
346            .map_err(|e| Error::Store(e.to_string()))?;
347        let mut count = 0;
348        for entry in entries {
349            if let Some(meta) = entry.metadata.as_deref().and_then(parse_meta) {
350                if meta.source == "oo" && meta.session == session_id {
351                    self.store
352                        .delete(&entry.id)
353                        .map_err(|e| Error::Store(e.to_string()))?;
354                    count += 1;
355                }
356            }
357        }
358        Ok(count)
359    }
360
361    fn cleanup_stale(&mut self, project_id: &str, max_age_secs: i64) -> Result<usize, Error> {
362        let now = util::now_epoch();
363        let entries = self
364            .store
365            .list(project_id, 10_000)
366            .map_err(|e| Error::Store(e.to_string()))?;
367        let mut count = 0;
368        for entry in entries {
369            if let Some(meta) = entry.metadata.as_deref().and_then(parse_meta) {
370                if meta.source == "oo" && (now - meta.timestamp) > max_age_secs {
371                    self.store
372                        .delete(&entry.id)
373                        .map_err(|e| Error::Store(e.to_string()))?;
374                    count += 1;
375                }
376            }
377        }
378        Ok(count)
379    }
380}
381
382// ---------------------------------------------------------------------------
383// Helpers
384// ---------------------------------------------------------------------------
385
386fn parse_meta(json: &str) -> Option<SessionMeta> {
387    serde_json::from_str(json).ok()
388}
389
390/// Open the default store (SqliteStore, or VipuneStore if feature-enabled).
391pub fn open() -> Result<Box<dyn Store>, Error> {
392    #[cfg(feature = "vipune-store")]
393    {
394        return Ok(Box::new(VipuneStore::open()?));
395    }
396    #[cfg(not(feature = "vipune-store"))]
397    {
398        Ok(Box::new(SqliteStore::open()?))
399    }
400}
401
402// ---------------------------------------------------------------------------
403// Tests
404// ---------------------------------------------------------------------------
405
406#[cfg(test)]
407#[path = "store_tests.rs"]
408mod tests;