Skip to main content

nex_core/
index_store.rs

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}