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::path::PathBuf;
23use std::time::{SystemTime, UNIX_EPOCH};
24use std::io::Write as _;
25use std::io::Read;
26use std::io::Write;
27use std::io::{Seek, SeekFrom};
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: Connection,
36}
37
38/// One history record.
39/// Port of `struct histent` from Src/zsh.h (`text` / `stim` /
40/// `ftim` fields) plus zshrs additions (`exit_code`, `cwd`,
41/// `frequency`, `duration_ms`) the SQLite schema captures from
42/// the `precmd`/`preexec` hooks.
43#[derive(Debug, Clone)]
44pub struct HistoryEntry {
45    pub id: i64,
46    pub command: String,
47    pub timestamp: i64,
48    pub duration_ms: Option<i64>,
49    pub exit_code: Option<i32>,
50    pub cwd: Option<String>,
51    pub frequency: u32,
52}
53
54impl HistoryEngine {
55    pub fn new() -> rusqlite::Result<Self> {
56        let path = Self::db_path();
57        if let Some(parent) = path.parent() {
58            std::fs::create_dir_all(parent).ok();
59        }
60
61        // One-shot migration. Two legacy sources, in priority order:
62        //   1. The previous shell-side path at
63        //      `$ZSHRS_HOME/zshrs_history` — was a sqlite db, briefly
64        //      named without the `.db` suffix. Move it into place.
65        //   2. The pre-2026-05-03 location at
66        //      `~/Library/Application Support/zshrs/history.db`
67        //      (macOS) / `$XDG_DATA_HOME/zshrs/history.db` (Linux).
68        //      Copy (don't move) so users who roll back keep the old
69        //      file readable.
70        // Both fire only when zshrs_history.db does not yet exist —
71        // never overwrites populated state. Schema is identical (same
72        // writer at every age), so byte-level copy is sufficient.
73        if !path.exists() {
74            let prev_inplace = Self::root().join("zshrs_history");
75            // Only move-rename if the file is actually a sqlite db —
76            // protects users who already created a flat-text
77            // `zshrs_history` by hand.
78            if prev_inplace.exists() && is_sqlite_file(&prev_inplace) {
79                match std::fs::rename(&prev_inplace, &path) {
80                    Ok(()) => tracing::info!(
81                        from = %prev_inplace.display(),
82                        to = %path.display(),
83                        "history: renamed legacy zshrs_history -> zshrs_history.db"
84                    ),
85                    Err(e) => tracing::warn!(
86                        ?e,
87                        "history: rename legacy zshrs_history failed"
88                    ),
89                }
90            }
91        }
92        if !path.exists() {
93            if let Some(legacy) = legacy_db_path() {
94                if legacy.exists() {
95                    if let Err(e) = std::fs::copy(&legacy, &path) {
96                        tracing::warn!(
97                            from = %legacy.display(),
98                            to = %path.display(),
99                            error = %e,
100                            "history: migrate from legacy path failed; starting empty"
101                        );
102                    } else {
103                        tracing::info!(
104                            from = %legacy.display(),
105                            to = %path.display(),
106                            "history: migrated from legacy path"
107                        );
108                    }
109                }
110            }
111        }
112
113        let conn = Connection::open(&path)?;
114        let engine = Self { conn };
115        engine.init_schema()?;
116        let count = engine.count().unwrap_or(0);
117        let db_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
118        tracing::info!(
119            entries = count,
120            db_bytes = db_size,
121            path = %path.display(),
122            "history: sqlite opened"
123        );
124
125        // Rehydrate the flat text mirror from the sqlite index when
126        // the text file is missing or stale (size 0 with a populated
127        // db — happens after the rename migration above moves the
128        // user's old `zshrs_history` to `.db`). Cheap: one-shot
129        // chronological dump, no FTS / no joins.
130        if let Err(e) = engine.rehydrate_text_if_stale() {
131            tracing::warn!(?e, "history: failed to rehydrate text mirror; continuing");
132        }
133        Ok(engine)
134    }
135
136    pub fn in_memory() -> rusqlite::Result<Self> {
137        let conn = Connection::open_in_memory()?;
138        let engine = Self { conn };
139        engine.init_schema()?;
140        Ok(engine)
141    }
142
143    // Helpers below are inherent associated functions; see free
144    // `legacy_db_path()` outside the impl block for the migration source.
145
146    /// `$ZSHRS_HOME/zshrs_history.db` — sqlite index that powers FTS5
147    /// search, frequency tracking, dedup. Hidden under `.db` so the
148    /// user-facing artifact is the flat-text mirror at
149    /// `zshrs_history` (see `text_path`), zsh-compatible so muscle
150    /// memory + `cat` / `grep` / external history tools all keep
151    /// working.
152    ///
153    /// The daemon owns its OWN history db at `~/.zshrs/history.db`
154    /// (different schema, daemon-only writer); shells append to it via
155    /// `history_append` IPC. This shell-side db is the fallback path
156    /// used when the daemon is absent.
157    fn db_path() -> PathBuf {
158        Self::root().join("zshrs_history.db")
159    }
160
161    /// `$ZSHRS_HOME/zshrs_history` — flat text mirror, one line per
162    /// command in zsh extended-history format:
163    ///
164    /// ```text
165    /// : <unix_ts>:<duration>;<command>
166    /// ```
167    ///
168    /// Newlines inside multi-line commands are escaped as the literal
169    /// two-character sequence `\\n` (matches `setopt EXTENDED_HISTORY`
170    /// — `zsh/Src/hist.c:gethistent`). Every `add` appends one line;
171    /// `update_last` rewrites the trailing line in place when the
172    /// duration becomes known. The sqlite index at `zshrs_history.db`
173    /// is the query-side mirror of this file — they're kept in lockstep
174    /// by the writer, and a divergence-repair pass on open re-reads
175    /// the text file if the sqlite is missing or older.
176    pub fn text_path() -> PathBuf {
177        Self::root().join("zshrs_history")
178    }
179
180    fn root() -> PathBuf {
181        if let Some(custom) = std::env::var_os("ZSHRS_HOME") {
182            PathBuf::from(custom)
183        } else {
184            dirs::home_dir()
185                .unwrap_or_else(|| PathBuf::from("."))
186                .join(".zshrs")
187        }
188    }
189
190    fn init_schema(&self) -> rusqlite::Result<()> {
191        self.conn.execute_batch(r#"
192            CREATE TABLE IF NOT EXISTS history (
193                id INTEGER PRIMARY KEY,
194                command TEXT NOT NULL,
195                timestamp INTEGER NOT NULL,
196                duration_ms INTEGER,
197                exit_code INTEGER,
198                cwd TEXT,
199                frequency INTEGER DEFAULT 1
200            );
201
202            CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp DESC);
203            CREATE INDEX IF NOT EXISTS idx_history_cwd ON history(cwd);
204            CREATE UNIQUE INDEX IF NOT EXISTS idx_history_command ON history(command);
205
206            CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5(
207                command,
208                content='history',
209                content_rowid='id',
210                tokenize='trigram'
211            );
212
213            CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
214                INSERT INTO history_fts(rowid, command) VALUES (new.id, new.command);
215            END;
216
217            CREATE TRIGGER IF NOT EXISTS history_ad AFTER DELETE ON history BEGIN
218                INSERT INTO history_fts(history_fts, rowid, command) VALUES('delete', old.id, old.command);
219            END;
220
221            CREATE TRIGGER IF NOT EXISTS history_au AFTER UPDATE ON history BEGIN
222                INSERT INTO history_fts(history_fts, rowid, command) VALUES('delete', old.id, old.command);
223                INSERT INTO history_fts(rowid, command) VALUES (new.id, new.command);
224            END;
225        "#)?;
226        Ok(())
227    }
228
229    fn now() -> i64 {
230        SystemTime::now()
231            .duration_since(UNIX_EPOCH)
232            .map(|d| d.as_secs() as i64)
233            .unwrap_or(0)
234    }
235
236    /// Add a command to history, updating frequency if it already exists
237    pub fn add(&self, command: &str, cwd: Option<&str>) -> rusqlite::Result<i64> {
238        let command = command.trim();
239        if command.is_empty() || command.starts_with(' ') {
240            return Ok(0);
241        }
242
243        let now = Self::now();
244
245        // Try to update existing entry
246        let updated = self.conn.execute(
247            "UPDATE history SET timestamp = ?1, frequency = frequency + 1, cwd = COALESCE(?2, cwd)
248             WHERE command = ?3",
249            params![now, cwd, command],
250        )?;
251
252        if updated > 0 {
253            // Return the existing ID
254            let id: i64 = self.conn.query_row(
255                "SELECT id FROM history WHERE command = ?1",
256                params![command],
257                |row| row.get(0),
258            )?;
259            return Ok(id);
260        }
261
262        // Insert new entry
263        self.conn.execute(
264            "INSERT INTO history (command, timestamp, cwd) VALUES (?1, ?2, ?3)",
265            params![command, now, cwd],
266        )?;
267
268        let id = self.conn.last_insert_rowid();
269
270        // Mirror to the flat zsh-extended-history file. Best-effort —
271        // a write failure here doesn't fail the sqlite insert (e.g.
272        // disk full mid-write should still let the shell record state
273        // in the index). The duration is unknown at this point;
274        // `update_last` rewrites the trailing line once it knows.
275        if let Err(e) = append_text_line(now, 0, command) {
276            tracing::warn!(?e, "history: text mirror append failed");
277        }
278
279        Ok(id)
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        // Update the trailing line of the text mirror with the now-known
290        // duration. Look up the command by id so the rewrite stays
291        // consistent even if `add` deduped to an earlier entry.
292        if let Ok((ts, command)) = self.conn.query_row(
293            "SELECT timestamp, command FROM history WHERE id = ?1",
294            params![id],
295            |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
296        ) {
297            let duration_secs = (duration_ms / 1000).max(0);
298            if let Err(e) = rewrite_last_text_line(ts, duration_secs, &command) {
299                tracing::warn!(?e, "history: text mirror update failed");
300            }
301        }
302        Ok(())
303    }
304
305    /// If the text mirror is missing or empty but the sqlite db has
306    /// entries, dump the db chronologically into the text file. Used
307    /// by `new()` after the first-time rename migration so users get
308    /// the full backlog in the user-facing text file from day one.
309    fn rehydrate_text_if_stale(&self) -> rusqlite::Result<()> {
310        let text = Self::text_path();
311        let text_size = std::fs::metadata(&text).map(|m| m.len()).unwrap_or(0);
312        if text_size > 0 {
313            return Ok(());
314        }
315        let count: i64 = self.conn.query_row("SELECT COUNT(*) FROM history", [], |r| r.get(0))?;
316        if count == 0 {
317            return Ok(());
318        }
319        let mut stmt = self.conn.prepare(
320            "SELECT timestamp, COALESCE(duration_ms, 0), command \
321             FROM history ORDER BY timestamp ASC, id ASC",
322        )?;
323        let rows = stmt.query_map([], |r| {
324            Ok((
325                r.get::<_, i64>(0)?,
326                r.get::<_, i64>(1)?,
327                r.get::<_, String>(2)?,
328            ))
329        })?;
330        let file = std::fs::OpenOptions::new()
331            .create(true)
332            .truncate(true)
333            .write(true)
334            .open(&text)
335            .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
336        let mut w = std::io::BufWriter::new(file);
337        let mut written: u64 = 0;
338        for row in rows {
339            let (ts, dur_ms, cmd) = row?;
340            let line = format_text_line(ts, (dur_ms / 1000).max(0), &cmd);
341            w.write_all(line.as_bytes())
342                .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
343            written += 1;
344        }
345        w.flush()
346            .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
347        tracing::info!(
348            entries = written,
349            path = %text.display(),
350            "history: rehydrated text mirror from sqlite index"
351        );
352        Ok(())
353    }
354
355    /// Search history with FTS5 (fuzzy/substring matching)
356    pub fn search(&self, query: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
357        if query.is_empty() {
358            return self.recent(limit);
359        }
360
361        // Escape special FTS5 characters and use prefix matching
362        let escaped = query.replace('"', "\"\"");
363        let fts_query = format!("\"{}\"*", escaped);
364
365        let mut stmt = self.conn.prepare(
366            r#"SELECT h.id, h.command, h.timestamp, h.duration_ms, h.exit_code, h.cwd, h.frequency
367               FROM history h
368               JOIN history_fts f ON h.id = f.rowid
369               WHERE history_fts MATCH ?1
370               ORDER BY h.frequency DESC, h.timestamp DESC
371               LIMIT ?2"#,
372        )?;
373
374        let entries = stmt.query_map(params![fts_query, limit as i64], |row| {
375            Ok(HistoryEntry {
376                id: row.get(0)?,
377                command: row.get(1)?,
378                timestamp: row.get(2)?,
379                duration_ms: row.get(3)?,
380                exit_code: row.get(4)?,
381                cwd: row.get(5)?,
382                frequency: row.get(6)?,
383            })
384        })?;
385
386        entries.collect()
387    }
388
389    /// Search history with prefix matching (for up-arrow completion)
390    pub fn search_prefix(&self, prefix: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
391        if prefix.is_empty() {
392            return self.recent(limit);
393        }
394
395        let mut stmt = self.conn.prepare(
396            r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
397               FROM history
398               WHERE command LIKE ?1 || '%' ESCAPE '\'
399               ORDER BY timestamp DESC
400               LIMIT ?2"#,
401        )?;
402
403        // Escape SQL LIKE special chars
404        let escaped = prefix
405            .replace('\\', "\\\\")
406            .replace('%', "\\%")
407            .replace('_', "\\_");
408
409        let entries = stmt.query_map(params![escaped, limit as i64], |row| {
410            Ok(HistoryEntry {
411                id: row.get(0)?,
412                command: row.get(1)?,
413                timestamp: row.get(2)?,
414                duration_ms: row.get(3)?,
415                exit_code: row.get(4)?,
416                cwd: row.get(5)?,
417                frequency: row.get(6)?,
418            })
419        })?;
420
421        entries.collect()
422    }
423
424    /// Get recent history entries
425    pub fn recent(&self, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
426        let mut stmt = self.conn.prepare(
427            r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
428               FROM history
429               ORDER BY timestamp DESC
430               LIMIT ?1"#,
431        )?;
432
433        let entries = stmt.query_map(params![limit as i64], |row| {
434            Ok(HistoryEntry {
435                id: row.get(0)?,
436                command: row.get(1)?,
437                timestamp: row.get(2)?,
438                duration_ms: row.get(3)?,
439                exit_code: row.get(4)?,
440                cwd: row.get(5)?,
441                frequency: row.get(6)?,
442            })
443        })?;
444
445        entries.collect()
446    }
447
448    /// Get history for a specific directory
449    pub fn for_directory(&self, cwd: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
450        let mut stmt = self.conn.prepare(
451            r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
452               FROM history
453               WHERE cwd = ?1
454               ORDER BY frequency DESC, timestamp DESC
455               LIMIT ?2"#,
456        )?;
457
458        let entries = stmt.query_map(params![cwd, limit as i64], |row| {
459            Ok(HistoryEntry {
460                id: row.get(0)?,
461                command: row.get(1)?,
462                timestamp: row.get(2)?,
463                duration_ms: row.get(3)?,
464                exit_code: row.get(4)?,
465                cwd: row.get(5)?,
466                frequency: row.get(6)?,
467            })
468        })?;
469
470        entries.collect()
471    }
472
473    /// Delete a history entry
474    pub fn delete(&self, id: i64) -> rusqlite::Result<()> {
475        self.conn
476            .execute("DELETE FROM history WHERE id = ?1", params![id])?;
477        Ok(())
478    }
479
480    /// Clear all history
481    pub fn clear(&self) -> rusqlite::Result<()> {
482        self.conn.execute("DELETE FROM history", [])?;
483        Ok(())
484    }
485
486    /// Get total history count
487    pub fn count(&self) -> rusqlite::Result<i64> {
488        self.conn
489            .query_row("SELECT COUNT(*) FROM history", [], |row| row.get(0))
490    }
491
492    /// Get entry by index from end (0 = most recent, like !-1)
493    pub fn get_by_offset(&self, offset: usize) -> rusqlite::Result<Option<HistoryEntry>> {
494        let mut stmt = self.conn.prepare(
495            r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
496               FROM history
497               ORDER BY timestamp DESC
498               LIMIT 1 OFFSET ?1"#,
499        )?;
500
501        let mut rows = stmt.query(params![offset as i64])?;
502        if let Some(row) = rows.next()? {
503            Ok(Some(HistoryEntry {
504                id: row.get(0)?,
505                command: row.get(1)?,
506                timestamp: row.get(2)?,
507                duration_ms: row.get(3)?,
508                exit_code: row.get(4)?,
509                cwd: row.get(5)?,
510                frequency: row.get(6)?,
511            }))
512        } else {
513            Ok(None)
514        }
515    }
516
517    /// Get entry by absolute history number (like !123)
518    pub fn get_by_number(&self, num: i64) -> rusqlite::Result<Option<HistoryEntry>> {
519        let mut stmt = self.conn.prepare(
520            r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
521               FROM history
522               WHERE id = ?1"#,
523        )?;
524
525        let mut rows = stmt.query(params![num])?;
526        if let Some(row) = rows.next()? {
527            Ok(Some(HistoryEntry {
528                id: row.get(0)?,
529                command: row.get(1)?,
530                timestamp: row.get(2)?,
531                duration_ms: row.get(3)?,
532                exit_code: row.get(4)?,
533                cwd: row.get(5)?,
534                frequency: row.get(6)?,
535            }))
536        } else {
537            Ok(None)
538        }
539    }
540}
541
542/// Pre-2026-05-03 history db location. Returned only when the legacy
543/// file actually exists — used by `HistoryEngine::new` to migrate
544/// once into `$ZSHRS_HOME/zshrs_history.db`. Returns None if
545/// `dirs::data_dir` can't resolve (no $HOME / no platform data dir).
546fn legacy_db_path() -> Option<PathBuf> {
547    Some(dirs::data_dir()?.join("zshrs").join("history.db"))
548}
549
550/// Detect whether `path` is a sqlite database by sniffing the magic
551/// header (first 16 bytes start with `SQLite format 3\0`). Used by
552/// the rename-migration to avoid clobbering a user's hand-written
553/// flat text file. Errors / short files / unknown content all return
554/// false (safe default — leave unknown content alone).
555fn is_sqlite_file(path: &std::path::Path) -> bool {
556    let mut f = match std::fs::File::open(path) {
557        Ok(f) => f,
558        Err(_) => return false,
559    };
560    let mut header = [0u8; 16];
561    if f.read_exact(&mut header).is_err() {
562        return false;
563    }
564    &header == b"SQLite format 3\0"
565}
566
567/// Format one zsh-extended-history line:
568///
569/// ```text
570/// : <unix_ts>:<duration>;<command>\n
571/// ```
572///
573/// Multi-line commands escape literal `\n` to the two-character
574/// sequence `\\n` so each entry stays on a single line; the unescape
575/// is the inverse done at read time. Matches what zsh writes when
576/// `EXTENDED_HISTORY` is set (zsh/Src/hist.c:savehistfile).
577fn format_text_line(ts: i64, duration_secs: i64, command: &str) -> String {
578    let escaped = command.replace('\\', "\\\\").replace('\n', "\\\n");
579    format!(": {}:{};{}\n", ts, duration_secs, escaped)
580}
581
582/// Append one line to `$ZSHRS_HOME/zshrs_history`.
583fn append_text_line(ts: i64, duration_secs: i64, command: &str) -> std::io::Result<()> {
584    let path = HistoryEngine::text_path();
585    if let Some(parent) = path.parent() {
586        std::fs::create_dir_all(parent).ok();
587    }
588    let line = format_text_line(ts, duration_secs, command);
589    let mut f = std::fs::OpenOptions::new()
590        .create(true)
591        .append(true)
592        .open(&path)?;
593    f.write_all(line.as_bytes())
594}
595
596/// Rewrite the trailing entry of the text file in place — used by
597/// `update_last` once the duration is known. Strategy: read the file
598/// to the last newline-delimited record, replace it with a freshly
599/// formatted line. For multi-MB history files we only buffer the
600/// trailing record's tail bytes (`max_tail` cap) — anything older
601/// stays untouched on disk.
602fn rewrite_last_text_line(ts: i64, duration_secs: i64, command: &str) -> std::io::Result<()> {
603    let path = HistoryEngine::text_path();
604    let mut f = std::fs::OpenOptions::new().read(true).write(true).open(&path)?;
605    let len = f.metadata()?.len();
606    // 64 KiB is enough for any realistic single-command record (zsh
607    // commands top out at ~1-4 KiB). Beyond that, give up and append
608    // a corrected line rather than risk truncating the file.
609    let max_tail = 65_536u64.min(len);
610    let read_from = len - max_tail;
611    f.seek(SeekFrom::Start(read_from))?;
612    let mut tail = Vec::with_capacity(max_tail as usize);
613    f.read_to_end(&mut tail)?;
614    // Find the offset (within `tail`) where the last record begins.
615    // A record begins at the byte AFTER the second-to-last newline,
616    // or at offset 0 if there is none.
617    let mut last_record_start = 0usize;
618    let mut nl_count = 0;
619    for (i, b) in tail.iter().enumerate().rev() {
620        if *b == b'\n' {
621            nl_count += 1;
622            if nl_count == 2 {
623                last_record_start = i + 1;
624                break;
625            }
626        }
627    }
628    let new_record = format_text_line(ts, duration_secs, command);
629    let new_abs = read_from + last_record_start as u64;
630    f.seek(SeekFrom::Start(new_abs))?;
631    f.write_all(new_record.as_bytes())?;
632    let new_len = new_abs + new_record.len() as u64;
633    if new_len < len {
634        f.set_len(new_len)?;
635    }
636    Ok(())
637}
638
639/// Adapter exposing `HistoryEngine` to the line editor.
640/// zshrs-original — bridges the SQLite engine into the line-editor
641/// crate. C zsh's equivalent is the per-key history-search /
642/// up-arrow plumbing in Src/Zle/zle_hist.c that walks the
643/// `histent` linked list directly.
644pub struct ReedlineHistory {
645    engine: HistoryEngine,
646    session_history: Vec<String>,
647    cursor: usize,
648}
649
650impl ReedlineHistory {
651    pub fn new() -> rusqlite::Result<Self> {
652        Ok(Self {
653            engine: HistoryEngine::new()?,
654            session_history: Vec::new(),
655            cursor: 0,
656        })
657    }
658
659    pub fn add(&mut self, command: &str) -> rusqlite::Result<i64> {
660        self.session_history.push(command.to_string());
661        self.cursor = self.session_history.len();
662        let cwd = std::env::current_dir()
663            .ok()
664            .map(|p| p.to_string_lossy().to_string());
665        self.engine.add(command, cwd.as_deref())
666    }
667
668    pub fn search(&self, query: &str) -> Vec<String> {
669        self.engine
670            .search(query, 50)
671            .unwrap_or_default()
672            .into_iter()
673            .map(|e| e.command)
674            .collect()
675    }
676
677    pub fn previous(&mut self, prefix: &str) -> Option<String> {
678        if self.cursor == 0 {
679            return None;
680        }
681
682        // Search backwards in session history first
683        for i in (0..self.cursor).rev() {
684            if self.session_history[i].starts_with(prefix) {
685                self.cursor = i;
686                return Some(self.session_history[i].clone());
687            }
688        }
689
690        // Fall back to database
691        self.engine
692            .search_prefix(prefix, 1)
693            .ok()
694            .and_then(|v| v.into_iter().next())
695            .map(|e| e.command)
696    }
697
698    pub fn next(&mut self, prefix: &str) -> Option<String> {
699        if self.cursor >= self.session_history.len() {
700            return None;
701        }
702
703        for i in (self.cursor + 1)..self.session_history.len() {
704            if self.session_history[i].starts_with(prefix) {
705                self.cursor = i;
706                return Some(self.session_history[i].clone());
707            }
708        }
709
710        self.cursor = self.session_history.len();
711        None
712    }
713
714    pub fn reset_cursor(&mut self) {
715        self.cursor = self.session_history.len();
716    }
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722
723    #[test]
724    fn test_add_and_search() {
725        let engine = HistoryEngine::in_memory().unwrap();
726
727        engine.add("ls -la", Some("/home/user")).unwrap();
728        engine.add("cd /tmp", Some("/home/user")).unwrap();
729        engine.add("echo hello", Some("/tmp")).unwrap();
730
731        // Use prefix search for short queries (trigram FTS5 needs 3+ chars)
732        let results = engine.search_prefix("ls", 10).unwrap();
733        assert_eq!(results.len(), 1);
734        assert_eq!(results[0].command, "ls -la");
735    }
736
737    #[test]
738    fn test_frequency_tracking() {
739        let engine = HistoryEngine::in_memory().unwrap();
740
741        engine.add("git status", None).unwrap();
742        engine.add("git status", None).unwrap();
743        engine.add("git status", None).unwrap();
744
745        let results = engine.recent(10).unwrap();
746        assert_eq!(results.len(), 1);
747        assert_eq!(results[0].frequency, 3);
748    }
749
750    #[test]
751    fn test_prefix_search() {
752        let engine = HistoryEngine::in_memory().unwrap();
753
754        engine.add("git status", None).unwrap();
755        engine.add("git commit -m 'test'", None).unwrap();
756        engine.add("grep foo bar", None).unwrap();
757
758        let results = engine.search_prefix("git", 10).unwrap();
759        assert_eq!(results.len(), 2);
760    }
761
762    #[test]
763    fn test_directory_history() {
764        let engine = HistoryEngine::in_memory().unwrap();
765
766        engine.add("make build", Some("/project")).unwrap();
767        engine.add("cargo test", Some("/project")).unwrap();
768        engine.add("ls", Some("/tmp")).unwrap();
769
770        let results = engine.for_directory("/project", 10).unwrap();
771        assert_eq!(results.len(), 2);
772    }
773}