Skip to main content

zsh/extensions/
history.rs

1//! SQLite-backed command history for zshrs.
2//!
3//! **zshrs-original infrastructure with strong C-zsh ancestry.** C
4//! zsh keeps history in a flat file (`Src/hist.c::savehistfile()`)
5//! and an in-memory linked list of `Histent` entries. zshrs
6//! replaces both with a SQLite database for two reasons: (1) FTS5
7//! full-text search makes fzf-style fuzzy matching microsecond-
8//! latency vs zsh's `O(N)` linear scan, (2) frequency / recency /
9//! per-directory tracking can layer on top of the same row without
10//! parallel files. The interactive surface (the `fc` builtin,
11//! `$HISTFILE` semantics, `setopt SHARE_HISTORY` etc.) preserves
12//! the C source's behavior — we just swap the storage backend.
13//!
14//! Features:
15//! - Persistent history across sessions
16//! - Frequency and recency tracking
17//! - FTS5 full-text search for fzf-style matching
18//! - Per-directory history context
19//! - Deduplication with timestamp updates
20
21use 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
29/// SQLite-backed history engine.
30/// Replaces the in-memory `histent` doubly-linked list +
31/// `histfile` flat-file pair from Src/hist.c — same logical
32/// history but with FTS5 search, frequency tracking, and
33/// per-directory context.
34pub struct HistoryEngine {
35    /// `conn` field.
36    conn: Connection,
37}
38
39/// One history record.
40/// Port of `struct histent` from Src/zsh.h (`text` / `stim` /
41/// `ftim` fields) plus zshrs additions (`exit_code`, `cwd`,
42/// `frequency`, `duration_ms`) the SQLite schema captures from
43/// the `precmd`/`preexec` hooks.
44#[derive(Debug, Clone)]
45pub struct HistoryEntry {
46    /// `id` field.
47    pub id: i64,
48    /// `command` field.
49    pub command: String,
50    /// `timestamp` field.
51    pub timestamp: i64,
52    /// `duration_ms` field.
53    pub duration_ms: Option<i64>,
54    /// `exit_code` field.
55    pub exit_code: Option<i32>,
56    /// `cwd` field.
57    pub cwd: Option<String>,
58    /// `frequency` field.
59    pub frequency: u32,
60}
61
62impl HistoryEngine {
63    /// `new` — see implementation.
64    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        // Rehydrate the flat text mirror from the sqlite index when
83        // the text file is missing or stale (size 0 with a populated
84        // db). Cheap: one-shot chronological dump, no FTS / no joins.
85        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    /// `in_memory` — see implementation.
91    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    /// `$ZSHRS_HOME/zshrs_history.db` — sqlite index that powers FTS5
99    /// search, frequency tracking, dedup. Hidden under `.db` so the
100    /// user-facing artifact is the flat-text mirror at
101    /// `zshrs_history` (see `text_path`), zsh-compatible so muscle
102    /// memory + `cat` / `grep` / external history tools all keep
103    /// working.
104    ///
105    /// The daemon owns its OWN history db at `~/.zshrs/history.db`
106    /// (different schema, daemon-only writer); shells append to it via
107    /// `history_append` IPC. This shell-side db is the fallback path
108    /// used when the daemon is absent.
109    fn db_path() -> PathBuf {
110        Self::root().join("zshrs_history.db")
111    }
112
113    /// `$ZSHRS_HOME/zshrs_history` — flat text mirror, one line per
114    /// command in zsh extended-history format:
115    ///
116    /// ```text
117    /// : <unix_ts>:<duration>;<command>
118    /// ```
119    ///
120    /// Newlines inside multi-line commands are escaped as the literal
121    /// two-character sequence `\\n` (matches `setopt EXTENDED_HISTORY`
122    /// — `zsh/Src/hist.c:gethistent`). Every `add` appends one line;
123    /// `update_last` rewrites the trailing line in place when the
124    /// duration becomes known. The sqlite index at `zshrs_history.db`
125    /// is the query-side mirror of this file — they're kept in lockstep
126    /// by the writer, and a divergence-repair pass on open re-reads
127    /// the text file if the sqlite is missing or older.
128    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    /// Add a command to history, updating frequency if it already exists
189    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        // Try to update existing entry
198        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            // Return the existing ID
206            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        // Insert new entry
215        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        // Mirror to the flat zsh-extended-history file. Best-effort —
223        // a write failure here doesn't fail the sqlite insert (e.g.
224        // disk full mid-write should still let the shell record state
225        // in the index). The duration is unknown at this point;
226        // `update_last` rewrites the trailing line once it knows.
227        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    /// Update the duration and exit code of the last command
235    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        // Update the trailing line of the text mirror with the now-known
242        // duration. Look up the command by id so the rewrite stays
243        // consistent even if `add` deduped to an earlier entry.
244        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    /// If the text mirror is missing or empty but the sqlite db has
258    /// entries, dump the db chronologically into the text file. Used
259    /// by `new()` after the first-time rename migration so users get
260    /// the full backlog in the user-facing text file from day one.
261    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    /// Search history with FTS5 (fuzzy/substring matching)
310    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        // Escape special FTS5 characters and use prefix matching
316        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    /// Search history with prefix matching (for up-arrow completion)
344    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        // Escape SQL LIKE special chars
358        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    /// Get recent history entries
379    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    /// Get history for a specific directory
403    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    /// Delete a history entry
428    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    /// Clear all history
435    pub fn clear(&self) -> rusqlite::Result<()> {
436        self.conn.execute("DELETE FROM history", [])?;
437        Ok(())
438    }
439
440    /// Get total history count
441    pub fn count(&self) -> rusqlite::Result<i64> {
442        self.conn
443            .query_row("SELECT COUNT(*) FROM history", [], |row| row.get(0))
444    }
445
446    /// Get entry by index from end (0 = most recent, like !-1)
447    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    /// Get entry by absolute history number (like !123)
472    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
496/// Detect whether `path` is a sqlite database by sniffing the magic
497/// header (first 16 bytes start with `SQLite format 3\0`). Errors /
498/// short files / unknown content all return false (safe default —
499/// leave unknown content alone).
500fn 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
512/// Format one zsh-extended-history line:
513///
514/// ```text
515/// : <unix_ts>:<duration>;<command>\n
516/// ```
517///
518/// Multi-line commands escape literal `\n` to the two-character
519/// sequence `\\n` so each entry stays on a single line; the unescape
520/// is the inverse done at read time. Matches what zsh writes when
521/// `EXTENDED_HISTORY` is set (zsh/Src/hist.c:savehistfile).
522fn 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
527/// Append one line to `$ZSHRS_HOME/zshrs_history`.
528fn 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
541/// Rewrite the trailing entry of the text file in place — used by
542/// `update_last` once the duration is known. Strategy: read the file
543/// to the last newline-delimited record, replace it with a freshly
544/// formatted line. For multi-MB history files we only buffer the
545/// trailing record's tail bytes (`max_tail` cap) — anything older
546/// stays untouched on disk.
547fn 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    // 64 KiB is enough for any realistic single-command record (zsh
555    // commands top out at ~1-4 KiB). Beyond that, give up and append
556    // a corrected line rather than risk truncating the file.
557    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    // Find the offset (within `tail`) where the last record begins.
563    // A record begins at the byte AFTER the second-to-last newline,
564    // or at offset 0 if there is none.
565    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        // Use prefix search for short queries (trigram FTS5 needs 3+ chars)
601        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    // ========================================================
647    // format_text_line — zsh EXTENDED_HISTORY line emission
648    // ========================================================
649
650    #[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        // `\` doubles so the inverse unescape recovers the original.
665        let line = format_text_line(1, 0, r"\n");
666        // Source `\n` → escaped `\\n`.
667        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        // Multi-line commands escape literal newlines so each
677        // logical entry stays on one disk line for read-back.
678        let line = format_text_line(2, 1, "line1\nline2");
679        // After replacement: `\\\n` = backslash then newline.
680        assert!(
681            line.contains("line1\\\nline2"),
682            "expected escaped newline, got: {:?}",
683            line
684        );
685        // Still terminates with one trailing real newline.
686        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        // Defensive: negative durations are unusual but represent
698        // "duration unknown" sentinels in some pre-commit paths.
699        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        // Exactly one trailing `\n` regardless of command bytes.
706        let line = format_text_line(1, 1, "abc");
707        let nls = line.matches('\n').count();
708        assert_eq!(nls, 1);
709    }
710
711    // ========================================================
712    // is_sqlite_file — magic-header sniff
713    // ========================================================
714
715    #[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        // Less than 16 bytes — `read_exact` fails → false.
739        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        // 16 bytes but not the magic — must reject.
754        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    // ========================================================
762    // HistoryEntry / engine — semantic coverage
763    // ========================================================
764
765    #[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}