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///
35/// Marked `#[non_exhaustive]` so future fields (e.g. `snippet`) can be added
36/// in a non-breaking way — downstream code must use struct update syntax or
37/// constructors rather than exhaustive field literals.
38#[derive(Debug)]
39#[non_exhaustive]
40pub struct SearchResult {
41    /// Unique identifier for this entry.
42    pub id: String,
43
44    /// The stored content (command output).
45    pub content: String,
46
47    /// Optional metadata about this entry's origin.
48    pub meta: Option<SessionMeta>,
49
50    /// Optional similarity score (for semantic search backends).
51    #[allow(dead_code)] // Used by VipuneStore (behind feature flag)
52    pub similarity: Option<f64>,
53
54    /// Optional bounded excerpt centered on the best match.
55    ///
56    /// Populated by the FTS5 branch of `SqliteStore::search` via the
57    /// `snippet()` FTS5 function. `None` for the short-query LIKE fallback
58    /// and for backends without FTS5 (e.g. `VipuneStore`). Callers should
59    /// fall back to a client-side bounded prefix of `content` when this is
60    /// `None`. Note the `snippet()` window is bounded in *tokens*, so for
61    /// content containing arbitrarily long multi-byte tokens a `Some` snippet
62    /// can still exceed the display budget — display callers must apply their
63    /// own char cap (see `recall_display::display_hit`).
64    pub snippet: Option<String>,
65}
66
67// ---------------------------------------------------------------------------
68// Store trait
69// ---------------------------------------------------------------------------
70
71/// Backend for storing and searching indexed command output.
72///
73/// Implementations can use different storage mechanisms (SQLite, Vipune, etc.)
74/// to persist and retrieve command output for later recall.
75pub trait Store {
76    /// Index a command output entry for later retrieval.
77    ///
78    /// Returns the unique identifier of the indexed entry.
79    fn index(
80        &mut self,
81        project_id: &str,
82        content: &str,
83        meta: &SessionMeta,
84    ) -> Result<String, Error>;
85
86    /// Search for indexed entries matching a query.
87    ///
88    /// Returns up to `limit` results ordered by relevance.
89    fn search(
90        &mut self,
91        project_id: &str,
92        query: &str,
93        limit: usize,
94    ) -> Result<Vec<SearchResult>, Error>;
95
96    /// Delete all entries for a specific session.
97    ///
98    /// Returns the number of entries deleted.
99    fn delete_by_session(&mut self, project_id: &str, session_id: &str) -> Result<usize, Error>;
100
101    /// Delete entries older than `max_age_secs` seconds.
102    ///
103    /// Returns the number of entries deleted.
104    fn cleanup_stale(&mut self, project_id: &str, max_age_secs: i64) -> Result<usize, Error>;
105}
106
107// ---------------------------------------------------------------------------
108// SqliteStore — default backend using FTS5 for text search
109// ---------------------------------------------------------------------------
110
111/// SQLite-based store using FTS5 for full-text search.
112///
113/// The default backend for `oo`, indexes command output in SQLite's
114/// FTS5 virtual table for efficient full-text search.
115pub struct SqliteStore {
116    conn: Connection,
117}
118
119fn db_path() -> PathBuf {
120    // OO_DATA_DIR overrides the base directory so tests can isolate the store.
121    if let Some(data_dir) = std::env::var_os("OO_DATA_DIR") {
122        return PathBuf::from(data_dir).join("oo.db");
123    }
124    dirs::data_dir()
125        .or_else(dirs::home_dir)
126        .unwrap_or_else(|| PathBuf::from("/tmp"))
127        .join(".oo")
128        .join("oo.db")
129}
130
131fn map_err(e: rusqlite::Error) -> Error {
132    Error::Store(e.to_string())
133}
134
135impl SqliteStore {
136    /// Open the default SQLite store at `~/.local/share/.oo/oo.db`.
137    ///
138    /// Creates the database and tables if they don't exist.
139    pub fn open() -> Result<Self, Error> {
140        Self::open_at(&db_path())
141    }
142
143    /// Open a SQLite store at a specific path.
144    ///
145    /// Creates the database and tables if they don't exist.
146    pub fn open_at(path: &std::path::Path) -> Result<Self, Error> {
147        if let Some(parent) = path.parent() {
148            std::fs::create_dir_all(parent).map_err(|e| Error::Store(e.to_string()))?;
149        }
150        let conn = Connection::open(path).map_err(map_err)?;
151        conn.execute_batch(
152            "CREATE TABLE IF NOT EXISTS entries (
153                id       TEXT PRIMARY KEY,
154                project  TEXT NOT NULL,
155                content  TEXT NOT NULL,
156                metadata TEXT,
157                created  INTEGER NOT NULL
158            );
159            CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
160                content,
161                content='entries',
162                content_rowid='rowid'
163            );
164            CREATE TRIGGER IF NOT EXISTS entries_ai AFTER INSERT ON entries BEGIN
165                INSERT INTO entries_fts(rowid, content)
166                VALUES (new.rowid, new.content);
167            END;
168            CREATE TRIGGER IF NOT EXISTS entries_ad AFTER DELETE ON entries BEGIN
169                INSERT INTO entries_fts(entries_fts, rowid, content)
170                VALUES ('delete', old.rowid, old.content);
171            END;
172            CREATE TRIGGER IF NOT EXISTS entries_au AFTER UPDATE ON entries BEGIN
173                INSERT INTO entries_fts(entries_fts, rowid, content)
174                VALUES ('delete', old.rowid, old.content);
175                INSERT INTO entries_fts(rowid, content)
176                VALUES (new.rowid, new.content);
177            END;",
178        )
179        .map_err(map_err)?;
180        Ok(Self { conn })
181    }
182}
183
184impl Store for SqliteStore {
185    fn index(
186        &mut self,
187        project_id: &str,
188        content: &str,
189        meta: &SessionMeta,
190    ) -> Result<String, Error> {
191        let id = uuid::Uuid::new_v4().to_string();
192        let meta_json = serde_json::to_string(meta).map_err(|e| Error::Store(e.to_string()))?;
193        self.conn
194            .execute(
195                "INSERT INTO entries (id, project, content, metadata, created)
196                 VALUES (?1, ?2, ?3, ?4, ?5)",
197                rusqlite::params![id, project_id, content, meta_json, meta.timestamp],
198            )
199            .map_err(map_err)?;
200        Ok(id)
201    }
202
203    fn search(
204        &mut self,
205        project_id: &str,
206        query: &str,
207        limit: usize,
208    ) -> Result<Vec<SearchResult>, Error> {
209        // Use FTS5 for full-text search, fall back to LIKE if query is too short
210        let results = if query.len() >= 2 {
211            let mut stmt = self
212                .conn
213                .prepare(
214                    // snippet(entries_fts, 0, '…', '…', '…', 32) — one bounded
215                    // fragment centered on the best-scoring match. Column 0 is
216                    // `content`. 32 tokens ≈ ~200 chars for typical ASCII output.
217                    // Markers are fixed literals (not derived from user query) so
218                    // there is no injection surface.
219                    "SELECT e.id, e.content, e.metadata, rank,
220                     snippet(entries_fts, 0, '…', '…', '…', 32)
221                     FROM entries_fts f
222                     JOIN entries e ON e.rowid = f.rowid
223                     WHERE entries_fts MATCH ?1 AND e.project = ?2
224                     ORDER BY rank
225                     LIMIT ?3",
226                )
227                .map_err(map_err)?;
228
229            // FTS5 query: strip embedded double-quotes before wrapping tokens to
230            // prevent FTS5 syntax errors from user-supplied quotes in search terms.
231            // Strip " to prevent FTS5 syntax injection. Other special chars (*, ^, -)
232            // are neutralized by phrase quoting — e.g. "foo*bar" is treated as a
233            // literal phrase match rather than a prefix search, which is safe and
234            // correct for our use-case (exact token recall).
235            let fts_query = query
236                .split_whitespace()
237                .map(|w| format!("\"{}\"", w.replace('"', "")))
238                .collect::<Vec<_>>()
239                .join(" ");
240
241            stmt.query_map(rusqlite::params![fts_query, project_id, limit], |row| {
242                let id: String = row.get(0)?;
243                let content: String = row.get(1)?;
244                let meta_json: Option<String> = row.get(2)?;
245                let rank: f64 = row.get(3)?;
246                let snippet: String = row.get(4)?;
247                let snippet =
248                    if snippet.is_empty() || snippet.chars().count() >= content.chars().count() {
249                        None
250                    } else {
251                        Some(snippet)
252                    };
253                Ok(SearchResult {
254                    id,
255                    content,
256                    meta: meta_json.as_deref().and_then(parse_meta),
257                    similarity: Some(-rank), // FTS5 rank is negative
258                    // snippet() returns the full row when the match covers the
259                    // entire content (no room to trim) — map that to None so
260                    // callers fall back to a client-side bounded prefix.
261                    // The comparison is on `chars()` (not bytes) to stay
262                    // consistent with the char-based `SNIPPET_CAP` in
263                    // `recall_display`: for ASCII both are equal, and for
264                    // multi-byte content the snippet is a verbatim substring of
265                    // `content`, so `chars()` cannot misclassify either direction.
266                    // It does NOT guarantee the snippet fits the display budget
267                    // (the token-bounded window can hold one arbitrarily long
268                    // multi-byte token) — that is bounded display-side.
269                    snippet,
270                })
271            })
272            .map_err(map_err)?
273            .filter_map(|r| r.ok())
274            .collect()
275        } else {
276            let mut stmt = self
277                .conn
278                .prepare(
279                    "SELECT id, content, metadata
280                     FROM entries
281                     WHERE project = ?1 AND content LIKE ?2
282                     ORDER BY created DESC
283                     LIMIT ?3",
284                )
285                .map_err(map_err)?;
286
287            let like = format!("%{query}%");
288            stmt.query_map(rusqlite::params![project_id, like, limit], |row| {
289                let id: String = row.get(0)?;
290                let content: String = row.get(1)?;
291                let meta_json: Option<String> = row.get(2)?;
292                Ok(SearchResult {
293                    id,
294                    content,
295                    meta: meta_json.as_deref().and_then(parse_meta),
296                    similarity: None,
297                    // No FTS5 match context in the LIKE branch — callers must
298                    // fall back to a client-side bounded prefix of `content`.
299                    snippet: None,
300                })
301            })
302            .map_err(map_err)?
303            .filter_map(|r| match r {
304                Ok(r) => Some(r),
305                // Never silently drop a matched row — surface the deserialisation
306                // failure so callers can tell "fewer hits" from "no match".
307                Err(e) => {
308                    eprintln!("oo: warning: dropped a search result row (row error): {e}");
309                    None
310                }
311            })
312            .collect()
313        };
314
315        Ok(results)
316    }
317
318    fn delete_by_session(&mut self, project_id: &str, session_id: &str) -> Result<usize, Error> {
319        // Find entries matching this session
320        let ids: Vec<String> = {
321            let mut stmt = self
322                .conn
323                .prepare("SELECT id, metadata FROM entries WHERE project = ?1")
324                .map_err(map_err)?;
325            stmt.query_map(rusqlite::params![project_id], |row| {
326                let id: String = row.get(0)?;
327                let meta_json: Option<String> = row.get(1)?;
328                Ok((id, meta_json))
329            })
330            .map_err(map_err)?
331            .filter_map(|r| r.ok())
332            .filter(|(_, meta_json)| {
333                meta_json
334                    .as_deref()
335                    .and_then(parse_meta)
336                    .is_some_and(|m| m.source == "oo" && m.session == session_id)
337            })
338            .map(|(id, _)| id)
339            .collect()
340        };
341
342        let count = ids.len();
343        for id in &ids {
344            self.conn
345                .execute("DELETE FROM entries WHERE id = ?1", rusqlite::params![id])
346                .map_err(map_err)?;
347        }
348        Ok(count)
349    }
350
351    fn cleanup_stale(&mut self, project_id: &str, max_age_secs: i64) -> Result<usize, Error> {
352        let now = util::now_epoch();
353        let ids: Vec<String> = {
354            let mut stmt = self
355                .conn
356                .prepare("SELECT id, metadata FROM entries WHERE project = ?1")
357                .map_err(map_err)?;
358            stmt.query_map(rusqlite::params![project_id], |row| {
359                let id: String = row.get(0)?;
360                let meta_json: Option<String> = row.get(1)?;
361                Ok((id, meta_json))
362            })
363            .map_err(map_err)?
364            .filter_map(|r| r.ok())
365            .filter(|(_, meta_json)| {
366                meta_json
367                    .as_deref()
368                    .and_then(parse_meta)
369                    .is_some_and(|m| m.source == "oo" && (now - m.timestamp) > max_age_secs)
370            })
371            .map(|(id, _)| id)
372            .collect()
373        };
374
375        let count = ids.len();
376        for id in &ids {
377            self.conn
378                .execute("DELETE FROM entries WHERE id = ?1", rusqlite::params![id])
379                .map_err(map_err)?;
380        }
381        Ok(count)
382    }
383}
384
385// ---------------------------------------------------------------------------
386// VipuneStore — optional backend with semantic search
387// ---------------------------------------------------------------------------
388
389/// Vipune-backed store with semantic search capabilities.
390///
391/// Uses Vipune's cross-session memory with semantic embedding search.
392/// Available behind the `vipune-store` feature flag.
393#[cfg(feature = "vipune-store")]
394pub struct VipuneStore {
395    store: vipune::MemoryStore,
396}
397
398#[cfg(feature = "vipune-store")]
399impl VipuneStore {
400    /// Open the Vipune store with default configuration.
401    ///
402    /// Loads Vipune configuration from its usual location and initializes
403    /// the memory store with semantic search.
404    pub fn open() -> Result<Self, Error> {
405        let config = vipune::Config::load().map_err(|e| Error::Store(e.to_string()))?;
406        let store =
407            vipune::MemoryStore::new(&config.database_path, &config.embedding_model, config)
408                .map_err(|e| Error::Store(e.to_string()))?;
409        Ok(Self { store })
410    }
411}
412
413#[cfg(feature = "vipune-store")]
414impl Store for VipuneStore {
415    fn index(
416        &mut self,
417        project_id: &str,
418        content: &str,
419        meta: &SessionMeta,
420    ) -> Result<String, Error> {
421        let meta_json = serde_json::to_string(meta).map_err(|e| Error::Store(e.to_string()))?;
422        match self
423            .store
424            .add_with_conflict(project_id, content, Some(&meta_json), true)
425        {
426            Ok(vipune::AddResult::Added { id }) => Ok(id),
427            Ok(vipune::AddResult::Conflicts { .. }) => Ok(String::new()),
428            Err(e) => Err(Error::Store(e.to_string())),
429        }
430    }
431
432    fn search(
433        &mut self,
434        project_id: &str,
435        query: &str,
436        limit: usize,
437    ) -> Result<Vec<SearchResult>, Error> {
438        let memories = self
439            .store
440            .search_hybrid(project_id, query, limit, 0.3)
441            .map_err(|e| Error::Store(e.to_string()))?;
442        Ok(memories
443            .into_iter()
444            .map(|m| SearchResult {
445                id: m.id,
446                meta: m.metadata.as_deref().and_then(parse_meta),
447                content: m.content,
448                similarity: m.similarity,
449                // VipuneStore has no FTS5 — callers fall back to a bounded
450                // client-side prefix of `content`.
451                snippet: None,
452            })
453            .collect())
454    }
455
456    fn delete_by_session(&mut self, project_id: &str, session_id: &str) -> Result<usize, Error> {
457        let entries = self
458            .store
459            .list(project_id, 10_000)
460            .map_err(|e| Error::Store(e.to_string()))?;
461        let mut count = 0;
462        for entry in entries {
463            if let Some(meta) = entry.metadata.as_deref().and_then(parse_meta) {
464                if meta.source == "oo" && meta.session == session_id {
465                    self.store
466                        .delete(&entry.id)
467                        .map_err(|e| Error::Store(e.to_string()))?;
468                    count += 1;
469                }
470            }
471        }
472        Ok(count)
473    }
474
475    fn cleanup_stale(&mut self, project_id: &str, max_age_secs: i64) -> Result<usize, Error> {
476        let now = util::now_epoch();
477        let entries = self
478            .store
479            .list(project_id, 10_000)
480            .map_err(|e| Error::Store(e.to_string()))?;
481        let mut count = 0;
482        for entry in entries {
483            if let Some(meta) = entry.metadata.as_deref().and_then(parse_meta) {
484                if meta.source == "oo" && (now - meta.timestamp) > max_age_secs {
485                    self.store
486                        .delete(&entry.id)
487                        .map_err(|e| Error::Store(e.to_string()))?;
488                    count += 1;
489                }
490            }
491        }
492        Ok(count)
493    }
494}
495
496// ---------------------------------------------------------------------------
497// Helpers
498// ---------------------------------------------------------------------------
499
500fn parse_meta(json: &str) -> Option<SessionMeta> {
501    serde_json::from_str(json).ok()
502}
503
504/// Open the default store (SqliteStore, or VipuneStore if feature-enabled).
505pub fn open() -> Result<Box<dyn Store>, Error> {
506    #[cfg(feature = "vipune-store")]
507    {
508        return Ok(Box::new(VipuneStore::open()?));
509    }
510    #[cfg(not(feature = "vipune-store"))]
511    {
512        Ok(Box::new(SqliteStore::open()?))
513    }
514}
515
516// ---------------------------------------------------------------------------
517// Tests
518// ---------------------------------------------------------------------------
519
520#[cfg(test)]
521#[path = "store_tests.rs"]
522mod tests;