Skip to main content

rag_rat_core/
storage.rs

1use std::{
2    fs,
3    path::{Path, PathBuf},
4};
5
6use rusqlite::Connection;
7use serde::Serialize;
8
9#[derive(Debug, Clone, Serialize)]
10pub struct StorageStatus {
11    pub backend: &'static str,
12    pub sqlite_version: String,
13    pub fts5_available: bool,
14}
15
16#[derive(Debug)]
17pub struct IndexConnection {
18    conn: Connection,
19    database_path: PathBuf,
20    source_root: Option<PathBuf>,
21}
22
23impl IndexConnection {
24    pub fn open(path: &Path) -> anyhow::Result<Self> {
25        if let Some(parent) = path.parent() {
26            fs::create_dir_all(parent)?;
27        }
28        let conn = Connection::open(path)?;
29        let storage = Self { conn, database_path: path.to_path_buf(), source_root: None };
30        storage.setup()?;
31        Ok(storage)
32    }
33
34    /// Read-only open for latency-critical, never-blocking callers (the grep-augment hook
35    /// fallback). Skips `setup()` — no pragma writes, no dir creation — and refuses to create
36    /// the file. WAL databases serve concurrent read-only opens; a DB that has never been
37    /// opened for write errors here, which callers treat as "no context".
38    pub fn open_read_only(path: &Path) -> anyhow::Result<Self> {
39        use rusqlite::OpenFlags;
40        let conn = Connection::open_with_flags(
41            path,
42            OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
43        )?;
44        conn.busy_timeout(std::time::Duration::from_millis(100))?;
45        Ok(Self { conn, database_path: path.to_path_buf(), source_root: None })
46    }
47
48    pub fn database_path(&self) -> &Path {
49        &self.database_path
50    }
51
52    pub fn connection(&self) -> &Connection {
53        &self.conn
54    }
55
56    pub fn source_root(&self) -> Option<&Path> {
57        self.source_root.as_deref()
58    }
59
60    pub fn set_source_root(&mut self, source_root: PathBuf) {
61        self.source_root = Some(source_root);
62    }
63
64    pub fn execute_batch(&self, sql: &str) -> anyhow::Result<()> {
65        self.conn.execute_batch(sql)?;
66        Ok(())
67    }
68
69    pub fn status(&self) -> anyhow::Result<StorageStatus> {
70        let sqlite_version =
71            self.conn.query_row("SELECT sqlite_version()", [], |row| row.get::<_, String>(0))?;
72        Ok(StorageStatus {
73            backend: "sqlite",
74            sqlite_version,
75            fts5_available: self.fts5_available(),
76        })
77    }
78
79    fn setup(&self) -> anyhow::Result<()> {
80        self.conn.execute_batch(
81            "
82            PRAGMA foreign_keys = ON;
83            PRAGMA journal_mode = WAL;
84            PRAGMA synchronous = NORMAL;
85            -- Wait out a concurrent writer (e.g. the background watcher mid-pass, or a lazy heal
86            -- on the query path) instead of failing with SQLITE_BUSY. WAL allows one writer at a
87            -- time; this serializes them safely without erroring.
88            PRAGMA busy_timeout = 5000;
89            ",
90        )?;
91        Ok(())
92    }
93
94    fn fts5_available(&self) -> bool {
95        self.conn
96            .execute_batch(
97                "
98                CREATE VIRTUAL TABLE temp.rag_rat_fts_probe USING fts5(text);
99                DROP TABLE temp.rag_rat_fts_probe;
100                ",
101            )
102            .is_ok()
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn open_read_only_reads_but_rejects_writes() {
112        let dir = std::env::temp_dir().join(format!("ragrat-ro-{}", std::process::id()));
113        std::fs::create_dir_all(&dir).unwrap();
114        let db = dir.join("index.db");
115        {
116            let rw = IndexConnection::open(&db).unwrap();
117            crate::index::schema::apply(rw.connection()).unwrap();
118        }
119        let ro = IndexConnection::open_read_only(&db).unwrap();
120        let n: i64 =
121            ro.connection().query_row("SELECT COUNT(*) FROM files", [], |row| row.get(0)).unwrap();
122        assert_eq!(n, 0);
123        let err = ro.connection().execute("INSERT INTO index_meta(key, value) VALUES('x','y')", []);
124        assert!(err.is_err(), "read-only connection must reject writes");
125        std::fs::remove_dir_all(&dir).ok();
126    }
127
128    #[test]
129    fn open_read_only_fails_cleanly_when_database_missing() {
130        let missing = std::env::temp_dir().join("ragrat-ro-missing/never-created.db");
131        assert!(IndexConnection::open_read_only(&missing).is_err());
132    }
133}