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;