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