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 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}