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        // Open (and materialize the WAL/SHM side files) with the low fds held,
71        // so sqlite's descriptors land above the script's range. sqlite caches
72        // the fd NUMBER internally, so this cannot be fixed up after the open —
73        // it has to land high in the first place. See crate::lowfd.
74        let _lowfd = crate::lowfd::LowFdGuard::new();
75        let conn = Connection::open(&path)?;
76        let engine = Self { conn };
77        engine.init_schema()?;
78        let count = engine.count().unwrap_or(0);
79        let db_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
80        tracing::info!(
81            entries = count,
82            db_bytes = db_size,
83            path = %path.display(),
84            "history: sqlite opened"
85        );
86
87        // Rehydrate the flat text mirror from the sqlite index when
88        // the text file is missing or stale (size 0 with a populated
89        // db). Cheap: one-shot chronological dump, no FTS / no joins.
90        if let Err(e) = engine.rehydrate_text_if_stale() {
91            tracing::warn!(?e, "history: failed to rehydrate text mirror; continuing");
92        }
93        Ok(engine)
94    }
95    /// `in_memory` — see implementation.
96    pub fn in_memory() -> rusqlite::Result<Self> {
97        let conn = Connection::open_in_memory()?;
98        let engine = Self { conn };
99        engine.init_schema()?;
100        Ok(engine)
101    }
102
103    /// `$ZSHRS_HOME/zshrs_history.db` — sqlite index that powers FTS5
104    /// search, frequency tracking, dedup. Hidden under `.db` so the
105    /// user-facing artifact is the flat-text mirror at
106    /// `zshrs_history` (see `text_path`), zsh-compatible so muscle
107    /// memory + `cat` / `grep` / external history tools all keep
108    /// working.
109    ///
110    /// The daemon owns its OWN history db at `~/.zshrs/history.db`
111    /// (different schema, daemon-only writer); shells append to it via
112    /// `history_append` IPC. This shell-side db is the fallback path
113    /// used when the daemon is absent.
114    fn db_path() -> PathBuf {
115        Self::root().join("zshrs_history.db")
116    }
117
118    /// `$ZSHRS_HOME/zshrs_history` — flat text mirror, one line per
119    /// command in zsh extended-history format:
120    ///
121    /// ```text
122    /// : <unix_ts>:<duration>;<command>
123    /// ```
124    ///
125    /// Newlines inside multi-line commands are escaped as the literal
126    /// two-character sequence `\\n` (matches `setopt EXTENDED_HISTORY`
127    /// — `zsh/Src/hist.c:gethistent`). Every `add` appends one line;
128    /// `update_last` rewrites the trailing line in place when the
129    /// duration becomes known. The sqlite index at `zshrs_history.db`
130    /// is the query-side mirror of this file — they're kept in lockstep
131    /// by the writer, and a divergence-repair pass on open re-reads
132    /// the text file if the sqlite is missing or older.
133    pub fn text_path() -> PathBuf {
134        Self::root().join("zshrs_history")
135    }
136
137    fn root() -> PathBuf {
138        if let Some(custom) = std::env::var_os("ZSHRS_HOME") {
139            PathBuf::from(custom)
140        } else {
141            dirs::home_dir()
142                .unwrap_or_else(|| PathBuf::from("."))
143                .join(".zshrs")
144        }
145    }
146
147    fn init_schema(&self) -> rusqlite::Result<()> {
148        self.conn.execute_batch(r#"
149            CREATE TABLE IF NOT EXISTS history (
150                id INTEGER PRIMARY KEY,
151                command TEXT NOT NULL,
152                timestamp INTEGER NOT NULL,
153                duration_ms INTEGER,
154                exit_code INTEGER,
155                cwd TEXT,
156                frequency INTEGER DEFAULT 1
157            );
158
159            CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp DESC);
160            CREATE INDEX IF NOT EXISTS idx_history_cwd ON history(cwd);
161            CREATE UNIQUE INDEX IF NOT EXISTS idx_history_command ON history(command);
162
163            CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5(
164                command,
165                content='history',
166                content_rowid='id',
167                tokenize='trigram'
168            );
169
170            CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
171                INSERT INTO history_fts(rowid, command) VALUES (new.id, new.command);
172            END;
173
174            CREATE TRIGGER IF NOT EXISTS history_ad AFTER DELETE ON history BEGIN
175                INSERT INTO history_fts(history_fts, rowid, command) VALUES('delete', old.id, old.command);
176            END;
177
178            CREATE TRIGGER IF NOT EXISTS history_au AFTER UPDATE ON history BEGIN
179                INSERT INTO history_fts(history_fts, rowid, command) VALUES('delete', old.id, old.command);
180                INSERT INTO history_fts(rowid, command) VALUES (new.id, new.command);
181            END;
182        "#)?;
183        Ok(())
184    }
185
186    fn now() -> i64 {
187        SystemTime::now()
188            .duration_since(UNIX_EPOCH)
189            .map(|d| d.as_secs() as i64)
190            .unwrap_or(0)
191    }
192
193    /// Add a command to history, updating frequency if it already exists
194    pub fn add(&self, command: &str, cwd: Option<&str>) -> rusqlite::Result<i64> {
195        let command = command.trim();
196        if command.is_empty() || command.starts_with(' ') {
197            return Ok(0);
198        }
199
200        let now = Self::now();
201
202        // Try to update existing entry
203        let updated = self.conn.execute(
204            "UPDATE history SET timestamp = ?1, frequency = frequency + 1, cwd = COALESCE(?2, cwd)
205             WHERE command = ?3",
206            params![now, cwd, command],
207        )?;
208
209        if updated > 0 {
210            // Return the existing ID
211            let id: i64 = self.conn.query_row(
212                "SELECT id FROM history WHERE command = ?1",
213                params![command],
214                |row| row.get(0),
215            )?;
216            return Ok(id);
217        }
218
219        // Insert new entry
220        self.conn.execute(
221            "INSERT INTO history (command, timestamp, cwd) VALUES (?1, ?2, ?3)",
222            params![command, now, cwd],
223        )?;
224
225        let id = self.conn.last_insert_rowid();
226
227        // Mirror to the flat zsh-extended-history file. Best-effort —
228        // a write failure here doesn't fail the sqlite insert (e.g.
229        // disk full mid-write should still let the shell record state
230        // in the index). The duration is unknown at this point;
231        // `update_last` rewrites the trailing line once it knows.
232        if let Err(e) = append_text_line(now, 0, command) {
233            tracing::warn!(?e, "history: text mirror append failed");
234        }
235
236        Ok(id)
237    }
238
239    /// Update the duration and exit code of the last command
240    pub fn update_last(&self, id: i64, duration_ms: i64, exit_code: i32) -> rusqlite::Result<()> {
241        self.conn.execute(
242            "UPDATE history SET duration_ms = ?1, exit_code = ?2 WHERE id = ?3",
243            params![duration_ms, exit_code, id],
244        )?;
245
246        // Update the trailing line of the text mirror with the now-known
247        // duration. Look up the command by id so the rewrite stays
248        // consistent even if `add` deduped to an earlier entry.
249        if let Ok((ts, command)) = self.conn.query_row(
250            "SELECT timestamp, command FROM history WHERE id = ?1",
251            params![id],
252            |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
253        ) {
254            let duration_secs = (duration_ms / 1000).max(0);
255            if let Err(e) = rewrite_last_text_line(ts, duration_secs, &command) {
256                tracing::warn!(?e, "history: text mirror update failed");
257            }
258        }
259        Ok(())
260    }
261
262    /// If the text mirror is missing or empty but the sqlite db has
263    /// entries, dump the db chronologically into the text file. Used
264    /// by `new()` after the first-time rename migration so users get
265    /// the full backlog in the user-facing text file from day one.
266    fn rehydrate_text_if_stale(&self) -> rusqlite::Result<()> {
267        let text = Self::text_path();
268        let text_size = std::fs::metadata(&text).map(|m| m.len()).unwrap_or(0);
269        if text_size > 0 {
270            return Ok(());
271        }
272        let count: i64 = self
273            .conn
274            .query_row("SELECT COUNT(*) FROM history", [], |r| r.get(0))?;
275        if count == 0 {
276            return Ok(());
277        }
278        let mut stmt = self.conn.prepare(
279            "SELECT timestamp, COALESCE(duration_ms, 0), command \
280             FROM history ORDER BY timestamp ASC, id ASC",
281        )?;
282        let rows = stmt.query_map([], |r| {
283            Ok((
284                r.get::<_, i64>(0)?,
285                r.get::<_, i64>(1)?,
286                r.get::<_, String>(2)?,
287            ))
288        })?;
289        let file = std::fs::OpenOptions::new()
290            .create(true)
291            .truncate(true)
292            .write(true)
293            .open(&text)
294            .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
295        let mut w = std::io::BufWriter::new(file);
296        let mut written: u64 = 0;
297        for row in rows {
298            let (ts, dur_ms, cmd) = row?;
299            let line = format_text_line(ts, (dur_ms / 1000).max(0), &cmd);
300            w.write_all(line.as_bytes())
301                .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
302            written += 1;
303        }
304        w.flush()
305            .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
306        tracing::info!(
307            entries = written,
308            path = %text.display(),
309            "history: rehydrated text mirror from sqlite index"
310        );
311        Ok(())
312    }
313
314    /// Search history with FTS5 (fuzzy/substring matching)
315    pub fn search(&self, query: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
316        if query.is_empty() {
317            return self.recent(limit);
318        }
319
320        // Escape special FTS5 characters and use prefix matching
321        let escaped = query.replace('"', "\"\"");
322        let fts_query = format!("\"{}\"*", escaped);
323
324        let mut stmt = self.conn.prepare(
325            r#"SELECT h.id, h.command, h.timestamp, h.duration_ms, h.exit_code, h.cwd, h.frequency
326               FROM history h
327               JOIN history_fts f ON h.id = f.rowid
328               WHERE history_fts MATCH ?1
329               ORDER BY h.frequency DESC, h.timestamp DESC
330               LIMIT ?2"#,
331        )?;
332
333        let entries = stmt.query_map(params![fts_query, limit as i64], |row| {
334            Ok(HistoryEntry {
335                id: row.get(0)?,
336                command: row.get(1)?,
337                timestamp: row.get(2)?,
338                duration_ms: row.get(3)?,
339                exit_code: row.get(4)?,
340                cwd: row.get(5)?,
341                frequency: row.get(6)?,
342            })
343        })?;
344
345        entries.collect()
346    }
347
348    /// Search history with prefix matching (for up-arrow completion)
349    pub fn search_prefix(&self, prefix: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
350        if prefix.is_empty() {
351            return self.recent(limit);
352        }
353
354        let mut stmt = self.conn.prepare(
355            r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
356               FROM history
357               WHERE command LIKE ?1 || '%' ESCAPE '\'
358               ORDER BY timestamp DESC
359               LIMIT ?2"#,
360        )?;
361
362        // Escape SQL LIKE special chars
363        let escaped = prefix
364            .replace('\\', "\\\\")
365            .replace('%', "\\%")
366            .replace('_', "\\_");
367
368        let entries = stmt.query_map(params![escaped, limit as i64], |row| {
369            Ok(HistoryEntry {
370                id: row.get(0)?,
371                command: row.get(1)?,
372                timestamp: row.get(2)?,
373                duration_ms: row.get(3)?,
374                exit_code: row.get(4)?,
375                cwd: row.get(5)?,
376                frequency: row.get(6)?,
377            })
378        })?;
379
380        entries.collect()
381    }
382
383    /// Case-SENSITIVE prefix probe for the per-keystroke ZLE paths
384    /// (autosuggest, up-arrow search). Uses GLOB, which is case-sensitive and
385    /// therefore eligible for the SQLite prefix optimization over
386    /// `idx_history_command` (BINARY collation). `search_prefix`'s LIKE is
387    /// case-insensitive by default and CANNOT use that index — at 500k+ rows
388    /// it full-scans, which is far too slow to run on every keystroke.
389    pub fn search_prefix_cs(
390        &self,
391        prefix: &str,
392        limit: usize,
393    ) -> rusqlite::Result<Vec<HistoryEntry>> {
394        if prefix.is_empty() {
395            return self.recent(limit);
396        }
397
398        let mut stmt = self.conn.prepare_cached(
399            r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
400               FROM history
401               WHERE command GLOB ?1
402               ORDER BY timestamp DESC
403               LIMIT ?2"#,
404        )?;
405
406        // Escape GLOB metachars via character classes ('[' first — the
407        // replacement brackets must not themselves get re-escaped).
408        let escaped = prefix
409            .replace('[', "[[]")
410            .replace('*', "[*]")
411            .replace('?', "[?]");
412        let pattern = format!("{escaped}*");
413
414        let entries = stmt.query_map(params![pattern, limit as i64], |row| {
415            Ok(HistoryEntry {
416                id: row.get(0)?,
417                command: row.get(1)?,
418                timestamp: row.get(2)?,
419                duration_ms: row.get(3)?,
420                exit_code: row.get(4)?,
421                cwd: row.get(5)?,
422                frequency: row.get(6)?,
423            })
424        })?;
425
426        entries.collect()
427    }
428
429    /// Get recent history entries
430    pub fn recent(&self, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
431        let mut stmt = self.conn.prepare(
432            r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
433               FROM history
434               ORDER BY timestamp DESC
435               LIMIT ?1"#,
436        )?;
437
438        let entries = stmt.query_map(params![limit as i64], |row| {
439            Ok(HistoryEntry {
440                id: row.get(0)?,
441                command: row.get(1)?,
442                timestamp: row.get(2)?,
443                duration_ms: row.get(3)?,
444                exit_code: row.get(4)?,
445                cwd: row.get(5)?,
446                frequency: row.get(6)?,
447            })
448        })?;
449
450        entries.collect()
451    }
452
453    /// Get history for a specific directory
454    pub fn for_directory(&self, cwd: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
455        let mut stmt = self.conn.prepare(
456            r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
457               FROM history
458               WHERE cwd = ?1
459               ORDER BY frequency DESC, timestamp DESC
460               LIMIT ?2"#,
461        )?;
462
463        let entries = stmt.query_map(params![cwd, limit as i64], |row| {
464            Ok(HistoryEntry {
465                id: row.get(0)?,
466                command: row.get(1)?,
467                timestamp: row.get(2)?,
468                duration_ms: row.get(3)?,
469                exit_code: row.get(4)?,
470                cwd: row.get(5)?,
471                frequency: row.get(6)?,
472            })
473        })?;
474
475        entries.collect()
476    }
477
478    /// Delete a history entry
479    pub fn delete(&self, id: i64) -> rusqlite::Result<()> {
480        self.conn
481            .execute("DELETE FROM history WHERE id = ?1", params![id])?;
482        Ok(())
483    }
484
485    /// Clear all history
486    pub fn clear(&self) -> rusqlite::Result<()> {
487        self.conn.execute("DELETE FROM history", [])?;
488        Ok(())
489    }
490
491    /// Get total history count
492    pub fn count(&self) -> rusqlite::Result<i64> {
493        self.conn
494            .query_row("SELECT COUNT(*) FROM history", [], |row| row.get(0))
495    }
496
497    /// Get entry by index from end (0 = most recent, like !-1)
498    pub fn get_by_offset(&self, offset: usize) -> rusqlite::Result<Option<HistoryEntry>> {
499        let mut stmt = self.conn.prepare(
500            r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
501               FROM history
502               ORDER BY timestamp DESC
503               LIMIT 1 OFFSET ?1"#,
504        )?;
505
506        let mut rows = stmt.query(params![offset as i64])?;
507        if let Some(row) = rows.next()? {
508            Ok(Some(HistoryEntry {
509                id: row.get(0)?,
510                command: row.get(1)?,
511                timestamp: row.get(2)?,
512                duration_ms: row.get(3)?,
513                exit_code: row.get(4)?,
514                cwd: row.get(5)?,
515                frequency: row.get(6)?,
516            }))
517        } else {
518            Ok(None)
519        }
520    }
521
522    /// Get entry by absolute history number (like !123)
523    pub fn get_by_number(&self, num: i64) -> rusqlite::Result<Option<HistoryEntry>> {
524        let mut stmt = self.conn.prepare(
525            r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
526               FROM history
527               WHERE id = ?1"#,
528        )?;
529
530        let mut rows = stmt.query(params![num])?;
531        if let Some(row) = rows.next()? {
532            Ok(Some(HistoryEntry {
533                id: row.get(0)?,
534                command: row.get(1)?,
535                timestamp: row.get(2)?,
536                duration_ms: row.get(3)?,
537                exit_code: row.get(4)?,
538                cwd: row.get(5)?,
539                frequency: row.get(6)?,
540            }))
541        } else {
542            Ok(None)
543        }
544    }
545}
546
547// ---------------------------------------------------------------------
548// Interactive-session sink — the SQLite index is zshrs's DEFAULT
549// history store; the flat $HISTFILE path in ported hist.rs stays fully
550// functional for users who override via HISTFILE/SAVEHIST. `hend()`
551// (src/ported/hist.rs) calls `history_sqlite_add` for every accepted
552// interactive line (same accept policy as the $HISTFILE write:
553// HIST_TMPSTORE / HIST_NOWRITE excluded); `preprompt()`
554// (src/ported/utils.rs) calls `history_sqlite_finish` right after
555// execode returns to stamp duration + exit status — mirroring what the
556// `-c` path does via ShellExecutor.history at bins/zshrs.rs
557// (engine.add → update_last). Thread-local because
558// rusqlite::Connection is !Sync; the interactive loop is
559// single-threaded on the main thread.
560// ---------------------------------------------------------------------
561
562thread_local! {
563    /// Lazily-opened engine for the interactive loop. Outer Option =
564    /// "tried to open yet?", inner Option = open result (None when the
565    /// db can't be opened — sink becomes a no-op, never an error).
566    static SESSION_HISTORY: std::cell::RefCell<Option<Option<HistoryEngine>>> =
567        const { std::cell::RefCell::new(None) };
568    /// Row id + start instant of the line whose command is currently
569    /// executing, armed by `history_sqlite_add`, consumed by
570    /// `history_sqlite_finish`.
571    static HIST_PENDING: std::cell::Cell<Option<(i64, std::time::Instant)>> =
572        const { std::cell::Cell::new(None) };
573}
574
575pub fn with_session_engine<R>(f: impl FnOnce(&HistoryEngine) -> R) -> Option<R> {
576    SESSION_HISTORY.with(|cell| {
577        let mut slot = cell.borrow_mut();
578        if slot.is_none() {
579            *slot = Some(HistoryEngine::new().ok());
580        }
581        slot.as_ref().unwrap().as_ref().map(f)
582    })
583}
584
585/// Append an accepted interactive line to the SQLite history index
586/// (+ text mirror) and arm the pending row for `history_sqlite_finish`.
587pub fn history_sqlite_add(line: &str) {
588    let cwd = std::env::current_dir()
589        .ok()
590        .map(|p| p.to_string_lossy().to_string());
591    let id = with_session_engine(|e| e.add(line, cwd.as_deref()).ok()).flatten();
592    if let Some(id) = id {
593        HIST_PENDING.with(|p| p.set(Some((id, std::time::Instant::now()))));
594    }
595}
596
597/// Stamp duration + exit status onto the row `history_sqlite_add`
598/// armed. No-op when nothing is pending.
599pub fn history_sqlite_finish(exit_code: i32) {
600    let Some((id, start)) = HIST_PENDING.with(|p| p.take()) else {
601        return;
602    };
603    let dur = start.elapsed().as_millis() as i64;
604    let _ = with_session_engine(|e| e.update_last(id, dur, exit_code));
605}
606
607/// Detect whether `path` is a sqlite database by sniffing the magic
608/// header (first 16 bytes start with `SQLite format 3\0`). Errors /
609/// short files / unknown content all return false (safe default —
610/// leave unknown content alone).
611fn is_sqlite_file(path: &std::path::Path) -> bool {
612    let mut f = match std::fs::File::open(path) {
613        Ok(f) => f,
614        Err(_) => return false,
615    };
616    let mut header = [0u8; 16];
617    if f.read_exact(&mut header).is_err() {
618        return false;
619    }
620    &header == b"SQLite format 3\0"
621}
622
623/// Format one zsh-extended-history line:
624///
625/// ```text
626/// : <unix_ts>:<duration>;<command>\n
627/// ```
628///
629/// Multi-line commands escape literal `\n` to the two-character
630/// sequence `\\n` so each entry stays on a single line; the unescape
631/// is the inverse done at read time. Matches what zsh writes when
632/// `EXTENDED_HISTORY` is set (zsh/Src/hist.c:savehistfile).
633fn format_text_line(ts: i64, duration_secs: i64, command: &str) -> String {
634    let escaped = command.replace('\\', "\\\\").replace('\n', "\\\n");
635    format!(": {}:{};{}\n", ts, duration_secs, escaped)
636}
637
638/// Append one line to `$ZSHRS_HOME/zshrs_history`.
639fn append_text_line(ts: i64, duration_secs: i64, command: &str) -> std::io::Result<()> {
640    let path = HistoryEngine::text_path();
641    if let Some(parent) = path.parent() {
642        std::fs::create_dir_all(parent).ok();
643    }
644    let line = format_text_line(ts, duration_secs, command);
645    let mut f = std::fs::OpenOptions::new()
646        .create(true)
647        .append(true)
648        .open(&path)?;
649    f.write_all(line.as_bytes())
650}
651
652/// Rewrite the trailing entry of the text file in place — used by
653/// `update_last` once the duration is known. Strategy: read the file
654/// to the last newline-delimited record, replace it with a freshly
655/// formatted line. For multi-MB history files we only buffer the
656/// trailing record's tail bytes (`max_tail` cap) — anything older
657/// stays untouched on disk.
658fn rewrite_last_text_line(ts: i64, duration_secs: i64, command: &str) -> std::io::Result<()> {
659    let path = HistoryEngine::text_path();
660    let mut f = std::fs::OpenOptions::new()
661        .read(true)
662        .write(true)
663        .open(&path)?;
664    let len = f.metadata()?.len();
665    // 64 KiB is enough for any realistic single-command record (zsh
666    // commands top out at ~1-4 KiB). Beyond that, give up and append
667    // a corrected line rather than risk truncating the file.
668    let max_tail = 65_536u64.min(len);
669    let read_from = len - max_tail;
670    f.seek(SeekFrom::Start(read_from))?;
671    let mut tail = Vec::with_capacity(max_tail as usize);
672    f.read_to_end(&mut tail)?;
673    // Find the offset (within `tail`) where the last record begins.
674    // A record begins at the byte AFTER the second-to-last newline,
675    // or at offset 0 if there is none.
676    let mut last_record_start = 0usize;
677    let mut nl_count = 0;
678    for (i, b) in tail.iter().enumerate().rev() {
679        if *b == b'\n' {
680            nl_count += 1;
681            if nl_count == 2 {
682                last_record_start = i + 1;
683                break;
684            }
685        }
686    }
687    let new_record = format_text_line(ts, duration_secs, command);
688    let new_abs = read_from + last_record_start as u64;
689    f.seek(SeekFrom::Start(new_abs))?;
690    f.write_all(new_record.as_bytes())?;
691    let new_len = new_abs + new_record.len() as u64;
692    if new_len < len {
693        f.set_len(new_len)?;
694    }
695    Ok(())
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701
702    #[test]
703    fn test_add_and_search() {
704        let _g = crate::test_util::global_state_lock();
705        let engine = HistoryEngine::in_memory().unwrap();
706
707        engine.add("ls -la", Some("/home/user")).unwrap();
708        engine.add("cd /tmp", Some("/home/user")).unwrap();
709        engine.add("echo hello", Some("/tmp")).unwrap();
710
711        // Use prefix search for short queries (trigram FTS5 needs 3+ chars)
712        let results = engine.search_prefix("ls", 10).unwrap();
713        assert_eq!(results.len(), 1);
714        assert_eq!(results[0].command, "ls -la");
715    }
716
717    #[test]
718    fn test_frequency_tracking() {
719        let _g = crate::test_util::global_state_lock();
720        let engine = HistoryEngine::in_memory().unwrap();
721
722        engine.add("git status", None).unwrap();
723        engine.add("git status", None).unwrap();
724        engine.add("git status", None).unwrap();
725
726        let results = engine.recent(10).unwrap();
727        assert_eq!(results.len(), 1);
728        assert_eq!(results[0].frequency, 3);
729    }
730
731    #[test]
732    fn test_prefix_search() {
733        let _g = crate::test_util::global_state_lock();
734        let engine = HistoryEngine::in_memory().unwrap();
735
736        engine.add("git status", None).unwrap();
737        engine.add("git commit -m 'test'", None).unwrap();
738        engine.add("grep foo bar", None).unwrap();
739
740        let results = engine.search_prefix("git", 10).unwrap();
741        assert_eq!(results.len(), 2);
742    }
743
744    #[test]
745    fn test_directory_history() {
746        let _g = crate::test_util::global_state_lock();
747        let engine = HistoryEngine::in_memory().unwrap();
748
749        engine.add("make build", Some("/project")).unwrap();
750        engine.add("cargo test", Some("/project")).unwrap();
751        engine.add("ls", Some("/tmp")).unwrap();
752
753        let results = engine.for_directory("/project", 10).unwrap();
754        assert_eq!(results.len(), 2);
755    }
756
757    // ========================================================
758    // format_text_line — zsh EXTENDED_HISTORY line emission
759    // ========================================================
760
761    #[test]
762    fn format_text_line_emits_canonical_prefix() {
763        let line = format_text_line(1_700_000_000, 5, "echo hi");
764        assert_eq!(line, ": 1700000000:5;echo hi\n");
765    }
766
767    #[test]
768    fn format_text_line_zero_duration_still_renders() {
769        let line = format_text_line(0, 0, "true");
770        assert_eq!(line, ": 0:0;true\n");
771    }
772
773    #[test]
774    fn format_text_line_escapes_literal_backslash() {
775        // `\` doubles so the inverse unescape recovers the original.
776        let line = format_text_line(1, 0, r"\n");
777        // Source `\n` → escaped `\\n`.
778        assert!(
779            line.ends_with(";\\\\n\n"),
780            "expected backslash-escape, got: {:?}",
781            line
782        );
783    }
784
785    #[test]
786    fn format_text_line_escapes_embedded_newline_to_backslash_newline() {
787        // Multi-line commands escape literal newlines so each
788        // logical entry stays on one disk line for read-back.
789        let line = format_text_line(2, 1, "line1\nline2");
790        // After replacement: `\\\n` = backslash then newline.
791        assert!(
792            line.contains("line1\\\nline2"),
793            "expected escaped newline, got: {:?}",
794            line
795        );
796        // Still terminates with one trailing real newline.
797        assert!(line.ends_with('\n'));
798    }
799
800    #[test]
801    fn format_text_line_handles_empty_command() {
802        let line = format_text_line(42, 7, "");
803        assert_eq!(line, ": 42:7;\n");
804    }
805
806    #[test]
807    fn format_text_line_negative_duration_round_trips() {
808        // Defensive: negative durations are unusual but represent
809        // "duration unknown" sentinels in some pre-commit paths.
810        let line = format_text_line(100, -1, "foo");
811        assert_eq!(line, ": 100:-1;foo\n");
812    }
813
814    #[test]
815    fn format_text_line_only_terminating_newline_present() {
816        // Exactly one trailing `\n` regardless of command bytes.
817        let line = format_text_line(1, 1, "abc");
818        let nls = line.matches('\n').count();
819        assert_eq!(nls, 1);
820    }
821
822    // ========================================================
823    // is_sqlite_file — magic-header sniff
824    // ========================================================
825
826    #[test]
827    fn is_sqlite_file_true_for_real_header() {
828        let _g = crate::test_util::global_state_lock();
829        let tmp = std::env::temp_dir().join("zshrs_history_sqlite_magic.bin");
830        std::fs::write(&tmp, b"SQLite format 3\0extra junk after").unwrap();
831        assert!(is_sqlite_file(&tmp));
832        let _ = std::fs::remove_file(&tmp);
833    }
834
835    #[test]
836    fn is_sqlite_file_false_for_plain_text() {
837        let _g = crate::test_util::global_state_lock();
838        let tmp = std::env::temp_dir().join("zshrs_history_text_not_db.txt");
839        std::fs::write(&tmp, b": 1700000000:5;ls -la\n").unwrap();
840        assert!(!is_sqlite_file(&tmp));
841        let _ = std::fs::remove_file(&tmp);
842    }
843
844    #[test]
845    fn is_sqlite_file_false_for_short_file() {
846        let _g = crate::test_util::global_state_lock();
847        let tmp = std::env::temp_dir().join("zshrs_history_short.bin");
848        std::fs::write(&tmp, b"abc").unwrap();
849        // Less than 16 bytes — `read_exact` fails → false.
850        assert!(!is_sqlite_file(&tmp));
851        let _ = std::fs::remove_file(&tmp);
852    }
853
854    #[test]
855    fn is_sqlite_file_false_for_missing_path() {
856        let _g = crate::test_util::global_state_lock();
857        assert!(!is_sqlite_file(std::path::Path::new(
858            "/nonexistent/zshrs/sqlite/magic.db"
859        )));
860    }
861
862    #[test]
863    fn is_sqlite_file_false_for_almost_matching_header() {
864        // 16 bytes but not the magic — must reject.
865        let _g = crate::test_util::global_state_lock();
866        let tmp = std::env::temp_dir().join("zshrs_history_fake_magic.bin");
867        std::fs::write(&tmp, b"SQLite format 4\0").unwrap();
868        assert!(!is_sqlite_file(&tmp));
869        let _ = std::fs::remove_file(&tmp);
870    }
871
872    // ========================================================
873    // HistoryEntry / engine — semantic coverage
874    // ========================================================
875
876    #[test]
877    fn search_prefix_respects_limit() {
878        let _g = crate::test_util::global_state_lock();
879        let engine = HistoryEngine::in_memory().unwrap();
880        for n in 0..5 {
881            engine.add(&format!("git-{}", n), None).unwrap();
882        }
883        let results = engine.search_prefix("git-", 2).unwrap();
884        assert!(results.len() <= 2, "limit not honored: {}", results.len());
885    }
886}