1use rusqlite::{params, Connection};
22use std::io::Read;
23use std::io::Write as _;
24use std::io::Write;
25use std::path::PathBuf;
26use std::time::{SystemTime, UNIX_EPOCH};
27
28pub struct HistoryEngine {
34 conn: Connection,
36 mirror_text: bool,
40}
41
42#[derive(Debug, Clone)]
48pub struct HistoryEntry {
49 pub id: i64,
51 pub command: String,
53 pub timestamp: i64,
55 pub duration_ms: Option<i64>,
57 pub exit_code: Option<i32>,
59 pub cwd: Option<String>,
61 pub frequency: u32,
63}
64
65impl HistoryEngine {
66 pub fn new() -> rusqlite::Result<Self> {
68 let path = Self::db_path();
69 if let Some(parent) = path.parent() {
70 std::fs::create_dir_all(parent).ok();
71 }
72
73 let _lowfd = crate::lowfd::LowFdGuard::new();
78 let conn = Connection::open(&path)?;
79 crate::startup_trace::mark("hist: sqlite open");
80 crate::lowfd::register_internal_fds();
85 let engine = Self { conn, mirror_text: true };
86 engine.init_schema()?;
87 crate::startup_trace::mark("hist: init_schema");
88 let est = engine.count_estimate().unwrap_or(0);
98 crate::startup_trace::mark("hist: count estimate");
99 let db_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
100 tracing::info!(
101 entries_est = est,
102 db_bytes = db_size,
103 path = %path.display(),
104 "history: sqlite opened"
105 );
106
107 crate::startup_trace::mark("hist: metadata + log");
111 if let Err(e) = engine.rehydrate_text_if_stale() {
112 tracing::warn!(?e, "history: failed to rehydrate text mirror; continuing");
113 }
114 crate::startup_trace::mark("hist: rehydrate_text_if_stale");
115 Ok(engine)
116 }
117 pub fn in_memory() -> rusqlite::Result<Self> {
119 let conn = Connection::open_in_memory()?;
120 let engine = Self { conn, mirror_text: false };
121 engine.init_schema()?;
122 Ok(engine)
123 }
124
125 pub fn db_path() -> PathBuf {
137 Self::root().join("zshrs_history.db")
138 }
139
140 pub fn text_path() -> PathBuf {
156 Self::root().join("zshrs_history")
157 }
158
159 fn root() -> PathBuf {
160 if let Some(custom) = std::env::var_os("ZSHRS_HOME") {
161 PathBuf::from(custom)
162 } else {
163 dirs::home_dir()
164 .unwrap_or_else(|| PathBuf::from("."))
165 .join(".zshrs")
166 }
167 }
168
169 fn init_schema(&self) -> rusqlite::Result<()> {
170 self.conn.execute_batch(r#"
171 CREATE TABLE IF NOT EXISTS history (
172 id INTEGER PRIMARY KEY,
173 command TEXT NOT NULL,
174 timestamp INTEGER NOT NULL,
175 duration_ms INTEGER,
176 exit_code INTEGER,
177 cwd TEXT,
178 frequency INTEGER DEFAULT 1
179 );
180
181 CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp DESC);
182 CREATE INDEX IF NOT EXISTS idx_history_cwd ON history(cwd);
183 CREATE UNIQUE INDEX IF NOT EXISTS idx_history_command ON history(command);
184
185 CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5(
186 command,
187 content='history',
188 content_rowid='id',
189 tokenize='trigram'
190 );
191
192 CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
193 INSERT INTO history_fts(rowid, command) VALUES (new.id, new.command);
194 END;
195
196 CREATE TRIGGER IF NOT EXISTS history_ad AFTER DELETE ON history BEGIN
197 INSERT INTO history_fts(history_fts, rowid, command) VALUES('delete', old.id, old.command);
198 END;
199
200 CREATE TRIGGER IF NOT EXISTS history_au AFTER UPDATE OF command ON history BEGIN
201 INSERT INTO history_fts(history_fts, rowid, command) VALUES('delete', old.id, old.command);
202 INSERT INTO history_fts(rowid, command) VALUES (new.id, new.command);
203 END;
204 "#)?;
205 self.migrate_fts_update_trigger()?;
206 Ok(())
207 }
208
209 fn migrate_fts_update_trigger(&self) -> rusqlite::Result<()> {
218 let sql: Option<String> = self
219 .conn
220 .query_row(
221 "SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = 'history_au'",
222 [],
223 |row| row.get(0),
224 )
225 .ok();
226 if sql.is_some_and(|s| s.contains("UPDATE OF command")) {
227 return Ok(());
228 }
229 self.conn.execute_batch(r#"
230 BEGIN IMMEDIATE;
231 DROP TRIGGER IF EXISTS history_au;
232 CREATE TRIGGER history_au AFTER UPDATE OF command ON history BEGIN
233 INSERT INTO history_fts(history_fts, rowid, command) VALUES('delete', old.id, old.command);
234 INSERT INTO history_fts(rowid, command) VALUES (new.id, new.command);
235 END;
236 COMMIT;
237 "#)
238 }
239
240 fn now() -> i64 {
241 SystemTime::now()
242 .duration_since(UNIX_EPOCH)
243 .map(|d| d.as_secs() as i64)
244 .unwrap_or(0)
245 }
246
247 pub fn add(&self, command: &str, cwd: Option<&str>) -> rusqlite::Result<i64> {
249 let command = command.trim();
250 if command.is_empty() || command.starts_with(' ') {
251 return Ok(0);
252 }
253
254 let now = Self::now();
255
256 let updated = self.conn.execute(
258 "UPDATE history SET timestamp = ?1, frequency = frequency + 1, cwd = COALESCE(?2, cwd)
259 WHERE command = ?3",
260 params![now, cwd, command],
261 )?;
262
263 if updated > 0 {
264 let id: i64 = self.conn.query_row(
266 "SELECT id FROM history WHERE command = ?1",
267 params![command],
268 |row| row.get(0),
269 )?;
270 return Ok(id);
271 }
272
273 self.conn.execute(
275 "INSERT INTO history (command, timestamp, cwd) VALUES (?1, ?2, ?3)",
276 params![command, now, cwd],
277 )?;
278
279 Ok(self.conn.last_insert_rowid())
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 !self.mirror_text {
290 return Ok(());
291 }
292 if let Ok((ts, command)) = self.conn.query_row(
297 "SELECT timestamp, command FROM history WHERE id = ?1",
298 params![id],
299 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
300 ) {
301 let duration_secs = (duration_ms / 1000).max(0);
302 if let Err(e) = append_text_line(ts, duration_secs, &command) {
303 tracing::warn!(?e, "history: text mirror append failed");
304 }
305 }
306 Ok(())
307 }
308
309 fn rehydrate_text_if_stale(&self) -> rusqlite::Result<()> {
314 let text = Self::text_path();
315 let text_size = std::fs::metadata(&text).map(|m| m.len()).unwrap_or(0);
316 if text_size > 0 {
317 return Ok(());
318 }
319 let count: i64 = self
320 .conn
321 .query_row("SELECT COUNT(*) FROM history", [], |r| r.get(0))?;
322 if count == 0 {
323 return Ok(());
324 }
325 let mut stmt = self.conn.prepare(
326 "SELECT timestamp, COALESCE(duration_ms, 0), command \
327 FROM history ORDER BY timestamp ASC, id ASC",
328 )?;
329 let rows = stmt.query_map([], |r| {
330 Ok((
331 r.get::<_, i64>(0)?,
332 r.get::<_, i64>(1)?,
333 r.get::<_, String>(2)?,
334 ))
335 })?;
336 let file = std::fs::OpenOptions::new()
337 .create(true)
338 .truncate(true)
339 .write(true)
340 .open(&text)
341 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
342 let mut w = std::io::BufWriter::new(file);
343 let mut written: u64 = 0;
344 for row in rows {
345 let (ts, dur_ms, cmd) = row?;
346 let line = format_text_line(ts, (dur_ms / 1000).max(0), &cmd);
347 w.write_all(line.as_bytes())
348 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
349 written += 1;
350 }
351 w.flush()
352 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
353 tracing::info!(
354 entries = written,
355 path = %text.display(),
356 "history: rehydrated text mirror from sqlite index"
357 );
358 Ok(())
359 }
360
361 pub fn search(&self, query: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
363 if query.is_empty() {
364 return self.recent(limit);
365 }
366
367 let escaped = query.replace('"', "\"\"");
369 let fts_query = format!("\"{}\"*", escaped);
370
371 let mut stmt = self.conn.prepare(
372 r#"SELECT h.id, h.command, h.timestamp, h.duration_ms, h.exit_code, h.cwd, h.frequency
373 FROM history h
374 JOIN history_fts f ON h.id = f.rowid
375 WHERE history_fts MATCH ?1
376 ORDER BY h.frequency DESC, h.timestamp DESC
377 LIMIT ?2"#,
378 )?;
379
380 let entries = stmt.query_map(params![fts_query, limit as i64], |row| {
381 Ok(HistoryEntry {
382 id: row.get(0)?,
383 command: row.get(1)?,
384 timestamp: row.get(2)?,
385 duration_ms: row.get(3)?,
386 exit_code: row.get(4)?,
387 cwd: row.get(5)?,
388 frequency: row.get(6)?,
389 })
390 })?;
391
392 entries.collect()
393 }
394
395 pub fn search_prefix(&self, prefix: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
397 if prefix.is_empty() {
398 return self.recent(limit);
399 }
400
401 let mut stmt = self.conn.prepare(
402 r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
403 FROM history
404 WHERE command LIKE ?1 || '%' ESCAPE '\'
405 ORDER BY timestamp DESC
406 LIMIT ?2"#,
407 )?;
408
409 let escaped = prefix
411 .replace('\\', "\\\\")
412 .replace('%', "\\%")
413 .replace('_', "\\_");
414
415 let entries = stmt.query_map(params![escaped, limit as i64], |row| {
416 Ok(HistoryEntry {
417 id: row.get(0)?,
418 command: row.get(1)?,
419 timestamp: row.get(2)?,
420 duration_ms: row.get(3)?,
421 exit_code: row.get(4)?,
422 cwd: row.get(5)?,
423 frequency: row.get(6)?,
424 })
425 })?;
426
427 entries.collect()
428 }
429
430 pub fn search_prefix_cs(
437 &self,
438 prefix: &str,
439 limit: usize,
440 ) -> rusqlite::Result<Vec<HistoryEntry>> {
441 if prefix.is_empty() {
442 return self.recent(limit);
443 }
444
445 let mut stmt = self.conn.prepare_cached(
446 r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
447 FROM history
448 WHERE command GLOB ?1
449 ORDER BY timestamp DESC
450 LIMIT ?2"#,
451 )?;
452
453 let escaped = prefix
456 .replace('[', "[[]")
457 .replace('*', "[*]")
458 .replace('?', "[?]");
459 let pattern = format!("{escaped}*");
460
461 let entries = stmt.query_map(params![pattern, limit as i64], |row| {
462 Ok(HistoryEntry {
463 id: row.get(0)?,
464 command: row.get(1)?,
465 timestamp: row.get(2)?,
466 duration_ms: row.get(3)?,
467 exit_code: row.get(4)?,
468 cwd: row.get(5)?,
469 frequency: row.get(6)?,
470 })
471 })?;
472
473 entries.collect()
474 }
475
476 pub fn recent(&self, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
478 let mut stmt = self.conn.prepare(
479 r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
480 FROM history
481 ORDER BY timestamp DESC
482 LIMIT ?1"#,
483 )?;
484
485 let entries = stmt.query_map(params![limit as i64], |row| {
486 Ok(HistoryEntry {
487 id: row.get(0)?,
488 command: row.get(1)?,
489 timestamp: row.get(2)?,
490 duration_ms: row.get(3)?,
491 exit_code: row.get(4)?,
492 cwd: row.get(5)?,
493 frequency: row.get(6)?,
494 })
495 })?;
496
497 entries.collect()
498 }
499
500 pub fn for_directory(&self, cwd: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
502 let mut stmt = self.conn.prepare(
503 r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
504 FROM history
505 WHERE cwd = ?1
506 ORDER BY frequency DESC, timestamp DESC
507 LIMIT ?2"#,
508 )?;
509
510 let entries = stmt.query_map(params![cwd, limit as i64], |row| {
511 Ok(HistoryEntry {
512 id: row.get(0)?,
513 command: row.get(1)?,
514 timestamp: row.get(2)?,
515 duration_ms: row.get(3)?,
516 exit_code: row.get(4)?,
517 cwd: row.get(5)?,
518 frequency: row.get(6)?,
519 })
520 })?;
521
522 entries.collect()
523 }
524
525 pub fn delete(&self, id: i64) -> rusqlite::Result<()> {
527 self.conn
528 .execute("DELETE FROM history WHERE id = ?1", params![id])?;
529 Ok(())
530 }
531
532 pub fn clear(&self) -> rusqlite::Result<()> {
534 self.conn.execute("DELETE FROM history", [])?;
535 Ok(())
536 }
537
538 pub fn count(&self) -> rusqlite::Result<i64> {
540 self.conn
541 .query_row("SELECT COUNT(*) FROM history", [], |row| row.get(0))
542 }
543
544 pub fn count_estimate(&self) -> rusqlite::Result<i64> {
553 self.conn
554 .query_row("SELECT IFNULL(MAX(rowid), 0) FROM history", [], |row| {
555 row.get(0)
556 })
557 }
558
559 pub fn get_by_offset(&self, offset: usize) -> rusqlite::Result<Option<HistoryEntry>> {
561 let mut stmt = self.conn.prepare(
562 r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
563 FROM history
564 ORDER BY timestamp DESC
565 LIMIT 1 OFFSET ?1"#,
566 )?;
567
568 let mut rows = stmt.query(params![offset as i64])?;
569 if let Some(row) = rows.next()? {
570 Ok(Some(HistoryEntry {
571 id: row.get(0)?,
572 command: row.get(1)?,
573 timestamp: row.get(2)?,
574 duration_ms: row.get(3)?,
575 exit_code: row.get(4)?,
576 cwd: row.get(5)?,
577 frequency: row.get(6)?,
578 }))
579 } else {
580 Ok(None)
581 }
582 }
583
584 pub fn get_by_number(&self, num: i64) -> rusqlite::Result<Option<HistoryEntry>> {
586 let mut stmt = self.conn.prepare(
587 r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
588 FROM history
589 WHERE id = ?1"#,
590 )?;
591
592 let mut rows = stmt.query(params![num])?;
593 if let Some(row) = rows.next()? {
594 Ok(Some(HistoryEntry {
595 id: row.get(0)?,
596 command: row.get(1)?,
597 timestamp: row.get(2)?,
598 duration_ms: row.get(3)?,
599 exit_code: row.get(4)?,
600 cwd: row.get(5)?,
601 frequency: row.get(6)?,
602 }))
603 } else {
604 Ok(None)
605 }
606 }
607}
608
609thread_local! {
625 static SESSION_HISTORY: std::cell::RefCell<Option<Option<HistoryEngine>>> =
629 const { std::cell::RefCell::new(None) };
630 static HIST_PENDING: std::cell::Cell<Option<(i64, std::time::Instant)>> =
634 const { std::cell::Cell::new(None) };
635}
636
637pub fn with_session_engine<R>(f: impl FnOnce(&HistoryEngine) -> R) -> Option<R> {
638 SESSION_HISTORY.with(|cell| {
639 let mut slot = cell.borrow_mut();
640 if slot.is_none() {
641 *slot = Some(HistoryEngine::new().ok());
642 }
643 slot.as_ref().unwrap().as_ref().map(f)
644 })
645}
646
647pub fn histfile_writable() -> bool {
652 crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
653}
654
655pub fn is_histfile(path: &str) -> bool {
659 let Some(hf) = crate::ported::params::getsparam("HISTFILE").filter(|h| !h.is_empty()) else {
660 return false;
661 };
662 if hf == path {
663 return true;
664 }
665 match (std::fs::canonicalize(&hf), std::fs::canonicalize(path)) {
666 (Ok(a), Ok(b)) => a == b,
667 _ => false,
668 }
669}
670
671pub fn history_sqlite_add(line: &str) {
674 let cwd = std::env::current_dir()
675 .ok()
676 .map(|p| p.to_string_lossy().to_string());
677 let id = with_session_engine(|e| e.add(line, cwd.as_deref()).ok()).flatten();
678 if let Some(id) = id {
679 HIST_PENDING.with(|p| p.set(Some((id, std::time::Instant::now()))));
680 }
681}
682
683pub fn history_sqlite_finish(exit_code: i32) {
686 let Some((id, start)) = HIST_PENDING.with(|p| p.take()) else {
687 return;
688 };
689 let dur = start.elapsed().as_millis() as i64;
690 let _ = with_session_engine(|e| e.update_last(id, dur, exit_code));
691}
692
693fn is_sqlite_file(path: &std::path::Path) -> bool {
698 let mut f = match std::fs::File::open(path) {
699 Ok(f) => f,
700 Err(_) => return false,
701 };
702 let mut header = [0u8; 16];
703 if f.read_exact(&mut header).is_err() {
704 return false;
705 }
706 &header == b"SQLite format 3\0"
707}
708
709fn format_text_line(ts: i64, duration_secs: i64, command: &str) -> String {
720 let escaped = command.replace('\\', "\\\\").replace('\n', "\\\n");
721 format!(": {}:{};{}\n", ts, duration_secs, escaped)
722}
723
724fn append_text_line(ts: i64, duration_secs: i64, command: &str) -> std::io::Result<()> {
726 let path = HistoryEngine::text_path();
727 if let Some(parent) = path.parent() {
728 std::fs::create_dir_all(parent).ok();
729 }
730 let line = format_text_line(ts, duration_secs, command);
731 let mut f = std::fs::OpenOptions::new()
732 .create(true)
733 .append(true)
734 .open(&path)?;
735 f.write_all(line.as_bytes())
736}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741
742 fn with_private_home(f: impl FnOnce(&std::path::Path)) {
745 let dir = tempfile::tempdir().unwrap();
746 let prev = std::env::var_os("ZSHRS_HOME");
747 unsafe { std::env::set_var("ZSHRS_HOME", dir.path()) };
748 f(dir.path());
749 match prev {
750 Some(v) => unsafe { std::env::set_var("ZSHRS_HOME", v) },
751 None => unsafe { std::env::remove_var("ZSHRS_HOME") },
752 }
753 }
754
755 #[test]
756 fn text_mirror_appends_each_finished_command_once() {
757 let _g = crate::test_util::global_state_lock();
758 with_private_home(|home| {
759 let engine = HistoryEngine {
760 conn: Connection::open_in_memory().unwrap(),
761 mirror_text: true,
762 };
763 engine.init_schema().unwrap();
764 let multi = "for i in 1; do\n print $i\ndone";
765 let id = engine.add(multi, None).unwrap();
769 engine.update_last(id, 1500, 0).unwrap();
770 let id = engine.add("print two", None).unwrap();
771 engine.update_last(id, 0, 0).unwrap();
772 let id = engine.add(multi, None).unwrap();
775 engine.update_last(id, 0, 0).unwrap();
776
777 let text = std::fs::read_to_string(home.join("zshrs_history")).unwrap();
778 let bodies: Vec<&str> = text
779 .lines()
780 .map(|l| if l.starts_with(": ") { l.split_once(';').unwrap().1 } else { l })
781 .collect();
782 assert_eq!(
783 bodies,
784 [
785 "for i in 1; do\\",
786 " print $i\\",
787 "done",
788 "print two",
789 "for i in 1; do\\",
790 " print $i\\",
791 "done",
792 ],
793 "mirror was:\n{text}"
794 );
795 assert!(text.starts_with(": ") && text.lines().next().unwrap().contains(":1;"));
796 });
797 }
798
799 #[test]
804 fn fts_update_trigger_fires_only_on_command_change() {
805 let engine = HistoryEngine {
806 conn: Connection::open_in_memory().unwrap(),
807 mirror_text: false,
808 };
809 engine.init_schema().unwrap();
810 engine
811 .conn
812 .execute_batch(
813 "DROP TRIGGER history_au;
814 CREATE TRIGGER history_au AFTER UPDATE ON history BEGIN
815 INSERT INTO history_fts(history_fts, rowid, command) VALUES('delete', old.id, old.command);
816 INSERT INTO history_fts(rowid, command) VALUES (new.id, new.command);
817 END;",
818 )
819 .unwrap();
820 engine.init_schema().unwrap();
821
822 let id = engine.add("print alpha", None).unwrap();
823 let before = engine.conn.total_changes();
824 engine.update_last(id, 1200, 0).unwrap();
825 assert_eq!(engine.conn.total_changes() - before, 1, "update_last touched FTS");
826
827 let before = engine.conn.total_changes();
828 assert_eq!(engine.add("print alpha", None).unwrap(), id);
829 assert_eq!(engine.conn.total_changes() - before, 1, "dedup bump touched FTS");
830
831 engine
832 .conn
833 .execute("UPDATE history SET command = 'print beta' WHERE id = ?1", params![id])
834 .unwrap();
835 let hits: i64 = engine
836 .conn
837 .query_row(
838 "SELECT count(*) FROM history_fts WHERE history_fts MATCH '\"beta\"'",
839 [],
840 |r| r.get(0),
841 )
842 .unwrap();
843 assert_eq!(hits, 1, "command edit was not re-indexed");
844 }
845
846 #[test]
847 fn in_memory_engine_never_writes_text_mirror() {
848 let _g = crate::test_util::global_state_lock();
849 with_private_home(|home| {
850 let engine = HistoryEngine::in_memory().unwrap();
851 let id = engine.add("print scratch", None).unwrap();
852 engine.update_last(id, 0, 0).unwrap();
853 assert!(!home.join("zshrs_history").exists());
854 });
855 }
856
857 #[test]
858 fn test_add_and_search() {
859 let _g = crate::test_util::global_state_lock();
860 let engine = HistoryEngine::in_memory().unwrap();
861
862 engine.add("ls -la", Some("/home/user")).unwrap();
863 engine.add("cd /tmp", Some("/home/user")).unwrap();
864 engine.add("echo hello", Some("/tmp")).unwrap();
865
866 let results = engine.search_prefix("ls", 10).unwrap();
868 assert_eq!(results.len(), 1);
869 assert_eq!(results[0].command, "ls -la");
870 }
871
872 #[test]
873 fn test_frequency_tracking() {
874 let _g = crate::test_util::global_state_lock();
875 let engine = HistoryEngine::in_memory().unwrap();
876
877 engine.add("git status", None).unwrap();
878 engine.add("git status", None).unwrap();
879 engine.add("git status", None).unwrap();
880
881 let results = engine.recent(10).unwrap();
882 assert_eq!(results.len(), 1);
883 assert_eq!(results[0].frequency, 3);
884 }
885
886 #[test]
887 fn test_prefix_search() {
888 let _g = crate::test_util::global_state_lock();
889 let engine = HistoryEngine::in_memory().unwrap();
890
891 engine.add("git status", None).unwrap();
892 engine.add("git commit -m 'test'", None).unwrap();
893 engine.add("grep foo bar", None).unwrap();
894
895 let results = engine.search_prefix("git", 10).unwrap();
896 assert_eq!(results.len(), 2);
897 }
898
899 #[test]
900 fn test_directory_history() {
901 let _g = crate::test_util::global_state_lock();
902 let engine = HistoryEngine::in_memory().unwrap();
903
904 engine.add("make build", Some("/project")).unwrap();
905 engine.add("cargo test", Some("/project")).unwrap();
906 engine.add("ls", Some("/tmp")).unwrap();
907
908 let results = engine.for_directory("/project", 10).unwrap();
909 assert_eq!(results.len(), 2);
910 }
911
912 #[test]
917 fn format_text_line_emits_canonical_prefix() {
918 let line = format_text_line(1_700_000_000, 5, "echo hi");
919 assert_eq!(line, ": 1700000000:5;echo hi\n");
920 }
921
922 #[test]
923 fn format_text_line_zero_duration_still_renders() {
924 let line = format_text_line(0, 0, "true");
925 assert_eq!(line, ": 0:0;true\n");
926 }
927
928 #[test]
929 fn format_text_line_escapes_literal_backslash() {
930 let line = format_text_line(1, 0, r"\n");
932 assert!(
934 line.ends_with(";\\\\n\n"),
935 "expected backslash-escape, got: {:?}",
936 line
937 );
938 }
939
940 #[test]
941 fn format_text_line_escapes_embedded_newline_to_backslash_newline() {
942 let line = format_text_line(2, 1, "line1\nline2");
945 assert!(
947 line.contains("line1\\\nline2"),
948 "expected escaped newline, got: {:?}",
949 line
950 );
951 assert!(line.ends_with('\n'));
953 }
954
955 #[test]
956 fn format_text_line_handles_empty_command() {
957 let line = format_text_line(42, 7, "");
958 assert_eq!(line, ": 42:7;\n");
959 }
960
961 #[test]
962 fn format_text_line_negative_duration_round_trips() {
963 let line = format_text_line(100, -1, "foo");
966 assert_eq!(line, ": 100:-1;foo\n");
967 }
968
969 #[test]
970 fn format_text_line_only_terminating_newline_present() {
971 let line = format_text_line(1, 1, "abc");
973 let nls = line.matches('\n').count();
974 assert_eq!(nls, 1);
975 }
976
977 #[test]
982 fn is_sqlite_file_true_for_real_header() {
983 let _g = crate::test_util::global_state_lock();
984 let tmp = std::env::temp_dir().join("zshrs_history_sqlite_magic.bin");
985 std::fs::write(&tmp, b"SQLite format 3\0extra junk after").unwrap();
986 assert!(is_sqlite_file(&tmp));
987 let _ = std::fs::remove_file(&tmp);
988 }
989
990 #[test]
991 fn is_sqlite_file_false_for_plain_text() {
992 let _g = crate::test_util::global_state_lock();
993 let tmp = std::env::temp_dir().join("zshrs_history_text_not_db.txt");
994 std::fs::write(&tmp, b": 1700000000:5;ls -la\n").unwrap();
995 assert!(!is_sqlite_file(&tmp));
996 let _ = std::fs::remove_file(&tmp);
997 }
998
999 #[test]
1000 fn is_sqlite_file_false_for_short_file() {
1001 let _g = crate::test_util::global_state_lock();
1002 let tmp = std::env::temp_dir().join("zshrs_history_short.bin");
1003 std::fs::write(&tmp, b"abc").unwrap();
1004 assert!(!is_sqlite_file(&tmp));
1006 let _ = std::fs::remove_file(&tmp);
1007 }
1008
1009 #[test]
1010 fn is_sqlite_file_false_for_missing_path() {
1011 let _g = crate::test_util::global_state_lock();
1012 assert!(!is_sqlite_file(std::path::Path::new(
1013 "/nonexistent/zshrs/sqlite/magic.db"
1014 )));
1015 }
1016
1017 #[test]
1018 fn is_sqlite_file_false_for_almost_matching_header() {
1019 let _g = crate::test_util::global_state_lock();
1021 let tmp = std::env::temp_dir().join("zshrs_history_fake_magic.bin");
1022 std::fs::write(&tmp, b"SQLite format 4\0").unwrap();
1023 assert!(!is_sqlite_file(&tmp));
1024 let _ = std::fs::remove_file(&tmp);
1025 }
1026
1027 #[test]
1032 fn search_prefix_respects_limit() {
1033 let _g = crate::test_util::global_state_lock();
1034 let engine = HistoryEngine::in_memory().unwrap();
1035 for n in 0..5 {
1036 engine.add(&format!("git-{}", n), None).unwrap();
1037 }
1038 let results = engine.search_prefix("git-", 2).unwrap();
1039 assert!(results.len() <= 2, "limit not honored: {}", results.len());
1040 }
1041}