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::path::PathBuf;
26use std::time::{SystemTime, UNIX_EPOCH};
27
28/// SQLite-backed history engine.
29/// Replaces the in-memory `histent` doubly-linked list +
30/// `histfile` flat-file pair from Src/hist.c — same logical
31/// history but with FTS5 search, frequency tracking, and
32/// per-directory context.
33pub struct HistoryEngine {
34    /// `conn` field.
35    conn: Connection,
36    /// Whether finished commands are appended to the flat text mirror at
37    /// `text_path()`. Only the on-disk engine writes it; an in-memory
38    /// engine must never touch the user's `$ZSHRS_HOME`.
39    mirror_text: bool,
40}
41
42/// One history record.
43/// Port of `struct histent` from Src/zsh.h (`text` / `stim` /
44/// `ftim` fields) plus zshrs additions (`exit_code`, `cwd`,
45/// `frequency`, `duration_ms`) the SQLite schema captures from
46/// the `precmd`/`preexec` hooks.
47#[derive(Debug, Clone)]
48pub struct HistoryEntry {
49    /// `id` field.
50    pub id: i64,
51    /// `command` field.
52    pub command: String,
53    /// `timestamp` field.
54    pub timestamp: i64,
55    /// `duration_ms` field.
56    pub duration_ms: Option<i64>,
57    /// `exit_code` field.
58    pub exit_code: Option<i32>,
59    /// `cwd` field.
60    pub cwd: Option<String>,
61    /// `frequency` field.
62    pub frequency: u32,
63}
64
65impl HistoryEngine {
66    /// `new` — see implementation.
67    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        // Open (and materialize the WAL/SHM side files) with the low fds held,
74        // so sqlite's descriptors land above the script's range. sqlite caches
75        // the fd NUMBER internally, so this cannot be fixed up after the open —
76        // it has to land high in the first place. See crate::lowfd.
77        let _lowfd = crate::lowfd::LowFdGuard::new();
78        let conn = Connection::open(&path)?;
79        crate::startup_trace::mark("hist: sqlite open");
80        // c:Src/utils.c:2007-2010 — every descriptor the shell takes for
81        // itself is recorded as `FDT_INTERNAL`. SQLite never hands back a
82        // descriptor, so the registration cannot ride along with the open
83        // the way `movefd` does; sweep for it instead.
84        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        // The exact row count is a `SELECT COUNT(*)` — a full table scan that
89        // costs ~37 ms on a 775k-row / 1.2 GB history and ran on EVERY shell
90        // start purely to fill in this log line (measured with
91        // `ZSHRS_STARTUP_TRACE=1`: it was the single largest item in
92        // time-to-first-prompt). `MAX(rowid)` answers from the b-tree's
93        // rightmost leaf instead — O(depth), and equal to the count until rows
94        // are deleted, which is why it is reported as an ESTIMATE. Callers
95        // that need the true number (the `--doctor` report, `fc -l` bounds)
96        // still call `count()`.
97        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        // Rehydrate the flat text mirror from the sqlite index when
108        // the text file is missing or stale (size 0 with a populated
109        // db). Cheap: one-shot chronological dump, no FTS / no joins.
110        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    /// `in_memory` — see implementation.
118    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    /// `$ZSHRS_HOME/zshrs_history.db` — sqlite index that powers FTS5
126    /// search, frequency tracking, dedup. Hidden under `.db` so the
127    /// user-facing artifact is the flat-text mirror at
128    /// `zshrs_history` (see `text_path`), zsh-compatible so muscle
129    /// memory + `cat` / `grep` / external history tools all keep
130    /// working.
131    ///
132    /// The daemon owns its OWN history db at `~/.zshrs/history.db`
133    /// (different schema, daemon-only writer); shells append to it via
134    /// `history_append` IPC. This shell-side db is the fallback path
135    /// used when the daemon is absent.
136    pub fn db_path() -> PathBuf {
137        Self::root().join("zshrs_history.db")
138    }
139
140    /// `$ZSHRS_HOME/zshrs_history` — flat text mirror, one line per
141    /// command in zsh extended-history format:
142    ///
143    /// ```text
144    /// : <unix_ts>:<duration>;<command>
145    /// ```
146    ///
147    /// A newline inside a multi-line command is written as a backslash
148    /// followed by the newline, so one record can span several physical
149    /// lines (`zsh/Src/hist.c:savehistfile`). Each record is appended
150    /// once, by `update_last`, when the command has finished and its
151    /// duration is known; the file is never rewritten in place, so
152    /// concurrent shells cannot clobber each other's records. The sqlite
153    /// index at `zshrs_history.db` is the query side; a missing or empty
154    /// text file is rehydrated from it on open.
155    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    /// The FTS index only covers `command`, so `history_au` must only
210    /// re-index when that column changes. Databases created before this
211    /// had `AFTER UPDATE ON history`, which deleted and re-inserted the
212    /// row's trigram entries on every `update_last` (duration/exit stamp
213    /// at each prompt) and every dedup bump in `add` (timestamp,
214    /// frequency, cwd). Replace that trigger once; the schema is left
215    /// untouched when it is already current, so concurrent shells do not
216    /// rewrite it on every open.
217    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    /// Add a command to history, updating frequency if it already exists
248    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        // Try to update existing entry
257        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            // Return the existing ID
265            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        // Insert new entry
274        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    /// Update the duration and exit code of the last command
283    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        // Append the finished command to the flat text mirror, once, now
293        // that its duration is known. Best-effort: a failed write must not
294        // fail the sqlite update. The command is looked up by id because
295        // `add` may have deduplicated onto an existing row.
296        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    /// If the text mirror is missing or empty but the sqlite db has
310    /// entries, dump the db chronologically into the text file. Used
311    /// by `new()` after the first-time rename migration so users get
312    /// the full backlog in the user-facing text file from day one.
313    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    /// Search history with FTS5 (fuzzy/substring matching)
362    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        // Escape special FTS5 characters and use prefix matching
368        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    /// Search history with prefix matching (for up-arrow completion)
396    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        // Escape SQL LIKE special chars
410        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    /// Case-SENSITIVE prefix probe for the per-keystroke ZLE paths
431    /// (autosuggest, up-arrow search). Uses GLOB, which is case-sensitive and
432    /// therefore eligible for the SQLite prefix optimization over
433    /// `idx_history_command` (BINARY collation). `search_prefix`'s LIKE is
434    /// case-insensitive by default and CANNOT use that index — at 500k+ rows
435    /// it full-scans, which is far too slow to run on every keystroke.
436    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        // Escape GLOB metachars via character classes ('[' first — the
454        // replacement brackets must not themselves get re-escaped).
455        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    /// Get recent history entries
477    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    /// Get history for a specific directory
501    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    /// Delete a history entry
526    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    /// Clear all history
533    pub fn clear(&self) -> rusqlite::Result<()> {
534        self.conn.execute("DELETE FROM history", [])?;
535        Ok(())
536    }
537
538    /// Get total history count
539    pub fn count(&self) -> rusqlite::Result<i64> {
540        self.conn
541            .query_row("SELECT COUNT(*) FROM history", [], |row| row.get(0))
542    }
543
544    /// Row-count ESTIMATE that does not scan the table.
545    ///
546    /// `MAX(rowid)` is resolved by descending to the rightmost leaf of the
547    /// rowid b-tree, so it is independent of table size where `COUNT(*)` is
548    /// linear in it (37 ms at 775k rows). It equals the true count for an
549    /// append-only history and over-reports by the number of deleted rows
550    /// otherwise — good enough for a log line, never used for arithmetic on
551    /// history events.
552    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    /// Get entry by index from end (0 = most recent, like !-1)
560    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    /// Get entry by absolute history number (like !123)
585    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
609// ---------------------------------------------------------------------
610// Interactive-session sink — the SQLite index is zshrs's DEFAULT
611// history store; the flat $HISTFILE path in ported hist.rs stays fully
612// functional for users who override via HISTFILE/SAVEHIST. `hend()`
613// (src/ported/hist.rs) calls `history_sqlite_add` for every accepted
614// interactive line (same accept policy as the $HISTFILE write:
615// HIST_TMPSTORE / HIST_NOWRITE excluded); `preprompt()`
616// (src/ported/utils.rs) calls `history_sqlite_finish` right after
617// execode returns to stamp duration + exit status and append the text
618// mirror record. Non-interactive runs (`-c`, scripts) never reach it,
619// as zsh records no history for them (c:Src/hist.c:1120). Thread-local because
620// rusqlite::Connection is !Sync; the interactive loop is
621// single-threaded on the main thread.
622// ---------------------------------------------------------------------
623
624thread_local! {
625    /// Lazily-opened engine for the interactive loop. Outer Option =
626    /// "tried to open yet?", inner Option = open result (None when the
627    /// db can't be opened — sink becomes a no-op, never an error).
628    static SESSION_HISTORY: std::cell::RefCell<Option<Option<HistoryEngine>>> =
629        const { std::cell::RefCell::new(None) };
630    /// Row id + start instant of the line whose command is currently
631    /// executing, armed by `history_sqlite_add`, consumed by
632    /// `history_sqlite_finish`.
633    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
647/// Whether this shell may write `$HISTFILE`. `$HISTFILE` is zsh's file:
648/// zshrs keeps its own history in the SQLite index + text mirror and
649/// writes `$HISTFILE` only in `--zsh` mode, where it stands in for zsh.
650/// Sharing the file let zshrs's writes clobber zsh's history.
651pub fn histfile_writable() -> bool {
652    crate::IS_ZSH_MODE.load(std::sync::atomic::Ordering::Relaxed)
653}
654
655/// Whether `path` names the file `$HISTFILE` points at. Compared through
656/// `canonicalize` when both exist, so `~/x` vs a symlinked spelling of
657/// the same file still matches.
658pub 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
671/// Append an accepted interactive line to the SQLite history index
672/// (+ text mirror) and arm the pending row for `history_sqlite_finish`.
673pub 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
683/// Stamp duration + exit status onto the row `history_sqlite_add`
684/// armed. No-op when nothing is pending.
685pub 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
693/// Detect whether `path` is a sqlite database by sniffing the magic
694/// header (first 16 bytes start with `SQLite format 3\0`). Errors /
695/// short files / unknown content all return false (safe default —
696/// leave unknown content alone).
697fn 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
709/// Format one zsh-extended-history line:
710///
711/// ```text
712/// : <unix_ts>:<duration>;<command>\n
713/// ```
714///
715/// Multi-line commands escape literal `\n` to the two-character
716/// sequence `\\n` so each entry stays on a single line; the unescape
717/// is the inverse done at read time. Matches what zsh writes when
718/// `EXTENDED_HISTORY` is set (zsh/Src/hist.c:savehistfile).
719fn 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
724/// Append one line to `$ZSHRS_HOME/zshrs_history`.
725fn 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    /// Runs `f` with `ZSHRS_HOME` pointed at a fresh temp dir, restoring the
743    /// previous value afterwards. Caller holds `global_state_lock`.
744    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            // A multi-line record spans three physical lines. The old
766            // in-place rewrite of "the last line" re-wrote the whole record
767            // over only its final line, duplicating the rest.
768            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            // A repeat deduplicates onto the first row. It must append its
773            // own record, not overwrite `print two`, which is last on disk.
774            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    /// A database created with the old `AFTER UPDATE ON history` trigger is
800    /// migrated on open: stamping duration/exit then changes only the
801    /// `history` row (no FTS delete + re-insert), a dedup bump likewise,
802    /// and editing `command` itself still re-indexes the row.
803    #[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        // Use prefix search for short queries (trigram FTS5 needs 3+ chars)
867        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    // ========================================================
913    // format_text_line — zsh EXTENDED_HISTORY line emission
914    // ========================================================
915
916    #[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        // `\` doubles so the inverse unescape recovers the original.
931        let line = format_text_line(1, 0, r"\n");
932        // Source `\n` → escaped `\\n`.
933        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        // Multi-line commands escape literal newlines so each
943        // logical entry stays on one disk line for read-back.
944        let line = format_text_line(2, 1, "line1\nline2");
945        // After replacement: `\\\n` = backslash then newline.
946        assert!(
947            line.contains("line1\\\nline2"),
948            "expected escaped newline, got: {:?}",
949            line
950        );
951        // Still terminates with one trailing real newline.
952        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        // Defensive: negative durations are unusual but represent
964        // "duration unknown" sentinels in some pre-commit paths.
965        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        // Exactly one trailing `\n` regardless of command bytes.
972        let line = format_text_line(1, 1, "abc");
973        let nls = line.matches('\n').count();
974        assert_eq!(nls, 1);
975    }
976
977    // ========================================================
978    // is_sqlite_file — magic-header sniff
979    // ========================================================
980
981    #[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        // Less than 16 bytes — `read_exact` fails → false.
1005        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        // 16 bytes but not the magic — must reject.
1020        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    // ========================================================
1028    // HistoryEntry / engine — semantic coverage
1029    // ========================================================
1030
1031    #[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}