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