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