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