1use rusqlite::{params, Connection};
22use std::io::Read;
23use std::io::Write as _;
24use std::io::Write;
25use std::io::{Seek, SeekFrom};
26use std::path::PathBuf;
27use std::time::{SystemTime, UNIX_EPOCH};
28
29pub struct HistoryEngine {
35 conn: Connection,
37}
38
39#[derive(Debug, Clone)]
45pub struct HistoryEntry {
46 pub id: i64,
48 pub command: String,
50 pub timestamp: i64,
52 pub duration_ms: Option<i64>,
54 pub exit_code: Option<i32>,
56 pub cwd: Option<String>,
58 pub frequency: u32,
60}
61
62impl HistoryEngine {
63 pub fn new() -> rusqlite::Result<Self> {
65 let path = Self::db_path();
66 if let Some(parent) = path.parent() {
67 std::fs::create_dir_all(parent).ok();
68 }
69
70 let conn = Connection::open(&path)?;
71 let engine = Self { conn };
72 engine.init_schema()?;
73 let count = engine.count().unwrap_or(0);
74 let db_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
75 tracing::info!(
76 entries = count,
77 db_bytes = db_size,
78 path = %path.display(),
79 "history: sqlite opened"
80 );
81
82 if let Err(e) = engine.rehydrate_text_if_stale() {
86 tracing::warn!(?e, "history: failed to rehydrate text mirror; continuing");
87 }
88 Ok(engine)
89 }
90 pub fn in_memory() -> rusqlite::Result<Self> {
92 let conn = Connection::open_in_memory()?;
93 let engine = Self { conn };
94 engine.init_schema()?;
95 Ok(engine)
96 }
97
98 fn db_path() -> PathBuf {
110 Self::root().join("zshrs_history.db")
111 }
112
113 pub fn text_path() -> PathBuf {
129 Self::root().join("zshrs_history")
130 }
131
132 fn root() -> PathBuf {
133 if let Some(custom) = std::env::var_os("ZSHRS_HOME") {
134 PathBuf::from(custom)
135 } else {
136 dirs::home_dir()
137 .unwrap_or_else(|| PathBuf::from("."))
138 .join(".zshrs")
139 }
140 }
141
142 fn init_schema(&self) -> rusqlite::Result<()> {
143 self.conn.execute_batch(r#"
144 CREATE TABLE IF NOT EXISTS history (
145 id INTEGER PRIMARY KEY,
146 command TEXT NOT NULL,
147 timestamp INTEGER NOT NULL,
148 duration_ms INTEGER,
149 exit_code INTEGER,
150 cwd TEXT,
151 frequency INTEGER DEFAULT 1
152 );
153
154 CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp DESC);
155 CREATE INDEX IF NOT EXISTS idx_history_cwd ON history(cwd);
156 CREATE UNIQUE INDEX IF NOT EXISTS idx_history_command ON history(command);
157
158 CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5(
159 command,
160 content='history',
161 content_rowid='id',
162 tokenize='trigram'
163 );
164
165 CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
166 INSERT INTO history_fts(rowid, command) VALUES (new.id, new.command);
167 END;
168
169 CREATE TRIGGER IF NOT EXISTS history_ad AFTER DELETE ON history BEGIN
170 INSERT INTO history_fts(history_fts, rowid, command) VALUES('delete', old.id, old.command);
171 END;
172
173 CREATE TRIGGER IF NOT EXISTS history_au AFTER UPDATE ON history BEGIN
174 INSERT INTO history_fts(history_fts, rowid, command) VALUES('delete', old.id, old.command);
175 INSERT INTO history_fts(rowid, command) VALUES (new.id, new.command);
176 END;
177 "#)?;
178 Ok(())
179 }
180
181 fn now() -> i64 {
182 SystemTime::now()
183 .duration_since(UNIX_EPOCH)
184 .map(|d| d.as_secs() as i64)
185 .unwrap_or(0)
186 }
187
188 pub fn add(&self, command: &str, cwd: Option<&str>) -> rusqlite::Result<i64> {
190 let command = command.trim();
191 if command.is_empty() || command.starts_with(' ') {
192 return Ok(0);
193 }
194
195 let now = Self::now();
196
197 let updated = self.conn.execute(
199 "UPDATE history SET timestamp = ?1, frequency = frequency + 1, cwd = COALESCE(?2, cwd)
200 WHERE command = ?3",
201 params![now, cwd, command],
202 )?;
203
204 if updated > 0 {
205 let id: i64 = self.conn.query_row(
207 "SELECT id FROM history WHERE command = ?1",
208 params![command],
209 |row| row.get(0),
210 )?;
211 return Ok(id);
212 }
213
214 self.conn.execute(
216 "INSERT INTO history (command, timestamp, cwd) VALUES (?1, ?2, ?3)",
217 params![command, now, cwd],
218 )?;
219
220 let id = self.conn.last_insert_rowid();
221
222 if let Err(e) = append_text_line(now, 0, command) {
228 tracing::warn!(?e, "history: text mirror append failed");
229 }
230
231 Ok(id)
232 }
233
234 pub fn update_last(&self, id: i64, duration_ms: i64, exit_code: i32) -> rusqlite::Result<()> {
236 self.conn.execute(
237 "UPDATE history SET duration_ms = ?1, exit_code = ?2 WHERE id = ?3",
238 params![duration_ms, exit_code, id],
239 )?;
240
241 if let Ok((ts, command)) = self.conn.query_row(
245 "SELECT timestamp, command FROM history WHERE id = ?1",
246 params![id],
247 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
248 ) {
249 let duration_secs = (duration_ms / 1000).max(0);
250 if let Err(e) = rewrite_last_text_line(ts, duration_secs, &command) {
251 tracing::warn!(?e, "history: text mirror update failed");
252 }
253 }
254 Ok(())
255 }
256
257 fn rehydrate_text_if_stale(&self) -> rusqlite::Result<()> {
262 let text = Self::text_path();
263 let text_size = std::fs::metadata(&text).map(|m| m.len()).unwrap_or(0);
264 if text_size > 0 {
265 return Ok(());
266 }
267 let count: i64 = self
268 .conn
269 .query_row("SELECT COUNT(*) FROM history", [], |r| r.get(0))?;
270 if count == 0 {
271 return Ok(());
272 }
273 let mut stmt = self.conn.prepare(
274 "SELECT timestamp, COALESCE(duration_ms, 0), command \
275 FROM history ORDER BY timestamp ASC, id ASC",
276 )?;
277 let rows = stmt.query_map([], |r| {
278 Ok((
279 r.get::<_, i64>(0)?,
280 r.get::<_, i64>(1)?,
281 r.get::<_, String>(2)?,
282 ))
283 })?;
284 let file = std::fs::OpenOptions::new()
285 .create(true)
286 .truncate(true)
287 .write(true)
288 .open(&text)
289 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
290 let mut w = std::io::BufWriter::new(file);
291 let mut written: u64 = 0;
292 for row in rows {
293 let (ts, dur_ms, cmd) = row?;
294 let line = format_text_line(ts, (dur_ms / 1000).max(0), &cmd);
295 w.write_all(line.as_bytes())
296 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
297 written += 1;
298 }
299 w.flush()
300 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
301 tracing::info!(
302 entries = written,
303 path = %text.display(),
304 "history: rehydrated text mirror from sqlite index"
305 );
306 Ok(())
307 }
308
309 pub fn search(&self, query: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
311 if query.is_empty() {
312 return self.recent(limit);
313 }
314
315 let escaped = query.replace('"', "\"\"");
317 let fts_query = format!("\"{}\"*", escaped);
318
319 let mut stmt = self.conn.prepare(
320 r#"SELECT h.id, h.command, h.timestamp, h.duration_ms, h.exit_code, h.cwd, h.frequency
321 FROM history h
322 JOIN history_fts f ON h.id = f.rowid
323 WHERE history_fts MATCH ?1
324 ORDER BY h.frequency DESC, h.timestamp DESC
325 LIMIT ?2"#,
326 )?;
327
328 let entries = stmt.query_map(params![fts_query, limit as i64], |row| {
329 Ok(HistoryEntry {
330 id: row.get(0)?,
331 command: row.get(1)?,
332 timestamp: row.get(2)?,
333 duration_ms: row.get(3)?,
334 exit_code: row.get(4)?,
335 cwd: row.get(5)?,
336 frequency: row.get(6)?,
337 })
338 })?;
339
340 entries.collect()
341 }
342
343 pub fn search_prefix(&self, prefix: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
345 if prefix.is_empty() {
346 return self.recent(limit);
347 }
348
349 let mut stmt = self.conn.prepare(
350 r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
351 FROM history
352 WHERE command LIKE ?1 || '%' ESCAPE '\'
353 ORDER BY timestamp DESC
354 LIMIT ?2"#,
355 )?;
356
357 let escaped = prefix
359 .replace('\\', "\\\\")
360 .replace('%', "\\%")
361 .replace('_', "\\_");
362
363 let entries = stmt.query_map(params![escaped, limit as i64], |row| {
364 Ok(HistoryEntry {
365 id: row.get(0)?,
366 command: row.get(1)?,
367 timestamp: row.get(2)?,
368 duration_ms: row.get(3)?,
369 exit_code: row.get(4)?,
370 cwd: row.get(5)?,
371 frequency: row.get(6)?,
372 })
373 })?;
374
375 entries.collect()
376 }
377
378 pub fn recent(&self, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
380 let mut stmt = self.conn.prepare(
381 r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
382 FROM history
383 ORDER BY timestamp DESC
384 LIMIT ?1"#,
385 )?;
386
387 let entries = stmt.query_map(params![limit as i64], |row| {
388 Ok(HistoryEntry {
389 id: row.get(0)?,
390 command: row.get(1)?,
391 timestamp: row.get(2)?,
392 duration_ms: row.get(3)?,
393 exit_code: row.get(4)?,
394 cwd: row.get(5)?,
395 frequency: row.get(6)?,
396 })
397 })?;
398
399 entries.collect()
400 }
401
402 pub fn for_directory(&self, cwd: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
404 let mut stmt = self.conn.prepare(
405 r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
406 FROM history
407 WHERE cwd = ?1
408 ORDER BY frequency DESC, timestamp DESC
409 LIMIT ?2"#,
410 )?;
411
412 let entries = stmt.query_map(params![cwd, limit as i64], |row| {
413 Ok(HistoryEntry {
414 id: row.get(0)?,
415 command: row.get(1)?,
416 timestamp: row.get(2)?,
417 duration_ms: row.get(3)?,
418 exit_code: row.get(4)?,
419 cwd: row.get(5)?,
420 frequency: row.get(6)?,
421 })
422 })?;
423
424 entries.collect()
425 }
426
427 pub fn delete(&self, id: i64) -> rusqlite::Result<()> {
429 self.conn
430 .execute("DELETE FROM history WHERE id = ?1", params![id])?;
431 Ok(())
432 }
433
434 pub fn clear(&self) -> rusqlite::Result<()> {
436 self.conn.execute("DELETE FROM history", [])?;
437 Ok(())
438 }
439
440 pub fn count(&self) -> rusqlite::Result<i64> {
442 self.conn
443 .query_row("SELECT COUNT(*) FROM history", [], |row| row.get(0))
444 }
445
446 pub fn get_by_offset(&self, offset: usize) -> rusqlite::Result<Option<HistoryEntry>> {
448 let mut stmt = self.conn.prepare(
449 r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
450 FROM history
451 ORDER BY timestamp DESC
452 LIMIT 1 OFFSET ?1"#,
453 )?;
454
455 let mut rows = stmt.query(params![offset as i64])?;
456 if let Some(row) = rows.next()? {
457 Ok(Some(HistoryEntry {
458 id: row.get(0)?,
459 command: row.get(1)?,
460 timestamp: row.get(2)?,
461 duration_ms: row.get(3)?,
462 exit_code: row.get(4)?,
463 cwd: row.get(5)?,
464 frequency: row.get(6)?,
465 }))
466 } else {
467 Ok(None)
468 }
469 }
470
471 pub fn get_by_number(&self, num: i64) -> rusqlite::Result<Option<HistoryEntry>> {
473 let mut stmt = self.conn.prepare(
474 r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
475 FROM history
476 WHERE id = ?1"#,
477 )?;
478
479 let mut rows = stmt.query(params![num])?;
480 if let Some(row) = rows.next()? {
481 Ok(Some(HistoryEntry {
482 id: row.get(0)?,
483 command: row.get(1)?,
484 timestamp: row.get(2)?,
485 duration_ms: row.get(3)?,
486 exit_code: row.get(4)?,
487 cwd: row.get(5)?,
488 frequency: row.get(6)?,
489 }))
490 } else {
491 Ok(None)
492 }
493 }
494}
495
496fn is_sqlite_file(path: &std::path::Path) -> bool {
501 let mut f = match std::fs::File::open(path) {
502 Ok(f) => f,
503 Err(_) => return false,
504 };
505 let mut header = [0u8; 16];
506 if f.read_exact(&mut header).is_err() {
507 return false;
508 }
509 &header == b"SQLite format 3\0"
510}
511
512fn format_text_line(ts: i64, duration_secs: i64, command: &str) -> String {
523 let escaped = command.replace('\\', "\\\\").replace('\n', "\\\n");
524 format!(": {}:{};{}\n", ts, duration_secs, escaped)
525}
526
527fn append_text_line(ts: i64, duration_secs: i64, command: &str) -> std::io::Result<()> {
529 let path = HistoryEngine::text_path();
530 if let Some(parent) = path.parent() {
531 std::fs::create_dir_all(parent).ok();
532 }
533 let line = format_text_line(ts, duration_secs, command);
534 let mut f = std::fs::OpenOptions::new()
535 .create(true)
536 .append(true)
537 .open(&path)?;
538 f.write_all(line.as_bytes())
539}
540
541fn rewrite_last_text_line(ts: i64, duration_secs: i64, command: &str) -> std::io::Result<()> {
548 let path = HistoryEngine::text_path();
549 let mut f = std::fs::OpenOptions::new()
550 .read(true)
551 .write(true)
552 .open(&path)?;
553 let len = f.metadata()?.len();
554 let max_tail = 65_536u64.min(len);
558 let read_from = len - max_tail;
559 f.seek(SeekFrom::Start(read_from))?;
560 let mut tail = Vec::with_capacity(max_tail as usize);
561 f.read_to_end(&mut tail)?;
562 let mut last_record_start = 0usize;
566 let mut nl_count = 0;
567 for (i, b) in tail.iter().enumerate().rev() {
568 if *b == b'\n' {
569 nl_count += 1;
570 if nl_count == 2 {
571 last_record_start = i + 1;
572 break;
573 }
574 }
575 }
576 let new_record = format_text_line(ts, duration_secs, command);
577 let new_abs = read_from + last_record_start as u64;
578 f.seek(SeekFrom::Start(new_abs))?;
579 f.write_all(new_record.as_bytes())?;
580 let new_len = new_abs + new_record.len() as u64;
581 if new_len < len {
582 f.set_len(new_len)?;
583 }
584 Ok(())
585}
586
587#[cfg(test)]
588mod tests {
589 use super::*;
590
591 #[test]
592 fn test_add_and_search() {
593 let _g = crate::test_util::global_state_lock();
594 let engine = HistoryEngine::in_memory().unwrap();
595
596 engine.add("ls -la", Some("/home/user")).unwrap();
597 engine.add("cd /tmp", Some("/home/user")).unwrap();
598 engine.add("echo hello", Some("/tmp")).unwrap();
599
600 let results = engine.search_prefix("ls", 10).unwrap();
602 assert_eq!(results.len(), 1);
603 assert_eq!(results[0].command, "ls -la");
604 }
605
606 #[test]
607 fn test_frequency_tracking() {
608 let _g = crate::test_util::global_state_lock();
609 let engine = HistoryEngine::in_memory().unwrap();
610
611 engine.add("git status", None).unwrap();
612 engine.add("git status", None).unwrap();
613 engine.add("git status", None).unwrap();
614
615 let results = engine.recent(10).unwrap();
616 assert_eq!(results.len(), 1);
617 assert_eq!(results[0].frequency, 3);
618 }
619
620 #[test]
621 fn test_prefix_search() {
622 let _g = crate::test_util::global_state_lock();
623 let engine = HistoryEngine::in_memory().unwrap();
624
625 engine.add("git status", None).unwrap();
626 engine.add("git commit -m 'test'", None).unwrap();
627 engine.add("grep foo bar", None).unwrap();
628
629 let results = engine.search_prefix("git", 10).unwrap();
630 assert_eq!(results.len(), 2);
631 }
632
633 #[test]
634 fn test_directory_history() {
635 let _g = crate::test_util::global_state_lock();
636 let engine = HistoryEngine::in_memory().unwrap();
637
638 engine.add("make build", Some("/project")).unwrap();
639 engine.add("cargo test", Some("/project")).unwrap();
640 engine.add("ls", Some("/tmp")).unwrap();
641
642 let results = engine.for_directory("/project", 10).unwrap();
643 assert_eq!(results.len(), 2);
644 }
645
646 #[test]
651 fn format_text_line_emits_canonical_prefix() {
652 let line = format_text_line(1_700_000_000, 5, "echo hi");
653 assert_eq!(line, ": 1700000000:5;echo hi\n");
654 }
655
656 #[test]
657 fn format_text_line_zero_duration_still_renders() {
658 let line = format_text_line(0, 0, "true");
659 assert_eq!(line, ": 0:0;true\n");
660 }
661
662 #[test]
663 fn format_text_line_escapes_literal_backslash() {
664 let line = format_text_line(1, 0, r"\n");
666 assert!(
668 line.ends_with(";\\\\n\n"),
669 "expected backslash-escape, got: {:?}",
670 line
671 );
672 }
673
674 #[test]
675 fn format_text_line_escapes_embedded_newline_to_backslash_newline() {
676 let line = format_text_line(2, 1, "line1\nline2");
679 assert!(
681 line.contains("line1\\\nline2"),
682 "expected escaped newline, got: {:?}",
683 line
684 );
685 assert!(line.ends_with('\n'));
687 }
688
689 #[test]
690 fn format_text_line_handles_empty_command() {
691 let line = format_text_line(42, 7, "");
692 assert_eq!(line, ": 42:7;\n");
693 }
694
695 #[test]
696 fn format_text_line_negative_duration_round_trips() {
697 let line = format_text_line(100, -1, "foo");
700 assert_eq!(line, ": 100:-1;foo\n");
701 }
702
703 #[test]
704 fn format_text_line_only_terminating_newline_present() {
705 let line = format_text_line(1, 1, "abc");
707 let nls = line.matches('\n').count();
708 assert_eq!(nls, 1);
709 }
710
711 #[test]
716 fn is_sqlite_file_true_for_real_header() {
717 let _g = crate::test_util::global_state_lock();
718 let tmp = std::env::temp_dir().join("zshrs_history_sqlite_magic.bin");
719 std::fs::write(&tmp, b"SQLite format 3\0extra junk after").unwrap();
720 assert!(is_sqlite_file(&tmp));
721 let _ = std::fs::remove_file(&tmp);
722 }
723
724 #[test]
725 fn is_sqlite_file_false_for_plain_text() {
726 let _g = crate::test_util::global_state_lock();
727 let tmp = std::env::temp_dir().join("zshrs_history_text_not_db.txt");
728 std::fs::write(&tmp, b": 1700000000:5;ls -la\n").unwrap();
729 assert!(!is_sqlite_file(&tmp));
730 let _ = std::fs::remove_file(&tmp);
731 }
732
733 #[test]
734 fn is_sqlite_file_false_for_short_file() {
735 let _g = crate::test_util::global_state_lock();
736 let tmp = std::env::temp_dir().join("zshrs_history_short.bin");
737 std::fs::write(&tmp, b"abc").unwrap();
738 assert!(!is_sqlite_file(&tmp));
740 let _ = std::fs::remove_file(&tmp);
741 }
742
743 #[test]
744 fn is_sqlite_file_false_for_missing_path() {
745 let _g = crate::test_util::global_state_lock();
746 assert!(!is_sqlite_file(std::path::Path::new(
747 "/nonexistent/zshrs/sqlite/magic.db"
748 )));
749 }
750
751 #[test]
752 fn is_sqlite_file_false_for_almost_matching_header() {
753 let _g = crate::test_util::global_state_lock();
755 let tmp = std::env::temp_dir().join("zshrs_history_fake_magic.bin");
756 std::fs::write(&tmp, b"SQLite format 4\0").unwrap();
757 assert!(!is_sqlite_file(&tmp));
758 let _ = std::fs::remove_file(&tmp);
759 }
760
761 #[test]
766 fn search_prefix_respects_limit() {
767 let _g = crate::test_util::global_state_lock();
768 let engine = HistoryEngine::in_memory().unwrap();
769 for n in 0..5 {
770 engine.add(&format!("git-{}", n), None).unwrap();
771 }
772 let results = engine.search_prefix("git-", 2).unwrap();
773 assert!(results.len() <= 2, "limit not honored: {}", results.len());
774 }
775}