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#[path = "store_perm.rs"]
133mod store_perm;
134#[cfg(test)]
135#[cfg(all(test, unix))]
136#[path = "store_perm_tests.rs"]
137mod store_perm_tests;
138#[cfg(test)]
139#[path = "store_test_support.rs"]
140mod store_test_support;
141#[cfg(test)]
142pub(crate) use store_test_support::set_test_data_dir;
143
144fn db_path() -> PathBuf {
145 // Test-only hook (set by a `TestDataDirGuard` for the duration of one
146 // test). It takes precedence so a test's isolation cannot be clobbered
147 // by a stale `OO_DATA_DIR`.
148 #[cfg(test)]
149 if let Some(dir) = store_test_support::test_data_dir() {
150 return dir.join("oo.db");
151 }
152 // OO_DATA_DIR overrides the base directory so tests can isolate the store.
153 crate::config::data_dir().join("oo.db")
154}
155
156fn map_err(e: rusqlite::Error) -> Error {
157 Error::Store(e.to_string())
158}
159
160impl SqliteStore {
161 /// Open the default SQLite store: `oo.db` in `OO_DATA_DIR` when it is an
162 /// absolute path (otherwise ignored with a warning), else in `<platform data dir>/.oo/` (see docs/configuration.md#directories).
163 ///
164 /// Creates the database and tables if they don't exist.
165 pub fn open() -> Result<Self, Error> {
166 Self::open_at(&db_path())
167 }
168
169 /// Open a SQLite store at a specific path.
170 ///
171 /// Creates the database and tables if they don't exist.
172 pub fn open_at(path: &std::path::Path) -> Result<Self, Error> {
173 if let Some(parent) = path.parent() {
174 store_perm::create_data_dir(parent).map_err(|e| Error::Store(e.to_string()))?;
175 }
176 store_perm::prepare_db_files(path);
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;