1use rusqlite::{params, Connection};
22use std::path::PathBuf;
23use std::time::{SystemTime, UNIX_EPOCH};
24use std::io::Write as _;
25use std::io::Read;
26use std::io::Write;
27use std::io::{Seek, SeekFrom};
28
29pub struct HistoryEngine {
35 conn: Connection,
36}
37
38#[derive(Debug, Clone)]
44pub struct HistoryEntry {
45 pub id: i64,
46 pub command: String,
47 pub timestamp: i64,
48 pub duration_ms: Option<i64>,
49 pub exit_code: Option<i32>,
50 pub cwd: Option<String>,
51 pub frequency: u32,
52}
53
54impl HistoryEngine {
55 pub fn new() -> rusqlite::Result<Self> {
56 let path = Self::db_path();
57 if let Some(parent) = path.parent() {
58 std::fs::create_dir_all(parent).ok();
59 }
60
61 if !path.exists() {
74 let prev_inplace = Self::root().join("zshrs_history");
75 if prev_inplace.exists() && is_sqlite_file(&prev_inplace) {
79 match std::fs::rename(&prev_inplace, &path) {
80 Ok(()) => tracing::info!(
81 from = %prev_inplace.display(),
82 to = %path.display(),
83 "history: renamed legacy zshrs_history -> zshrs_history.db"
84 ),
85 Err(e) => tracing::warn!(
86 ?e,
87 "history: rename legacy zshrs_history failed"
88 ),
89 }
90 }
91 }
92 if !path.exists() {
93 if let Some(legacy) = legacy_db_path() {
94 if legacy.exists() {
95 if let Err(e) = std::fs::copy(&legacy, &path) {
96 tracing::warn!(
97 from = %legacy.display(),
98 to = %path.display(),
99 error = %e,
100 "history: migrate from legacy path failed; starting empty"
101 );
102 } else {
103 tracing::info!(
104 from = %legacy.display(),
105 to = %path.display(),
106 "history: migrated from legacy path"
107 );
108 }
109 }
110 }
111 }
112
113 let conn = Connection::open(&path)?;
114 let engine = Self { conn };
115 engine.init_schema()?;
116 let count = engine.count().unwrap_or(0);
117 let db_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
118 tracing::info!(
119 entries = count,
120 db_bytes = db_size,
121 path = %path.display(),
122 "history: sqlite opened"
123 );
124
125 if let Err(e) = engine.rehydrate_text_if_stale() {
131 tracing::warn!(?e, "history: failed to rehydrate text mirror; continuing");
132 }
133 Ok(engine)
134 }
135
136 pub fn in_memory() -> rusqlite::Result<Self> {
137 let conn = Connection::open_in_memory()?;
138 let engine = Self { conn };
139 engine.init_schema()?;
140 Ok(engine)
141 }
142
143 fn db_path() -> PathBuf {
158 Self::root().join("zshrs_history.db")
159 }
160
161 pub fn text_path() -> PathBuf {
177 Self::root().join("zshrs_history")
178 }
179
180 fn root() -> PathBuf {
181 if let Some(custom) = std::env::var_os("ZSHRS_HOME") {
182 PathBuf::from(custom)
183 } else {
184 dirs::home_dir()
185 .unwrap_or_else(|| PathBuf::from("."))
186 .join(".zshrs")
187 }
188 }
189
190 fn init_schema(&self) -> rusqlite::Result<()> {
191 self.conn.execute_batch(r#"
192 CREATE TABLE IF NOT EXISTS history (
193 id INTEGER PRIMARY KEY,
194 command TEXT NOT NULL,
195 timestamp INTEGER NOT NULL,
196 duration_ms INTEGER,
197 exit_code INTEGER,
198 cwd TEXT,
199 frequency INTEGER DEFAULT 1
200 );
201
202 CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp DESC);
203 CREATE INDEX IF NOT EXISTS idx_history_cwd ON history(cwd);
204 CREATE UNIQUE INDEX IF NOT EXISTS idx_history_command ON history(command);
205
206 CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5(
207 command,
208 content='history',
209 content_rowid='id',
210 tokenize='trigram'
211 );
212
213 CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
214 INSERT INTO history_fts(rowid, command) VALUES (new.id, new.command);
215 END;
216
217 CREATE TRIGGER IF NOT EXISTS history_ad AFTER DELETE ON history BEGIN
218 INSERT INTO history_fts(history_fts, rowid, command) VALUES('delete', old.id, old.command);
219 END;
220
221 CREATE TRIGGER IF NOT EXISTS history_au AFTER UPDATE ON history BEGIN
222 INSERT INTO history_fts(history_fts, rowid, command) VALUES('delete', old.id, old.command);
223 INSERT INTO history_fts(rowid, command) VALUES (new.id, new.command);
224 END;
225 "#)?;
226 Ok(())
227 }
228
229 fn now() -> i64 {
230 SystemTime::now()
231 .duration_since(UNIX_EPOCH)
232 .map(|d| d.as_secs() as i64)
233 .unwrap_or(0)
234 }
235
236 pub fn add(&self, command: &str, cwd: Option<&str>) -> rusqlite::Result<i64> {
238 let command = command.trim();
239 if command.is_empty() || command.starts_with(' ') {
240 return Ok(0);
241 }
242
243 let now = Self::now();
244
245 let updated = self.conn.execute(
247 "UPDATE history SET timestamp = ?1, frequency = frequency + 1, cwd = COALESCE(?2, cwd)
248 WHERE command = ?3",
249 params![now, cwd, command],
250 )?;
251
252 if updated > 0 {
253 let id: i64 = self.conn.query_row(
255 "SELECT id FROM history WHERE command = ?1",
256 params![command],
257 |row| row.get(0),
258 )?;
259 return Ok(id);
260 }
261
262 self.conn.execute(
264 "INSERT INTO history (command, timestamp, cwd) VALUES (?1, ?2, ?3)",
265 params![command, now, cwd],
266 )?;
267
268 let id = self.conn.last_insert_rowid();
269
270 if let Err(e) = append_text_line(now, 0, command) {
276 tracing::warn!(?e, "history: text mirror append failed");
277 }
278
279 Ok(id)
280 }
281
282 pub fn update_last(&self, id: i64, duration_ms: i64, exit_code: i32) -> rusqlite::Result<()> {
284 self.conn.execute(
285 "UPDATE history SET duration_ms = ?1, exit_code = ?2 WHERE id = ?3",
286 params![duration_ms, exit_code, id],
287 )?;
288
289 if let Ok((ts, command)) = self.conn.query_row(
293 "SELECT timestamp, command FROM history WHERE id = ?1",
294 params![id],
295 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
296 ) {
297 let duration_secs = (duration_ms / 1000).max(0);
298 if let Err(e) = rewrite_last_text_line(ts, duration_secs, &command) {
299 tracing::warn!(?e, "history: text mirror update failed");
300 }
301 }
302 Ok(())
303 }
304
305 fn rehydrate_text_if_stale(&self) -> rusqlite::Result<()> {
310 let text = Self::text_path();
311 let text_size = std::fs::metadata(&text).map(|m| m.len()).unwrap_or(0);
312 if text_size > 0 {
313 return Ok(());
314 }
315 let count: i64 = self.conn.query_row("SELECT COUNT(*) FROM history", [], |r| r.get(0))?;
316 if count == 0 {
317 return Ok(());
318 }
319 let mut stmt = self.conn.prepare(
320 "SELECT timestamp, COALESCE(duration_ms, 0), command \
321 FROM history ORDER BY timestamp ASC, id ASC",
322 )?;
323 let rows = stmt.query_map([], |r| {
324 Ok((
325 r.get::<_, i64>(0)?,
326 r.get::<_, i64>(1)?,
327 r.get::<_, String>(2)?,
328 ))
329 })?;
330 let file = std::fs::OpenOptions::new()
331 .create(true)
332 .truncate(true)
333 .write(true)
334 .open(&text)
335 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
336 let mut w = std::io::BufWriter::new(file);
337 let mut written: u64 = 0;
338 for row in rows {
339 let (ts, dur_ms, cmd) = row?;
340 let line = format_text_line(ts, (dur_ms / 1000).max(0), &cmd);
341 w.write_all(line.as_bytes())
342 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
343 written += 1;
344 }
345 w.flush()
346 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
347 tracing::info!(
348 entries = written,
349 path = %text.display(),
350 "history: rehydrated text mirror from sqlite index"
351 );
352 Ok(())
353 }
354
355 pub fn search(&self, query: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
357 if query.is_empty() {
358 return self.recent(limit);
359 }
360
361 let escaped = query.replace('"', "\"\"");
363 let fts_query = format!("\"{}\"*", escaped);
364
365 let mut stmt = self.conn.prepare(
366 r#"SELECT h.id, h.command, h.timestamp, h.duration_ms, h.exit_code, h.cwd, h.frequency
367 FROM history h
368 JOIN history_fts f ON h.id = f.rowid
369 WHERE history_fts MATCH ?1
370 ORDER BY h.frequency DESC, h.timestamp DESC
371 LIMIT ?2"#,
372 )?;
373
374 let entries = stmt.query_map(params![fts_query, limit as i64], |row| {
375 Ok(HistoryEntry {
376 id: row.get(0)?,
377 command: row.get(1)?,
378 timestamp: row.get(2)?,
379 duration_ms: row.get(3)?,
380 exit_code: row.get(4)?,
381 cwd: row.get(5)?,
382 frequency: row.get(6)?,
383 })
384 })?;
385
386 entries.collect()
387 }
388
389 pub fn search_prefix(&self, prefix: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
391 if prefix.is_empty() {
392 return self.recent(limit);
393 }
394
395 let mut stmt = self.conn.prepare(
396 r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
397 FROM history
398 WHERE command LIKE ?1 || '%' ESCAPE '\'
399 ORDER BY timestamp DESC
400 LIMIT ?2"#,
401 )?;
402
403 let escaped = prefix
405 .replace('\\', "\\\\")
406 .replace('%', "\\%")
407 .replace('_', "\\_");
408
409 let entries = stmt.query_map(params![escaped, limit as i64], |row| {
410 Ok(HistoryEntry {
411 id: row.get(0)?,
412 command: row.get(1)?,
413 timestamp: row.get(2)?,
414 duration_ms: row.get(3)?,
415 exit_code: row.get(4)?,
416 cwd: row.get(5)?,
417 frequency: row.get(6)?,
418 })
419 })?;
420
421 entries.collect()
422 }
423
424 pub fn recent(&self, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
426 let mut stmt = self.conn.prepare(
427 r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
428 FROM history
429 ORDER BY timestamp DESC
430 LIMIT ?1"#,
431 )?;
432
433 let entries = stmt.query_map(params![limit as i64], |row| {
434 Ok(HistoryEntry {
435 id: row.get(0)?,
436 command: row.get(1)?,
437 timestamp: row.get(2)?,
438 duration_ms: row.get(3)?,
439 exit_code: row.get(4)?,
440 cwd: row.get(5)?,
441 frequency: row.get(6)?,
442 })
443 })?;
444
445 entries.collect()
446 }
447
448 pub fn for_directory(&self, cwd: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
450 let mut stmt = self.conn.prepare(
451 r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
452 FROM history
453 WHERE cwd = ?1
454 ORDER BY frequency DESC, timestamp DESC
455 LIMIT ?2"#,
456 )?;
457
458 let entries = stmt.query_map(params![cwd, limit as i64], |row| {
459 Ok(HistoryEntry {
460 id: row.get(0)?,
461 command: row.get(1)?,
462 timestamp: row.get(2)?,
463 duration_ms: row.get(3)?,
464 exit_code: row.get(4)?,
465 cwd: row.get(5)?,
466 frequency: row.get(6)?,
467 })
468 })?;
469
470 entries.collect()
471 }
472
473 pub fn delete(&self, id: i64) -> rusqlite::Result<()> {
475 self.conn
476 .execute("DELETE FROM history WHERE id = ?1", params![id])?;
477 Ok(())
478 }
479
480 pub fn clear(&self) -> rusqlite::Result<()> {
482 self.conn.execute("DELETE FROM history", [])?;
483 Ok(())
484 }
485
486 pub fn count(&self) -> rusqlite::Result<i64> {
488 self.conn
489 .query_row("SELECT COUNT(*) FROM history", [], |row| row.get(0))
490 }
491
492 pub fn get_by_offset(&self, offset: usize) -> rusqlite::Result<Option<HistoryEntry>> {
494 let mut stmt = self.conn.prepare(
495 r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
496 FROM history
497 ORDER BY timestamp DESC
498 LIMIT 1 OFFSET ?1"#,
499 )?;
500
501 let mut rows = stmt.query(params![offset as i64])?;
502 if let Some(row) = rows.next()? {
503 Ok(Some(HistoryEntry {
504 id: row.get(0)?,
505 command: row.get(1)?,
506 timestamp: row.get(2)?,
507 duration_ms: row.get(3)?,
508 exit_code: row.get(4)?,
509 cwd: row.get(5)?,
510 frequency: row.get(6)?,
511 }))
512 } else {
513 Ok(None)
514 }
515 }
516
517 pub fn get_by_number(&self, num: i64) -> rusqlite::Result<Option<HistoryEntry>> {
519 let mut stmt = self.conn.prepare(
520 r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
521 FROM history
522 WHERE id = ?1"#,
523 )?;
524
525 let mut rows = stmt.query(params![num])?;
526 if let Some(row) = rows.next()? {
527 Ok(Some(HistoryEntry {
528 id: row.get(0)?,
529 command: row.get(1)?,
530 timestamp: row.get(2)?,
531 duration_ms: row.get(3)?,
532 exit_code: row.get(4)?,
533 cwd: row.get(5)?,
534 frequency: row.get(6)?,
535 }))
536 } else {
537 Ok(None)
538 }
539 }
540}
541
542fn legacy_db_path() -> Option<PathBuf> {
547 Some(dirs::data_dir()?.join("zshrs").join("history.db"))
548}
549
550fn is_sqlite_file(path: &std::path::Path) -> bool {
556 let mut f = match std::fs::File::open(path) {
557 Ok(f) => f,
558 Err(_) => return false,
559 };
560 let mut header = [0u8; 16];
561 if f.read_exact(&mut header).is_err() {
562 return false;
563 }
564 &header == b"SQLite format 3\0"
565}
566
567fn format_text_line(ts: i64, duration_secs: i64, command: &str) -> String {
578 let escaped = command.replace('\\', "\\\\").replace('\n', "\\\n");
579 format!(": {}:{};{}\n", ts, duration_secs, escaped)
580}
581
582fn append_text_line(ts: i64, duration_secs: i64, command: &str) -> std::io::Result<()> {
584 let path = HistoryEngine::text_path();
585 if let Some(parent) = path.parent() {
586 std::fs::create_dir_all(parent).ok();
587 }
588 let line = format_text_line(ts, duration_secs, command);
589 let mut f = std::fs::OpenOptions::new()
590 .create(true)
591 .append(true)
592 .open(&path)?;
593 f.write_all(line.as_bytes())
594}
595
596fn rewrite_last_text_line(ts: i64, duration_secs: i64, command: &str) -> std::io::Result<()> {
603 let path = HistoryEngine::text_path();
604 let mut f = std::fs::OpenOptions::new().read(true).write(true).open(&path)?;
605 let len = f.metadata()?.len();
606 let max_tail = 65_536u64.min(len);
610 let read_from = len - max_tail;
611 f.seek(SeekFrom::Start(read_from))?;
612 let mut tail = Vec::with_capacity(max_tail as usize);
613 f.read_to_end(&mut tail)?;
614 let mut last_record_start = 0usize;
618 let mut nl_count = 0;
619 for (i, b) in tail.iter().enumerate().rev() {
620 if *b == b'\n' {
621 nl_count += 1;
622 if nl_count == 2 {
623 last_record_start = i + 1;
624 break;
625 }
626 }
627 }
628 let new_record = format_text_line(ts, duration_secs, command);
629 let new_abs = read_from + last_record_start as u64;
630 f.seek(SeekFrom::Start(new_abs))?;
631 f.write_all(new_record.as_bytes())?;
632 let new_len = new_abs + new_record.len() as u64;
633 if new_len < len {
634 f.set_len(new_len)?;
635 }
636 Ok(())
637}
638
639pub struct ReedlineHistory {
645 engine: HistoryEngine,
646 session_history: Vec<String>,
647 cursor: usize,
648}
649
650impl ReedlineHistory {
651 pub fn new() -> rusqlite::Result<Self> {
652 Ok(Self {
653 engine: HistoryEngine::new()?,
654 session_history: Vec::new(),
655 cursor: 0,
656 })
657 }
658
659 pub fn add(&mut self, command: &str) -> rusqlite::Result<i64> {
660 self.session_history.push(command.to_string());
661 self.cursor = self.session_history.len();
662 let cwd = std::env::current_dir()
663 .ok()
664 .map(|p| p.to_string_lossy().to_string());
665 self.engine.add(command, cwd.as_deref())
666 }
667
668 pub fn search(&self, query: &str) -> Vec<String> {
669 self.engine
670 .search(query, 50)
671 .unwrap_or_default()
672 .into_iter()
673 .map(|e| e.command)
674 .collect()
675 }
676
677 pub fn previous(&mut self, prefix: &str) -> Option<String> {
678 if self.cursor == 0 {
679 return None;
680 }
681
682 for i in (0..self.cursor).rev() {
684 if self.session_history[i].starts_with(prefix) {
685 self.cursor = i;
686 return Some(self.session_history[i].clone());
687 }
688 }
689
690 self.engine
692 .search_prefix(prefix, 1)
693 .ok()
694 .and_then(|v| v.into_iter().next())
695 .map(|e| e.command)
696 }
697
698 pub fn next(&mut self, prefix: &str) -> Option<String> {
699 if self.cursor >= self.session_history.len() {
700 return None;
701 }
702
703 for i in (self.cursor + 1)..self.session_history.len() {
704 if self.session_history[i].starts_with(prefix) {
705 self.cursor = i;
706 return Some(self.session_history[i].clone());
707 }
708 }
709
710 self.cursor = self.session_history.len();
711 None
712 }
713
714 pub fn reset_cursor(&mut self) {
715 self.cursor = self.session_history.len();
716 }
717}
718
719#[cfg(test)]
720mod tests {
721 use super::*;
722
723 #[test]
724 fn test_add_and_search() {
725 let engine = HistoryEngine::in_memory().unwrap();
726
727 engine.add("ls -la", Some("/home/user")).unwrap();
728 engine.add("cd /tmp", Some("/home/user")).unwrap();
729 engine.add("echo hello", Some("/tmp")).unwrap();
730
731 let results = engine.search_prefix("ls", 10).unwrap();
733 assert_eq!(results.len(), 1);
734 assert_eq!(results[0].command, "ls -la");
735 }
736
737 #[test]
738 fn test_frequency_tracking() {
739 let engine = HistoryEngine::in_memory().unwrap();
740
741 engine.add("git status", None).unwrap();
742 engine.add("git status", None).unwrap();
743 engine.add("git status", None).unwrap();
744
745 let results = engine.recent(10).unwrap();
746 assert_eq!(results.len(), 1);
747 assert_eq!(results[0].frequency, 3);
748 }
749
750 #[test]
751 fn test_prefix_search() {
752 let engine = HistoryEngine::in_memory().unwrap();
753
754 engine.add("git status", None).unwrap();
755 engine.add("git commit -m 'test'", None).unwrap();
756 engine.add("grep foo bar", None).unwrap();
757
758 let results = engine.search_prefix("git", 10).unwrap();
759 assert_eq!(results.len(), 2);
760 }
761
762 #[test]
763 fn test_directory_history() {
764 let engine = HistoryEngine::in_memory().unwrap();
765
766 engine.add("make build", Some("/project")).unwrap();
767 engine.add("cargo test", Some("/project")).unwrap();
768 engine.add("ls", Some("/tmp")).unwrap();
769
770 let results = engine.for_directory("/project", 10).unwrap();
771 assert_eq!(results.len(), 2);
772 }
773}