1use std::fmt::{Display, Formatter};
2use std::path::Path;
3
4use rusqlite::{params, Connection};
5
6use crate::config::Config;
7use crate::model::SearchItem;
8
9#[derive(Debug)]
10pub enum StoreError {
11 Io(std::io::Error),
12 Db(rusqlite::Error),
13}
14
15impl Display for StoreError {
16 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
17 match self {
18 Self::Io(error) => write!(f, "io error: {error}"),
19 Self::Db(error) => write!(f, "db error: {error}"),
20 }
21 }
22}
23
24impl std::error::Error for StoreError {}
25
26impl From<std::io::Error> for StoreError {
27 fn from(value: std::io::Error) -> Self {
28 Self::Io(value)
29 }
30}
31
32impl From<rusqlite::Error> for StoreError {
33 fn from(value: rusqlite::Error) -> Self {
34 Self::Db(value)
35 }
36}
37
38pub fn open_memory() -> Result<Connection, StoreError> {
39 let conn = Connection::open_in_memory()?;
40 init_schema(&conn)?;
41 Ok(conn)
42}
43
44pub fn open_file(path: &Path) -> Result<Connection, StoreError> {
45 if let Some(parent) = path.parent() {
46 std::fs::create_dir_all(parent)?;
47 }
48
49 let conn = Connection::open(path)?;
50 init_schema(&conn)?;
51 Ok(conn)
52}
53
54pub fn open_from_config(cfg: &Config) -> Result<Connection, StoreError> {
55 open_file(&cfg.index_db_path)
56}
57
58pub fn upsert_item(db: &Connection, item: &SearchItem) -> Result<(), StoreError> {
59 db.execute(
60 "INSERT INTO item (id, kind, title, path, subtitle, use_count, last_accessed_epoch_secs) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
61 ON CONFLICT(id) DO UPDATE SET kind=excluded.kind, title=excluded.title, path=excluded.path, subtitle=excluded.subtitle,
62 use_count=excluded.use_count, last_accessed_epoch_secs=excluded.last_accessed_epoch_secs",
63 params![
64 item.id,
65 item.kind,
66 item.title,
67 item.path,
68 item.subtitle,
69 item.use_count,
70 item.last_accessed_epoch_secs,
71 ],
72 )?;
73 Ok(())
74}
75
76pub fn get_item(db: &Connection, id: &str) -> Result<Option<SearchItem>, StoreError> {
77 let mut stmt = db.prepare(
78 "SELECT id, kind, title, path, subtitle, use_count, last_accessed_epoch_secs FROM item WHERE id = ?1",
79 )?;
80 let mut rows = stmt.query(params![id])?;
81 if let Some(row) = rows.next()? {
82 let id: String = row.get(0)?;
83 let kind: String = row.get(1)?;
84 let title: String = row.get(2)?;
85 let path: String = row.get(3)?;
86 let subtitle: String = row.get(4)?;
87 let use_count: u32 = row.get(5)?;
88 let last_accessed_epoch_secs: i64 = row.get(6)?;
89 Ok(Some(SearchItem::from_owned_with_subtitle(
90 id,
91 kind,
92 title,
93 path,
94 subtitle,
95 use_count,
96 last_accessed_epoch_secs,
97 )))
98 } else {
99 Ok(None)
100 }
101}
102
103pub fn list_items(db: &Connection) -> Result<Vec<SearchItem>, StoreError> {
104 let mut stmt = db.prepare(
105 "SELECT id, kind, title, path, subtitle, use_count, last_accessed_epoch_secs FROM item ORDER BY id",
106 )?;
107 let mut rows = stmt.query([])?;
108
109 let mut out = Vec::new();
110 while let Some(row) = rows.next()? {
111 let id: String = row.get(0)?;
112 let kind: String = row.get(1)?;
113 let title: String = row.get(2)?;
114 let path: String = row.get(3)?;
115 let subtitle: String = row.get(4)?;
116 let use_count: u32 = row.get(5)?;
117 let last_accessed_epoch_secs: i64 = row.get(6)?;
118 out.push(SearchItem::from_owned_with_subtitle(
119 id,
120 kind,
121 title,
122 path,
123 subtitle,
124 use_count,
125 last_accessed_epoch_secs,
126 ));
127 }
128
129 Ok(out)
130}
131
132pub fn clear_items(db: &Connection) -> Result<(), StoreError> {
133 db.execute("DELETE FROM item", [])?;
134 Ok(())
135}
136
137pub fn delete_item(db: &Connection, id: &str) -> Result<(), StoreError> {
138 db.execute("DELETE FROM item WHERE id = ?1", params![id])?;
139 Ok(())
140}
141
142pub fn get_meta(db: &Connection, key: &str) -> Result<Option<String>, StoreError> {
143 let mut stmt = db.prepare("SELECT value FROM index_meta WHERE key = ?1")?;
144 let mut rows = stmt.query(params![key])?;
145 if let Some(row) = rows.next()? {
146 let value: String = row.get(0)?;
147 Ok(Some(value))
148 } else {
149 Ok(None)
150 }
151}
152
153pub fn set_meta(db: &Connection, key: &str, value: &str) -> Result<(), StoreError> {
154 db.execute(
155 "INSERT INTO index_meta (key, value) VALUES (?1, ?2)
156 ON CONFLICT(key) DO UPDATE SET value=excluded.value",
157 params![key, value],
158 )?;
159 Ok(())
160}
161
162pub fn record_query_selection(
163 db: &Connection,
164 query_norm: &str,
165 mode: &str,
166 item_id: &str,
167 selected_at_epoch_secs: i64,
168) -> Result<(), StoreError> {
169 db.execute(
170 "INSERT INTO item_query_memory (query_norm, mode, item_id, selected_count, last_selected_epoch_secs)
171 VALUES (?1, ?2, ?3, 1, ?4)
172 ON CONFLICT(query_norm, mode, item_id) DO UPDATE SET
173 selected_count = MIN(item_query_memory.selected_count + 1, 1000),
174 last_selected_epoch_secs = excluded.last_selected_epoch_secs",
175 params![query_norm, mode, item_id, selected_at_epoch_secs],
176 )?;
177 Ok(())
178}
179
180pub fn list_query_selections(
181 db: &Connection,
182 query_norm: &str,
183 mode: &str,
184 limit: usize,
185) -> Result<Vec<(String, u32, i64)>, StoreError> {
186 if query_norm.trim().is_empty() || mode.trim().is_empty() || limit == 0 {
187 return Ok(Vec::new());
188 }
189
190 let mut stmt = db.prepare(
191 "SELECT item_id, selected_count, last_selected_epoch_secs
192 FROM item_query_memory
193 WHERE query_norm = ?1 AND mode = ?2
194 ORDER BY selected_count DESC, last_selected_epoch_secs DESC
195 LIMIT ?3",
196 )?;
197 let mut rows = stmt.query(params![query_norm, mode, limit as i64])?;
198
199 let mut out = Vec::new();
200 while let Some(row) = rows.next()? {
201 out.push((row.get(0)?, row.get(1)?, row.get(2)?));
202 }
203 Ok(out)
204}
205
206fn init_schema(conn: &Connection) -> Result<(), StoreError> {
207 let current_version: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
208
209 if current_version < 1 {
210 migration_v1(conn)?;
211 }
212 if current_version < 2 {
213 migration_v2(conn)?;
214 }
215 if current_version < 3 {
216 migration_v3(conn)?;
217 }
218 if current_version < 4 {
219 migration_v4(conn)?;
220 }
221
222 if current_version < 4 {
223 conn.pragma_update(None, "user_version", 4_i64)?;
224 }
225
226 Ok(())
227}
228
229fn migration_v1(conn: &Connection) -> Result<(), StoreError> {
230 conn.execute(
231 "CREATE TABLE IF NOT EXISTS item (
232 id TEXT PRIMARY KEY,
233 kind TEXT NOT NULL,
234 title TEXT NOT NULL,
235 path TEXT NOT NULL,
236 subtitle TEXT NOT NULL DEFAULT '',
237 use_count INTEGER NOT NULL DEFAULT 0,
238 last_accessed_epoch_secs INTEGER NOT NULL DEFAULT 0
239 )",
240 [],
241 )?;
242
243 Ok(())
244}
245
246fn migration_v2(conn: &Connection) -> Result<(), StoreError> {
247 conn.execute(
248 "CREATE TABLE IF NOT EXISTS index_meta (
249 key TEXT PRIMARY KEY,
250 value TEXT NOT NULL
251 )",
252 [],
253 )?;
254 Ok(())
255}
256
257fn migration_v3(conn: &Connection) -> Result<(), StoreError> {
258 conn.execute(
259 "CREATE TABLE IF NOT EXISTS item_query_memory (
260 query_norm TEXT NOT NULL,
261 mode TEXT NOT NULL,
262 item_id TEXT NOT NULL,
263 selected_count INTEGER NOT NULL DEFAULT 0,
264 last_selected_epoch_secs INTEGER NOT NULL DEFAULT 0,
265 PRIMARY KEY(query_norm, mode, item_id)
266 )",
267 [],
268 )?;
269 conn.execute(
270 "CREATE INDEX IF NOT EXISTS idx_item_query_memory_lookup
271 ON item_query_memory(query_norm, mode, selected_count DESC, last_selected_epoch_secs DESC)",
272 [],
273 )?;
274 Ok(())
275}
276
277fn migration_v4(conn: &Connection) -> Result<(), StoreError> {
278 let mut has_subtitle = false;
279 let mut stmt = conn.prepare("PRAGMA table_info(item)")?;
280 let mut rows = stmt.query([])?;
281 while let Some(row) = rows.next()? {
282 let column_name: String = row.get(1)?;
283 if column_name.eq_ignore_ascii_case("subtitle") {
284 has_subtitle = true;
285 break;
286 }
287 }
288
289 if !has_subtitle {
290 conn.execute(
291 "ALTER TABLE item ADD COLUMN subtitle TEXT NOT NULL DEFAULT ''",
292 [],
293 )?;
294 }
295 Ok(())
296}