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 the backend can enumerate under `project_id`,
100    /// regardless of the session that indexed it, including entries with NULL
101    /// or unparseable metadata. Completeness is backend-dependent:
102    ///
103    /// - `SqliteStore` deletes by the same `project` predicate that `search`
104    ///   uses, so `recall` finds nothing afterwards.
105    /// - `VipuneStore` enumerates the project with repeated `list` calls capped at
106    ///   10,000 entries, and its search is similarity-based. Completeness for very
107    ///   large projects is therefore backend-dependent and bounded by that
108    ///   stopping rule (see the `VipuneStore` impl docs in `store_vipune.rs`). A
109    ///   failed individual deletion is reported as an error.
110    ///
111    /// Returns the number of entries deleted.
112    fn delete_project(&mut self, project_id: &str) -> Result<usize, Error>;
113
114    /// Delete entries older than `max_age_secs` seconds.
115    ///
116    /// Returns the number of entries deleted.
117    fn cleanup_stale(&mut self, project_id: &str, max_age_secs: i64) -> Result<usize, Error>;
118}
119
120// ---------------------------------------------------------------------------
121// SqliteStore — default backend using FTS5 for text search
122// ---------------------------------------------------------------------------
123
124/// SQLite-based store using FTS5 for full-text search.
125///
126/// The default backend for `oo`, indexes command output in SQLite's
127/// FTS5 virtual table for efficient full-text search.
128pub struct SqliteStore {
129    conn: Connection,
130}
131
132#[cfg(test)]
133#[path = "store_test_support.rs"]
134mod store_test_support;
135#[cfg(test)]
136pub(crate) use store_test_support::{TestDataDirGuard, set_test_data_dir};
137
138fn db_path() -> PathBuf {
139    // Test-only hook (set by a `TestDataDirGuard` for the duration of one
140    // test). It takes precedence so a test's isolation cannot be clobbered
141    // by a stale `OO_DATA_DIR`.
142    #[cfg(test)]
143    if let Some(dir) = store_test_support::test_data_dir() {
144        return dir.join("oo.db");
145    }
146    // OO_DATA_DIR overrides the base directory so tests can isolate the store.
147    if let Some(data_dir) = std::env::var_os("OO_DATA_DIR") {
148        return PathBuf::from(data_dir).join("oo.db");
149    }
150    dirs::data_dir()
151        .or_else(dirs::home_dir)
152        .unwrap_or_else(|| PathBuf::from("/tmp"))
153        .join(".oo")
154        .join("oo.db")
155}
156
157fn map_err(e: rusqlite::Error) -> Error {
158    Error::Store(e.to_string())
159}
160
161impl SqliteStore {
162    /// Open the default SQLite store: `oo.db` in `OO_DATA_DIR` when set, otherwise
163    /// in `<platform data dir>/.oo/` (see docs/configuration.md#directories).
164    ///
165    /// Creates the database and tables if they don't exist.
166    pub fn open() -> Result<Self, Error> {
167        Self::open_at(&db_path())
168    }
169
170    /// Open a SQLite store at a specific path.
171    ///
172    /// Creates the database and tables if they don't exist.
173    pub fn open_at(path: &std::path::Path) -> Result<Self, Error> {
174        if let Some(parent) = path.parent() {
175            std::fs::create_dir_all(parent).map_err(|e| Error::Store(e.to_string()))?;
176        }
177        let conn = Connection::open(path).map_err(map_err)?;
178        conn.execute_batch(
179            "CREATE TABLE IF NOT EXISTS entries (
180                id       TEXT PRIMARY KEY,
181                project  TEXT NOT NULL,
182                content  TEXT NOT NULL,
183                metadata TEXT,
184                created  INTEGER NOT NULL
185            );
186            CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
187                content,
188                content='entries',
189                content_rowid='rowid'
190            );
191            CREATE TRIGGER IF NOT EXISTS entries_ai AFTER INSERT ON entries BEGIN
192                INSERT INTO entries_fts(rowid, content)
193                VALUES (new.rowid, new.content);
194            END;
195            CREATE TRIGGER IF NOT EXISTS entries_ad AFTER DELETE ON entries BEGIN
196                INSERT INTO entries_fts(entries_fts, rowid, content)
197                VALUES ('delete', old.rowid, old.content);
198            END;
199            CREATE TRIGGER IF NOT EXISTS entries_au AFTER UPDATE ON entries BEGIN
200                INSERT INTO entries_fts(entries_fts, rowid, content)
201                VALUES ('delete', old.rowid, old.content);
202                INSERT INTO entries_fts(rowid, content)
203                VALUES (new.rowid, new.content);
204            END;",
205        )
206        .map_err(map_err)?;
207        Ok(Self { conn })
208    }
209}
210
211impl Store for SqliteStore {
212    fn index(
213        &mut self,
214        project_id: &str,
215        content: &str,
216        meta: &SessionMeta,
217    ) -> Result<String, Error> {
218        let id = uuid::Uuid::new_v4().to_string();
219        let meta_json = serde_json::to_string(meta).map_err(|e| Error::Store(e.to_string()))?;
220        self.conn
221            .execute(
222                "INSERT INTO entries (id, project, content, metadata, created)
223                 VALUES (?1, ?2, ?3, ?4, ?5)",
224                rusqlite::params![id, project_id, content, meta_json, meta.timestamp],
225            )
226            .map_err(map_err)?;
227        Ok(id)
228    }
229
230    fn search(
231        &mut self,
232        project_id: &str,
233        query: &str,
234        limit: usize,
235    ) -> Result<Vec<SearchResult>, Error> {
236        // Use FTS5 for full-text search, fall back to LIKE if query is too short
237        let results = if query.len() >= 2 {
238            let mut stmt = self
239                .conn
240                .prepare(
241                    // snippet(entries_fts, 0, '…', '…', '…', 32) — one bounded
242                    // fragment centered on the best-scoring match. Column 0 is
243                    // `content`. 32 tokens ≈ ~200 chars for typical ASCII output.
244                    // Markers are fixed literals (not derived from user query) so
245                    // there is no injection surface.
246                    "SELECT e.id, e.content, e.metadata, rank,
247                     snippet(entries_fts, 0, '…', '…', '…', 32)
248                     FROM entries_fts f
249                     JOIN entries e ON e.rowid = f.rowid
250                     WHERE entries_fts MATCH ?1 AND e.project = ?2
251                     ORDER BY rank
252                     LIMIT ?3",
253                )
254                .map_err(map_err)?;
255
256            // FTS5 query: strip embedded double-quotes before wrapping tokens to
257            // prevent FTS5 syntax errors from user-supplied quotes in search terms.
258            // Strip " to prevent FTS5 syntax injection. Other special chars (*, ^, -)
259            // are neutralized by phrase quoting — e.g. "foo*bar" is treated as a
260            // literal phrase match rather than a prefix search, which is safe and
261            // correct for our use-case (exact token recall).
262            let fts_query = query
263                .split_whitespace()
264                .map(|w| format!("\"{}\"", w.replace('"', "")))
265                .collect::<Vec<_>>()
266                .join(" ");
267
268            stmt.query_map(rusqlite::params![fts_query, project_id, limit], |row| {
269                let id: String = row.get(0)?;
270                let content: String = row.get(1)?;
271                let meta_json: Option<String> = row.get(2)?;
272                let rank: f64 = row.get(3)?;
273                let snippet: String = row.get(4)?;
274                let snippet =
275                    if snippet.is_empty() || snippet.chars().count() >= content.chars().count() {
276                        None
277                    } else {
278                        Some(snippet)
279                    };
280                Ok(SearchResult {
281                    id,
282                    content,
283                    meta: meta_json.as_deref().and_then(parse_meta),
284                    similarity: Some(-rank), // FTS5 rank is negative
285                    // snippet() returns the full row when the match covers the
286                    // entire content (no room to trim) — map that to None so
287                    // callers fall back to a client-side bounded prefix.
288                    // The comparison is on `chars()` (not bytes) to stay
289                    // consistent with the char-based `SNIPPET_CAP` in
290                    // `recall_display`: for ASCII both are equal, and for
291                    // multi-byte content the snippet is a verbatim substring of
292                    // `content`, so `chars()` cannot misclassify either direction.
293                    // It does NOT guarantee the snippet fits the display budget
294                    // (the token-bounded window can hold one arbitrarily long
295                    // multi-byte token) — that is bounded display-side.
296                    snippet,
297                })
298            })
299            .map_err(map_err)?
300            .filter_map(|r| r.ok())
301            .collect()
302        } else {
303            let mut stmt = self
304                .conn
305                .prepare(
306                    "SELECT id, content, metadata
307                     FROM entries
308                     WHERE project = ?1 AND content LIKE ?2
309                     ORDER BY created DESC
310                     LIMIT ?3",
311                )
312                .map_err(map_err)?;
313
314            let like = format!("%{query}%");
315            stmt.query_map(rusqlite::params![project_id, like, limit], |row| {
316                let id: String = row.get(0)?;
317                let content: String = row.get(1)?;
318                let meta_json: Option<String> = row.get(2)?;
319                Ok(SearchResult {
320                    id,
321                    content,
322                    meta: meta_json.as_deref().and_then(parse_meta),
323                    similarity: None,
324                    // No FTS5 match context in the LIKE branch — callers must
325                    // fall back to a client-side bounded prefix of `content`.
326                    snippet: None,
327                })
328            })
329            .map_err(map_err)?
330            .filter_map(|r| match r {
331                Ok(r) => Some(r),
332                // Never silently drop a matched row — surface the deserialisation
333                // failure so callers can tell "fewer hits" from "no match".
334                Err(e) => {
335                    eprintln!("oo: warning: dropped a search result row (row error): {e}");
336                    None
337                }
338            })
339            .collect()
340        };
341
342        Ok(results)
343    }
344
345    fn delete_project(&mut self, project_id: &str) -> Result<usize, Error> {
346        // Project-scoped delete: drop every row for this project — including
347        // rows with NULL or unparseable metadata — so it removes exactly the
348        // set `search` can return. The FTS5 shadow table is kept in sync by
349        // the entries_ad trigger, so a plain DELETE suffices.
350        //
351        // Note: a trigger failure on the FTS5 shadow table is not surfaced
352        // through the returned row count — it is a rusqlite::Error, which is
353        // propagated, but a row-count check would not detect it.
354        let count = self
355            .conn
356            .execute(
357                "DELETE FROM entries WHERE project = ?1",
358                rusqlite::params![project_id],
359            )
360            .map_err(map_err)?;
361        Ok(count)
362    }
363
364    fn cleanup_stale(&mut self, project_id: &str, max_age_secs: i64) -> Result<usize, Error> {
365        let now = util::now_epoch();
366        let ids: Vec<String> = {
367            let mut stmt = self
368                .conn
369                .prepare("SELECT id, metadata FROM entries WHERE project = ?1")
370                .map_err(map_err)?;
371            stmt.query_map(rusqlite::params![project_id], |row| {
372                let id: String = row.get(0)?;
373                let meta_json: Option<String> = row.get(1)?;
374                Ok((id, meta_json))
375            })
376            .map_err(map_err)?
377            .filter_map(|r| r.ok())
378            .filter(|(_, meta_json)| {
379                meta_json
380                    .as_deref()
381                    .and_then(parse_meta)
382                    .is_some_and(|m| m.source == "oo" && (now - m.timestamp) > max_age_secs)
383            })
384            .map(|(id, _)| id)
385            .collect()
386        };
387
388        let count = ids.len();
389        for id in &ids {
390            self.conn
391                .execute("DELETE FROM entries WHERE id = ?1", rusqlite::params![id])
392                .map_err(map_err)?;
393        }
394        Ok(count)
395    }
396}
397
398// ---------------------------------------------------------------------------
399// VipuneStore — optional backend with semantic search
400// ---------------------------------------------------------------------------
401
402#[cfg(feature = "vipune-store")]
403#[path = "store_vipune.rs"]
404mod store_vipune;
405#[cfg(feature = "vipune-store")]
406pub use store_vipune::VipuneStore;
407
408// ---------------------------------------------------------------------------
409// Helpers
410// ---------------------------------------------------------------------------
411
412fn parse_meta(json: &str) -> Option<SessionMeta> {
413    serde_json::from_str(json).ok()
414}
415
416/// Open the default store (SqliteStore, or VipuneStore if feature-enabled).
417pub fn open() -> Result<Box<dyn Store>, Error> {
418    #[cfg(feature = "vipune-store")]
419    {
420        Ok(Box::new(VipuneStore::open()?))
421    }
422    #[cfg(not(feature = "vipune-store"))]
423    {
424        Ok(Box::new(SqliteStore::open()?))
425    }
426}
427
428// ---------------------------------------------------------------------------
429// Tests
430// ---------------------------------------------------------------------------
431
432#[cfg(test)]
433#[path = "store_tests.rs"]
434mod tests;