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;