Skip to main content

lean_ctx/core/
archive_fts.rs

1use rusqlite::{params, Connection};
2use std::path::PathBuf;
3use std::sync::Mutex;
4
5use super::data_dir::lean_ctx_data_dir;
6
7static DB: std::sync::LazyLock<Mutex<Option<Connection>>> =
8    std::sync::LazyLock::new(|| Mutex::new(open_db()));
9
10fn db_path() -> PathBuf {
11    lean_ctx_data_dir()
12        .unwrap_or_else(|_| PathBuf::from(".lean-ctx"))
13        .join("archives")
14        .join("index.db")
15}
16
17fn open_db() -> Option<Connection> {
18    let path = db_path();
19    if let Some(parent) = path.parent() {
20        let _ = std::fs::create_dir_all(parent);
21    }
22    let conn = Connection::open(&path).ok()?;
23    conn.execute_batch(
24        "PRAGMA journal_mode=WAL;
25         PRAGMA synchronous=NORMAL;
26         CREATE TABLE IF NOT EXISTS archive_meta (
27             archive_id TEXT PRIMARY KEY,
28             tool TEXT NOT NULL,
29             command TEXT NOT NULL,
30             created_at TEXT NOT NULL
31         );
32         CREATE VIRTUAL TABLE IF NOT EXISTS archive_fts USING fts5(
33             tool,
34             command,
35             content,
36             archive_id UNINDEXED
37         );",
38    )
39    .ok()?;
40    Some(conn)
41}
42
43pub fn index_entry(archive_id: &str, tool: &str, command: &str, content: &str) {
44    let guard = DB.lock().ok();
45    let Some(conn) = guard.as_ref().and_then(|g| g.as_ref()) else {
46        return;
47    };
48
49    let exists: bool = conn
50        .query_row(
51            "SELECT 1 FROM archive_meta WHERE archive_id = ?1",
52            params![archive_id],
53            |_| Ok(true),
54        )
55        .unwrap_or(false);
56
57    if exists {
58        return;
59    }
60
61    let created_at = chrono::Utc::now().to_rfc3339();
62    let _ = conn.execute(
63        "INSERT OR IGNORE INTO archive_meta (archive_id, tool, command, created_at) VALUES (?1, ?2, ?3, ?4)",
64        params![archive_id, tool, command, created_at],
65    );
66    let _ = conn.execute(
67        "INSERT INTO archive_fts (archive_id, tool, command, content) VALUES (?1, ?2, ?3, ?4)",
68        params![archive_id, tool, command, content],
69    );
70}
71
72pub fn remove_entry(archive_id: &str) {
73    let guard = DB.lock().ok();
74    let Some(conn) = guard.as_ref().and_then(|g| g.as_ref()) else {
75        return;
76    };
77    let _ = conn.execute(
78        "DELETE FROM archive_meta WHERE archive_id = ?1",
79        params![archive_id],
80    );
81    let _ = conn.execute(
82        "DELETE FROM archive_fts WHERE archive_id = ?1",
83        params![archive_id],
84    );
85}
86
87#[derive(Debug, Clone)]
88pub struct FtsResult {
89    pub archive_id: String,
90    pub tool: String,
91    pub command: String,
92    pub snippet: String,
93    pub rank: f64,
94}
95
96pub fn search(query: &str, limit: usize) -> Vec<FtsResult> {
97    let guard = DB.lock().ok();
98    let Some(conn) = guard.as_ref().and_then(|g| g.as_ref()) else {
99        return Vec::new();
100    };
101
102    let Ok(mut stmt) = conn.prepare(
103        "SELECT archive_id, tool, command, snippet(archive_fts, 2, '»', '«', '…', 40), rank
104         FROM archive_fts
105         WHERE archive_fts MATCH ?1
106         ORDER BY rank
107         LIMIT ?2",
108    ) else {
109        return Vec::new();
110    };
111
112    let results = stmt
113        .query_map(params![query, limit as i64], |row| {
114            Ok(FtsResult {
115                archive_id: row.get(0)?,
116                tool: row.get(1)?,
117                command: row.get(2)?,
118                snippet: row.get(3)?,
119                rank: row.get(4)?,
120            })
121        })
122        .ok()
123        .map(|rows| rows.flatten().collect::<Vec<_>>())
124        .unwrap_or_default();
125
126    results
127}
128
129pub fn entry_count() -> usize {
130    let guard = DB.lock().ok();
131    let Some(conn) = guard.as_ref().and_then(|g| g.as_ref()) else {
132        return 0;
133    };
134    conn.query_row("SELECT COUNT(*) FROM archive_meta", [], |row| {
135        row.get::<_, i64>(0)
136    })
137    .unwrap_or(0) as usize
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn fts_roundtrip() {
146        let _lock = crate::core::data_dir::test_env_lock();
147        let tmp = tempfile::tempdir().unwrap();
148        std::env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
149
150        // Force re-open by directly testing open_db
151        let conn = open_db().expect("should open");
152        conn.execute(
153            "INSERT INTO archive_meta (archive_id, tool, command, created_at) VALUES ('t1', 'shell', 'git log', '2026-01-01')",
154            [],
155        ).unwrap();
156        conn.execute(
157            "INSERT INTO archive_fts (archive_id, tool, command, content) VALUES ('t1', 'shell', 'git log', 'commit abc refactored the parser module')",
158            [],
159        ).unwrap();
160
161        let mut stmt = conn
162            .prepare("SELECT archive_id FROM archive_fts WHERE archive_fts MATCH 'parser'")
163            .unwrap();
164        let ids: Vec<String> = stmt
165            .query_map([], |row| row.get(0))
166            .unwrap()
167            .flatten()
168            .collect();
169        assert_eq!(ids, vec!["t1"]);
170    }
171}