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 (parent process ID).
22 pub session: String,
23
24 /// The command that generated this output.
25 pub command: String,
26
27 /// Unix timestamp when this entry was created.
28 pub timestamp: i64,
29}
30
31/// Result from a store search operation.
32///
33/// Contains the stored content along with its identifier and optional metadata.
34///
35/// Marked `#[non_exhaustive]` so future fields (e.g. `snippet`) can be added
36/// in a non-breaking way — downstream code must use struct update syntax or
37/// constructors rather than exhaustive field literals.
38#[derive(Debug)]
39#[non_exhaustive]
40pub struct SearchResult {
41 /// Unique identifier for this entry.
42 pub id: String,
43
44 /// The stored content (command output).
45 pub content: String,
46
47 /// Optional metadata about this entry's origin.
48 pub meta: Option<SessionMeta>,
49
50 /// Optional similarity score (for semantic search backends).
51 #[allow(dead_code)] // Used by VipuneStore (behind feature flag)
52 pub similarity: Option<f64>,
53
54 /// Optional bounded excerpt centered on the best match.
55 ///
56 /// Populated by the FTS5 branch of `SqliteStore::search` via the
57 /// `snippet()` FTS5 function. `None` for the short-query LIKE fallback
58 /// and for backends without FTS5 (e.g. `VipuneStore`). Callers should
59 /// fall back to a client-side bounded prefix of `content` when this is
60 /// `None`. Note the `snippet()` window is bounded in *tokens*, so for
61 /// content containing arbitrarily long multi-byte tokens a `Some` snippet
62 /// can still exceed the display budget — display callers must apply their
63 /// own char cap (see `recall_display::display_hit`).
64 pub snippet: Option<String>,
65}
66
67// ---------------------------------------------------------------------------
68// Store trait
69// ---------------------------------------------------------------------------
70
71/// Backend for storing and searching indexed command output.
72///
73/// Implementations can use different storage mechanisms (SQLite, Vipune, etc.)
74/// to persist and retrieve command output for later recall.
75pub trait Store {
76 /// Index a command output entry for later retrieval.
77 ///
78 /// Returns the unique identifier of the indexed entry.
79 fn index(
80 &mut self,
81 project_id: &str,
82 content: &str,
83 meta: &SessionMeta,
84 ) -> Result<String, Error>;
85
86 /// Search for indexed entries matching a query.
87 ///
88 /// Returns up to `limit` results ordered by relevance.
89 fn search(
90 &mut self,
91 project_id: &str,
92 query: &str,
93 limit: usize,
94 ) -> Result<Vec<SearchResult>, Error>;
95
96 /// Delete all entries for a specific session.
97 ///
98 /// Returns the number of entries deleted.
99 fn delete_by_session(&mut self, project_id: &str, session_id: &str) -> Result<usize, Error>;
100
101 /// Delete entries older than `max_age_secs` seconds.
102 ///
103 /// Returns the number of entries deleted.
104 fn cleanup_stale(&mut self, project_id: &str, max_age_secs: i64) -> Result<usize, Error>;
105}
106
107// ---------------------------------------------------------------------------
108// SqliteStore — default backend using FTS5 for text search
109// ---------------------------------------------------------------------------
110
111/// SQLite-based store using FTS5 for full-text search.
112///
113/// The default backend for `oo`, indexes command output in SQLite's
114/// FTS5 virtual table for efficient full-text search.
115pub struct SqliteStore {
116 conn: Connection,
117}
118
119fn db_path() -> PathBuf {
120 // OO_DATA_DIR overrides the base directory so tests can isolate the store.
121 if let Some(data_dir) = std::env::var_os("OO_DATA_DIR") {
122 return PathBuf::from(data_dir).join("oo.db");
123 }
124 dirs::data_dir()
125 .or_else(dirs::home_dir)
126 .unwrap_or_else(|| PathBuf::from("/tmp"))
127 .join(".oo")
128 .join("oo.db")
129}
130
131fn map_err(e: rusqlite::Error) -> Error {
132 Error::Store(e.to_string())
133}
134
135impl SqliteStore {
136 /// Open the default SQLite store at `~/.local/share/.oo/oo.db`.
137 ///
138 /// Creates the database and tables if they don't exist.
139 pub fn open() -> Result<Self, Error> {
140 Self::open_at(&db_path())
141 }
142
143 /// Open a SQLite store at a specific path.
144 ///
145 /// Creates the database and tables if they don't exist.
146 pub fn open_at(path: &std::path::Path) -> Result<Self, Error> {
147 if let Some(parent) = path.parent() {
148 std::fs::create_dir_all(parent).map_err(|e| Error::Store(e.to_string()))?;
149 }
150 let conn = Connection::open(path).map_err(map_err)?;
151 conn.execute_batch(
152 "CREATE TABLE IF NOT EXISTS entries (
153 id TEXT PRIMARY KEY,
154 project TEXT NOT NULL,
155 content TEXT NOT NULL,
156 metadata TEXT,
157 created INTEGER NOT NULL
158 );
159 CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
160 content,
161 content='entries',
162 content_rowid='rowid'
163 );
164 CREATE TRIGGER IF NOT EXISTS entries_ai AFTER INSERT ON entries BEGIN
165 INSERT INTO entries_fts(rowid, content)
166 VALUES (new.rowid, new.content);
167 END;
168 CREATE TRIGGER IF NOT EXISTS entries_ad AFTER DELETE ON entries BEGIN
169 INSERT INTO entries_fts(entries_fts, rowid, content)
170 VALUES ('delete', old.rowid, old.content);
171 END;
172 CREATE TRIGGER IF NOT EXISTS entries_au AFTER UPDATE ON entries BEGIN
173 INSERT INTO entries_fts(entries_fts, rowid, content)
174 VALUES ('delete', old.rowid, old.content);
175 INSERT INTO entries_fts(rowid, content)
176 VALUES (new.rowid, new.content);
177 END;",
178 )
179 .map_err(map_err)?;
180 Ok(Self { conn })
181 }
182}
183
184impl Store for SqliteStore {
185 fn index(
186 &mut self,
187 project_id: &str,
188 content: &str,
189 meta: &SessionMeta,
190 ) -> Result<String, Error> {
191 let id = uuid::Uuid::new_v4().to_string();
192 let meta_json = serde_json::to_string(meta).map_err(|e| Error::Store(e.to_string()))?;
193 self.conn
194 .execute(
195 "INSERT INTO entries (id, project, content, metadata, created)
196 VALUES (?1, ?2, ?3, ?4, ?5)",
197 rusqlite::params![id, project_id, content, meta_json, meta.timestamp],
198 )
199 .map_err(map_err)?;
200 Ok(id)
201 }
202
203 fn search(
204 &mut self,
205 project_id: &str,
206 query: &str,
207 limit: usize,
208 ) -> Result<Vec<SearchResult>, Error> {
209 // Use FTS5 for full-text search, fall back to LIKE if query is too short
210 let results = if query.len() >= 2 {
211 let mut stmt = self
212 .conn
213 .prepare(
214 // snippet(entries_fts, 0, '…', '…', '…', 32) — one bounded
215 // fragment centered on the best-scoring match. Column 0 is
216 // `content`. 32 tokens ≈ ~200 chars for typical ASCII output.
217 // Markers are fixed literals (not derived from user query) so
218 // there is no injection surface.
219 "SELECT e.id, e.content, e.metadata, rank,
220 snippet(entries_fts, 0, '…', '…', '…', 32)
221 FROM entries_fts f
222 JOIN entries e ON e.rowid = f.rowid
223 WHERE entries_fts MATCH ?1 AND e.project = ?2
224 ORDER BY rank
225 LIMIT ?3",
226 )
227 .map_err(map_err)?;
228
229 // FTS5 query: strip embedded double-quotes before wrapping tokens to
230 // prevent FTS5 syntax errors from user-supplied quotes in search terms.
231 // Strip " to prevent FTS5 syntax injection. Other special chars (*, ^, -)
232 // are neutralized by phrase quoting — e.g. "foo*bar" is treated as a
233 // literal phrase match rather than a prefix search, which is safe and
234 // correct for our use-case (exact token recall).
235 let fts_query = query
236 .split_whitespace()
237 .map(|w| format!("\"{}\"", w.replace('"', "")))
238 .collect::<Vec<_>>()
239 .join(" ");
240
241 stmt.query_map(rusqlite::params![fts_query, project_id, limit], |row| {
242 let id: String = row.get(0)?;
243 let content: String = row.get(1)?;
244 let meta_json: Option<String> = row.get(2)?;
245 let rank: f64 = row.get(3)?;
246 let snippet: String = row.get(4)?;
247 let snippet =
248 if snippet.is_empty() || snippet.chars().count() >= content.chars().count() {
249 None
250 } else {
251 Some(snippet)
252 };
253 Ok(SearchResult {
254 id,
255 content,
256 meta: meta_json.as_deref().and_then(parse_meta),
257 similarity: Some(-rank), // FTS5 rank is negative
258 // snippet() returns the full row when the match covers the
259 // entire content (no room to trim) — map that to None so
260 // callers fall back to a client-side bounded prefix.
261 // The comparison is on `chars()` (not bytes) to stay
262 // consistent with the char-based `SNIPPET_CAP` in
263 // `recall_display`: for ASCII both are equal, and for
264 // multi-byte content the snippet is a verbatim substring of
265 // `content`, so `chars()` cannot misclassify either direction.
266 // It does NOT guarantee the snippet fits the display budget
267 // (the token-bounded window can hold one arbitrarily long
268 // multi-byte token) — that is bounded display-side.
269 snippet,
270 })
271 })
272 .map_err(map_err)?
273 .filter_map(|r| r.ok())
274 .collect()
275 } else {
276 let mut stmt = self
277 .conn
278 .prepare(
279 "SELECT id, content, metadata
280 FROM entries
281 WHERE project = ?1 AND content LIKE ?2
282 ORDER BY created DESC
283 LIMIT ?3",
284 )
285 .map_err(map_err)?;
286
287 let like = format!("%{query}%");
288 stmt.query_map(rusqlite::params![project_id, like, limit], |row| {
289 let id: String = row.get(0)?;
290 let content: String = row.get(1)?;
291 let meta_json: Option<String> = row.get(2)?;
292 Ok(SearchResult {
293 id,
294 content,
295 meta: meta_json.as_deref().and_then(parse_meta),
296 similarity: None,
297 // No FTS5 match context in the LIKE branch — callers must
298 // fall back to a client-side bounded prefix of `content`.
299 snippet: None,
300 })
301 })
302 .map_err(map_err)?
303 .filter_map(|r| match r {
304 Ok(r) => Some(r),
305 // Never silently drop a matched row — surface the deserialisation
306 // failure so callers can tell "fewer hits" from "no match".
307 Err(e) => {
308 eprintln!("oo: warning: dropped a search result row (row error): {e}");
309 None
310 }
311 })
312 .collect()
313 };
314
315 Ok(results)
316 }
317
318 fn delete_by_session(&mut self, project_id: &str, session_id: &str) -> Result<usize, Error> {
319 // Find entries matching this session
320 let ids: Vec<String> = {
321 let mut stmt = self
322 .conn
323 .prepare("SELECT id, metadata FROM entries WHERE project = ?1")
324 .map_err(map_err)?;
325 stmt.query_map(rusqlite::params![project_id], |row| {
326 let id: String = row.get(0)?;
327 let meta_json: Option<String> = row.get(1)?;
328 Ok((id, meta_json))
329 })
330 .map_err(map_err)?
331 .filter_map(|r| r.ok())
332 .filter(|(_, meta_json)| {
333 meta_json
334 .as_deref()
335 .and_then(parse_meta)
336 .is_some_and(|m| m.source == "oo" && m.session == session_id)
337 })
338 .map(|(id, _)| id)
339 .collect()
340 };
341
342 let count = ids.len();
343 for id in &ids {
344 self.conn
345 .execute("DELETE FROM entries WHERE id = ?1", rusqlite::params![id])
346 .map_err(map_err)?;
347 }
348 Ok(count)
349 }
350
351 fn cleanup_stale(&mut self, project_id: &str, max_age_secs: i64) -> Result<usize, Error> {
352 let now = util::now_epoch();
353 let ids: Vec<String> = {
354 let mut stmt = self
355 .conn
356 .prepare("SELECT id, metadata FROM entries WHERE project = ?1")
357 .map_err(map_err)?;
358 stmt.query_map(rusqlite::params![project_id], |row| {
359 let id: String = row.get(0)?;
360 let meta_json: Option<String> = row.get(1)?;
361 Ok((id, meta_json))
362 })
363 .map_err(map_err)?
364 .filter_map(|r| r.ok())
365 .filter(|(_, meta_json)| {
366 meta_json
367 .as_deref()
368 .and_then(parse_meta)
369 .is_some_and(|m| m.source == "oo" && (now - m.timestamp) > max_age_secs)
370 })
371 .map(|(id, _)| id)
372 .collect()
373 };
374
375 let count = ids.len();
376 for id in &ids {
377 self.conn
378 .execute("DELETE FROM entries WHERE id = ?1", rusqlite::params![id])
379 .map_err(map_err)?;
380 }
381 Ok(count)
382 }
383}
384
385// ---------------------------------------------------------------------------
386// VipuneStore — optional backend with semantic search
387// ---------------------------------------------------------------------------
388
389/// Vipune-backed store with semantic search capabilities.
390///
391/// Uses Vipune's cross-session memory with semantic embedding search.
392/// Available behind the `vipune-store` feature flag.
393#[cfg(feature = "vipune-store")]
394pub struct VipuneStore {
395 store: vipune::MemoryStore,
396}
397
398#[cfg(feature = "vipune-store")]
399impl VipuneStore {
400 /// Open the Vipune store with default configuration.
401 ///
402 /// Loads Vipune configuration from its usual location and initializes
403 /// the memory store with semantic search.
404 pub fn open() -> Result<Self, Error> {
405 let config = vipune::Config::load().map_err(|e| Error::Store(e.to_string()))?;
406 let store =
407 vipune::MemoryStore::new(&config.database_path, &config.embedding_model, config)
408 .map_err(|e| Error::Store(e.to_string()))?;
409 Ok(Self { store })
410 }
411}
412
413#[cfg(feature = "vipune-store")]
414impl Store for VipuneStore {
415 fn index(
416 &mut self,
417 project_id: &str,
418 content: &str,
419 meta: &SessionMeta,
420 ) -> Result<String, Error> {
421 let meta_json = serde_json::to_string(meta).map_err(|e| Error::Store(e.to_string()))?;
422 match self
423 .store
424 .add_with_conflict(project_id, content, Some(&meta_json), true)
425 {
426 Ok(vipune::AddResult::Added { id }) => Ok(id),
427 Ok(vipune::AddResult::Conflicts { .. }) => Ok(String::new()),
428 Err(e) => Err(Error::Store(e.to_string())),
429 }
430 }
431
432 fn search(
433 &mut self,
434 project_id: &str,
435 query: &str,
436 limit: usize,
437 ) -> Result<Vec<SearchResult>, Error> {
438 let memories = self
439 .store
440 .search_hybrid(project_id, query, limit, 0.3)
441 .map_err(|e| Error::Store(e.to_string()))?;
442 Ok(memories
443 .into_iter()
444 .map(|m| SearchResult {
445 id: m.id,
446 meta: m.metadata.as_deref().and_then(parse_meta),
447 content: m.content,
448 similarity: m.similarity,
449 // VipuneStore has no FTS5 — callers fall back to a bounded
450 // client-side prefix of `content`.
451 snippet: None,
452 })
453 .collect())
454 }
455
456 fn delete_by_session(&mut self, project_id: &str, session_id: &str) -> Result<usize, Error> {
457 let entries = self
458 .store
459 .list(project_id, 10_000)
460 .map_err(|e| Error::Store(e.to_string()))?;
461 let mut count = 0;
462 for entry in entries {
463 if let Some(meta) = entry.metadata.as_deref().and_then(parse_meta) {
464 if meta.source == "oo" && meta.session == session_id {
465 self.store
466 .delete(&entry.id)
467 .map_err(|e| Error::Store(e.to_string()))?;
468 count += 1;
469 }
470 }
471 }
472 Ok(count)
473 }
474
475 fn cleanup_stale(&mut self, project_id: &str, max_age_secs: i64) -> Result<usize, Error> {
476 let now = util::now_epoch();
477 let entries = self
478 .store
479 .list(project_id, 10_000)
480 .map_err(|e| Error::Store(e.to_string()))?;
481 let mut count = 0;
482 for entry in entries {
483 if let Some(meta) = entry.metadata.as_deref().and_then(parse_meta) {
484 if meta.source == "oo" && (now - meta.timestamp) > max_age_secs {
485 self.store
486 .delete(&entry.id)
487 .map_err(|e| Error::Store(e.to_string()))?;
488 count += 1;
489 }
490 }
491 }
492 Ok(count)
493 }
494}
495
496// ---------------------------------------------------------------------------
497// Helpers
498// ---------------------------------------------------------------------------
499
500fn parse_meta(json: &str) -> Option<SessionMeta> {
501 serde_json::from_str(json).ok()
502}
503
504/// Open the default store (SqliteStore, or VipuneStore if feature-enabled).
505pub fn open() -> Result<Box<dyn Store>, Error> {
506 #[cfg(feature = "vipune-store")]
507 {
508 return Ok(Box::new(VipuneStore::open()?));
509 }
510 #[cfg(not(feature = "vipune-store"))]
511 {
512 Ok(Box::new(SqliteStore::open()?))
513 }
514}
515
516// ---------------------------------------------------------------------------
517// Tests
518// ---------------------------------------------------------------------------
519
520#[cfg(test)]
521#[path = "store_tests.rs"]
522mod tests;