1use rusqlite::{params, Connection};
11use std::path::PathBuf;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14pub struct HistoryEngine {
15 conn: Connection,
16}
17
18#[derive(Debug, Clone)]
19pub struct HistoryEntry {
20 pub id: i64,
21 pub command: String,
22 pub timestamp: i64,
23 pub duration_ms: Option<i64>,
24 pub exit_code: Option<i32>,
25 pub cwd: Option<String>,
26 pub frequency: u32,
27}
28
29impl HistoryEngine {
30 pub fn new() -> rusqlite::Result<Self> {
31 let path = Self::db_path();
32 if let Some(parent) = path.parent() {
33 std::fs::create_dir_all(parent).ok();
34 }
35
36 let conn = Connection::open(&path)?;
37 let engine = Self { conn };
38 engine.init_schema()?;
39 let count = engine.count().unwrap_or(0);
40 let db_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
41 tracing::info!(
42 entries = count,
43 db_bytes = db_size,
44 path = %path.display(),
45 "history: sqlite opened"
46 );
47 Ok(engine)
48 }
49
50 pub fn in_memory() -> rusqlite::Result<Self> {
51 let conn = Connection::open_in_memory()?;
52 let engine = Self { conn };
53 engine.init_schema()?;
54 Ok(engine)
55 }
56
57 fn db_path() -> PathBuf {
58 dirs::data_dir()
59 .unwrap_or_else(|| PathBuf::from("."))
60 .join("zshrs")
61 .join("history.db")
62 }
63
64 fn init_schema(&self) -> rusqlite::Result<()> {
65 self.conn.execute_batch(r#"
66 CREATE TABLE IF NOT EXISTS history (
67 id INTEGER PRIMARY KEY,
68 command TEXT NOT NULL,
69 timestamp INTEGER NOT NULL,
70 duration_ms INTEGER,
71 exit_code INTEGER,
72 cwd TEXT,
73 frequency INTEGER DEFAULT 1
74 );
75
76 CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp DESC);
77 CREATE INDEX IF NOT EXISTS idx_history_cwd ON history(cwd);
78 CREATE UNIQUE INDEX IF NOT EXISTS idx_history_command ON history(command);
79
80 CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5(
81 command,
82 content='history',
83 content_rowid='id',
84 tokenize='trigram'
85 );
86
87 CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
88 INSERT INTO history_fts(rowid, command) VALUES (new.id, new.command);
89 END;
90
91 CREATE TRIGGER IF NOT EXISTS history_ad AFTER DELETE ON history BEGIN
92 INSERT INTO history_fts(history_fts, rowid, command) VALUES('delete', old.id, old.command);
93 END;
94
95 CREATE TRIGGER IF NOT EXISTS history_au AFTER UPDATE ON history BEGIN
96 INSERT INTO history_fts(history_fts, rowid, command) VALUES('delete', old.id, old.command);
97 INSERT INTO history_fts(rowid, command) VALUES (new.id, new.command);
98 END;
99 "#)?;
100 Ok(())
101 }
102
103 fn now() -> i64 {
104 SystemTime::now()
105 .duration_since(UNIX_EPOCH)
106 .map(|d| d.as_secs() as i64)
107 .unwrap_or(0)
108 }
109
110 pub fn add(&self, command: &str, cwd: Option<&str>) -> rusqlite::Result<i64> {
112 let command = command.trim();
113 if command.is_empty() || command.starts_with(' ') {
114 return Ok(0);
115 }
116
117 let now = Self::now();
118
119 let updated = self.conn.execute(
121 "UPDATE history SET timestamp = ?1, frequency = frequency + 1, cwd = COALESCE(?2, cwd)
122 WHERE command = ?3",
123 params![now, cwd, command],
124 )?;
125
126 if updated > 0 {
127 let id: i64 = self.conn.query_row(
129 "SELECT id FROM history WHERE command = ?1",
130 params![command],
131 |row| row.get(0),
132 )?;
133 return Ok(id);
134 }
135
136 self.conn.execute(
138 "INSERT INTO history (command, timestamp, cwd) VALUES (?1, ?2, ?3)",
139 params![command, now, cwd],
140 )?;
141
142 Ok(self.conn.last_insert_rowid())
143 }
144
145 pub fn update_last(&self, id: i64, duration_ms: i64, exit_code: i32) -> rusqlite::Result<()> {
147 self.conn.execute(
148 "UPDATE history SET duration_ms = ?1, exit_code = ?2 WHERE id = ?3",
149 params![duration_ms, exit_code, id],
150 )?;
151 Ok(())
152 }
153
154 pub fn search(&self, query: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
156 if query.is_empty() {
157 return self.recent(limit);
158 }
159
160 let escaped = query.replace('"', "\"\"");
162 let fts_query = format!("\"{}\"*", escaped);
163
164 let mut stmt = self.conn.prepare(
165 r#"SELECT h.id, h.command, h.timestamp, h.duration_ms, h.exit_code, h.cwd, h.frequency
166 FROM history h
167 JOIN history_fts f ON h.id = f.rowid
168 WHERE history_fts MATCH ?1
169 ORDER BY h.frequency DESC, h.timestamp DESC
170 LIMIT ?2"#,
171 )?;
172
173 let entries = stmt.query_map(params![fts_query, limit as i64], |row| {
174 Ok(HistoryEntry {
175 id: row.get(0)?,
176 command: row.get(1)?,
177 timestamp: row.get(2)?,
178 duration_ms: row.get(3)?,
179 exit_code: row.get(4)?,
180 cwd: row.get(5)?,
181 frequency: row.get(6)?,
182 })
183 })?;
184
185 entries.collect()
186 }
187
188 pub fn search_prefix(&self, prefix: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
190 if prefix.is_empty() {
191 return self.recent(limit);
192 }
193
194 let mut stmt = self.conn.prepare(
195 r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
196 FROM history
197 WHERE command LIKE ?1 || '%' ESCAPE '\'
198 ORDER BY timestamp DESC
199 LIMIT ?2"#,
200 )?;
201
202 let escaped = prefix
204 .replace('\\', "\\\\")
205 .replace('%', "\\%")
206 .replace('_', "\\_");
207
208 let entries = stmt.query_map(params![escaped, limit as i64], |row| {
209 Ok(HistoryEntry {
210 id: row.get(0)?,
211 command: row.get(1)?,
212 timestamp: row.get(2)?,
213 duration_ms: row.get(3)?,
214 exit_code: row.get(4)?,
215 cwd: row.get(5)?,
216 frequency: row.get(6)?,
217 })
218 })?;
219
220 entries.collect()
221 }
222
223 pub fn recent(&self, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
225 let mut stmt = self.conn.prepare(
226 r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
227 FROM history
228 ORDER BY timestamp DESC
229 LIMIT ?1"#,
230 )?;
231
232 let entries = stmt.query_map(params![limit as i64], |row| {
233 Ok(HistoryEntry {
234 id: row.get(0)?,
235 command: row.get(1)?,
236 timestamp: row.get(2)?,
237 duration_ms: row.get(3)?,
238 exit_code: row.get(4)?,
239 cwd: row.get(5)?,
240 frequency: row.get(6)?,
241 })
242 })?;
243
244 entries.collect()
245 }
246
247 pub fn for_directory(&self, cwd: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
249 let mut stmt = self.conn.prepare(
250 r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
251 FROM history
252 WHERE cwd = ?1
253 ORDER BY frequency DESC, timestamp DESC
254 LIMIT ?2"#,
255 )?;
256
257 let entries = stmt.query_map(params![cwd, limit as i64], |row| {
258 Ok(HistoryEntry {
259 id: row.get(0)?,
260 command: row.get(1)?,
261 timestamp: row.get(2)?,
262 duration_ms: row.get(3)?,
263 exit_code: row.get(4)?,
264 cwd: row.get(5)?,
265 frequency: row.get(6)?,
266 })
267 })?;
268
269 entries.collect()
270 }
271
272 pub fn delete(&self, id: i64) -> rusqlite::Result<()> {
274 self.conn
275 .execute("DELETE FROM history WHERE id = ?1", params![id])?;
276 Ok(())
277 }
278
279 pub fn clear(&self) -> rusqlite::Result<()> {
281 self.conn.execute("DELETE FROM history", [])?;
282 Ok(())
283 }
284
285 pub fn count(&self) -> rusqlite::Result<i64> {
287 self.conn
288 .query_row("SELECT COUNT(*) FROM history", [], |row| row.get(0))
289 }
290
291 pub fn get_by_offset(&self, offset: usize) -> rusqlite::Result<Option<HistoryEntry>> {
293 let mut stmt = self.conn.prepare(
294 r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
295 FROM history
296 ORDER BY timestamp DESC
297 LIMIT 1 OFFSET ?1"#,
298 )?;
299
300 let mut rows = stmt.query(params![offset as i64])?;
301 if let Some(row) = rows.next()? {
302 Ok(Some(HistoryEntry {
303 id: row.get(0)?,
304 command: row.get(1)?,
305 timestamp: row.get(2)?,
306 duration_ms: row.get(3)?,
307 exit_code: row.get(4)?,
308 cwd: row.get(5)?,
309 frequency: row.get(6)?,
310 }))
311 } else {
312 Ok(None)
313 }
314 }
315
316 pub fn get_by_number(&self, num: i64) -> rusqlite::Result<Option<HistoryEntry>> {
318 let mut stmt = self.conn.prepare(
319 r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
320 FROM history
321 WHERE id = ?1"#,
322 )?;
323
324 let mut rows = stmt.query(params![num])?;
325 if let Some(row) = rows.next()? {
326 Ok(Some(HistoryEntry {
327 id: row.get(0)?,
328 command: row.get(1)?,
329 timestamp: row.get(2)?,
330 duration_ms: row.get(3)?,
331 exit_code: row.get(4)?,
332 cwd: row.get(5)?,
333 frequency: row.get(6)?,
334 }))
335 } else {
336 Ok(None)
337 }
338 }
339}
340
341pub struct ReedlineHistory {
343 engine: HistoryEngine,
344 session_history: Vec<String>,
345 cursor: usize,
346}
347
348impl ReedlineHistory {
349 pub fn new() -> rusqlite::Result<Self> {
350 Ok(Self {
351 engine: HistoryEngine::new()?,
352 session_history: Vec::new(),
353 cursor: 0,
354 })
355 }
356
357 pub fn add(&mut self, command: &str) -> rusqlite::Result<i64> {
358 self.session_history.push(command.to_string());
359 self.cursor = self.session_history.len();
360 let cwd = std::env::current_dir()
361 .ok()
362 .map(|p| p.to_string_lossy().to_string());
363 self.engine.add(command, cwd.as_deref())
364 }
365
366 pub fn search(&self, query: &str) -> Vec<String> {
367 self.engine
368 .search(query, 50)
369 .unwrap_or_default()
370 .into_iter()
371 .map(|e| e.command)
372 .collect()
373 }
374
375 pub fn previous(&mut self, prefix: &str) -> Option<String> {
376 if self.cursor == 0 {
377 return None;
378 }
379
380 for i in (0..self.cursor).rev() {
382 if self.session_history[i].starts_with(prefix) {
383 self.cursor = i;
384 return Some(self.session_history[i].clone());
385 }
386 }
387
388 self.engine
390 .search_prefix(prefix, 1)
391 .ok()
392 .and_then(|v| v.into_iter().next())
393 .map(|e| e.command)
394 }
395
396 pub fn next(&mut self, prefix: &str) -> Option<String> {
397 if self.cursor >= self.session_history.len() {
398 return None;
399 }
400
401 for i in (self.cursor + 1)..self.session_history.len() {
402 if self.session_history[i].starts_with(prefix) {
403 self.cursor = i;
404 return Some(self.session_history[i].clone());
405 }
406 }
407
408 self.cursor = self.session_history.len();
409 None
410 }
411
412 pub fn reset_cursor(&mut self) {
413 self.cursor = self.session_history.len();
414 }
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn test_add_and_search() {
423 let engine = HistoryEngine::in_memory().unwrap();
424
425 engine.add("ls -la", Some("/home/user")).unwrap();
426 engine.add("cd /tmp", Some("/home/user")).unwrap();
427 engine.add("echo hello", Some("/tmp")).unwrap();
428
429 let results = engine.search_prefix("ls", 10).unwrap();
431 assert_eq!(results.len(), 1);
432 assert_eq!(results[0].command, "ls -la");
433 }
434
435 #[test]
436 fn test_frequency_tracking() {
437 let engine = HistoryEngine::in_memory().unwrap();
438
439 engine.add("git status", None).unwrap();
440 engine.add("git status", None).unwrap();
441 engine.add("git status", None).unwrap();
442
443 let results = engine.recent(10).unwrap();
444 assert_eq!(results.len(), 1);
445 assert_eq!(results[0].frequency, 3);
446 }
447
448 #[test]
449 fn test_prefix_search() {
450 let engine = HistoryEngine::in_memory().unwrap();
451
452 engine.add("git status", None).unwrap();
453 engine.add("git commit -m 'test'", None).unwrap();
454 engine.add("grep foo bar", None).unwrap();
455
456 let results = engine.search_prefix("git", 10).unwrap();
457 assert_eq!(results.len(), 2);
458 }
459
460 #[test]
461 fn test_directory_history() {
462 let engine = HistoryEngine::in_memory().unwrap();
463
464 engine.add("make build", Some("/project")).unwrap();
465 engine.add("cargo test", Some("/project")).unwrap();
466 engine.add("ls", Some("/tmp")).unwrap();
467
468 let results = engine.for_directory("/project", 10).unwrap();
469 assert_eq!(results.len(), 2);
470 }
471}