Skip to main content

zsh/
history.rs

1//! SQLite-backed command history for zshrs
2//!
3//! Features:
4//! - Persistent history across sessions
5//! - Frequency and recency tracking
6//! - FTS5 full-text search for fzf-style matching
7//! - Per-directory history context
8//! - Deduplication with timestamp updates
9
10use rusqlite::{params, Connection};
11use std::path::PathBuf;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14pub struct HistoryEngine {
15    conn: Connection,
16}
17
18#[derive(Debug, Clone)]
19pub struct HistoryEntry {
20    pub id: i64,
21    pub command: String,
22    pub timestamp: i64,
23    pub duration_ms: Option<i64>,
24    pub exit_code: Option<i32>,
25    pub cwd: Option<String>,
26    pub frequency: u32,
27}
28
29impl HistoryEngine {
30    pub fn new() -> rusqlite::Result<Self> {
31        let path = Self::db_path();
32        if let Some(parent) = path.parent() {
33            std::fs::create_dir_all(parent).ok();
34        }
35
36        let conn = Connection::open(&path)?;
37        let engine = Self { conn };
38        engine.init_schema()?;
39        let count = engine.count().unwrap_or(0);
40        let db_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
41        tracing::info!(
42            entries = count,
43            db_bytes = db_size,
44            path = %path.display(),
45            "history: sqlite opened"
46        );
47        Ok(engine)
48    }
49
50    pub fn in_memory() -> rusqlite::Result<Self> {
51        let conn = Connection::open_in_memory()?;
52        let engine = Self { conn };
53        engine.init_schema()?;
54        Ok(engine)
55    }
56
57    fn db_path() -> PathBuf {
58        dirs::data_dir()
59            .unwrap_or_else(|| PathBuf::from("."))
60            .join("zshrs")
61            .join("history.db")
62    }
63
64    fn init_schema(&self) -> rusqlite::Result<()> {
65        self.conn.execute_batch(r#"
66            CREATE TABLE IF NOT EXISTS history (
67                id INTEGER PRIMARY KEY,
68                command TEXT NOT NULL,
69                timestamp INTEGER NOT NULL,
70                duration_ms INTEGER,
71                exit_code INTEGER,
72                cwd TEXT,
73                frequency INTEGER DEFAULT 1
74            );
75
76            CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp DESC);
77            CREATE INDEX IF NOT EXISTS idx_history_cwd ON history(cwd);
78            CREATE UNIQUE INDEX IF NOT EXISTS idx_history_command ON history(command);
79
80            CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5(
81                command,
82                content='history',
83                content_rowid='id',
84                tokenize='trigram'
85            );
86
87            CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
88                INSERT INTO history_fts(rowid, command) VALUES (new.id, new.command);
89            END;
90
91            CREATE TRIGGER IF NOT EXISTS history_ad AFTER DELETE ON history BEGIN
92                INSERT INTO history_fts(history_fts, rowid, command) VALUES('delete', old.id, old.command);
93            END;
94
95            CREATE TRIGGER IF NOT EXISTS history_au AFTER UPDATE ON history BEGIN
96                INSERT INTO history_fts(history_fts, rowid, command) VALUES('delete', old.id, old.command);
97                INSERT INTO history_fts(rowid, command) VALUES (new.id, new.command);
98            END;
99        "#)?;
100        Ok(())
101    }
102
103    fn now() -> i64 {
104        SystemTime::now()
105            .duration_since(UNIX_EPOCH)
106            .map(|d| d.as_secs() as i64)
107            .unwrap_or(0)
108    }
109
110    /// Add a command to history, updating frequency if it already exists
111    pub fn add(&self, command: &str, cwd: Option<&str>) -> rusqlite::Result<i64> {
112        let command = command.trim();
113        if command.is_empty() || command.starts_with(' ') {
114            return Ok(0);
115        }
116
117        let now = Self::now();
118
119        // Try to update existing entry
120        let updated = self.conn.execute(
121            "UPDATE history SET timestamp = ?1, frequency = frequency + 1, cwd = COALESCE(?2, cwd)
122             WHERE command = ?3",
123            params![now, cwd, command],
124        )?;
125
126        if updated > 0 {
127            // Return the existing ID
128            let id: i64 = self.conn.query_row(
129                "SELECT id FROM history WHERE command = ?1",
130                params![command],
131                |row| row.get(0),
132            )?;
133            return Ok(id);
134        }
135
136        // Insert new entry
137        self.conn.execute(
138            "INSERT INTO history (command, timestamp, cwd) VALUES (?1, ?2, ?3)",
139            params![command, now, cwd],
140        )?;
141
142        Ok(self.conn.last_insert_rowid())
143    }
144
145    /// Update the duration and exit code of the last command
146    pub fn update_last(&self, id: i64, duration_ms: i64, exit_code: i32) -> rusqlite::Result<()> {
147        self.conn.execute(
148            "UPDATE history SET duration_ms = ?1, exit_code = ?2 WHERE id = ?3",
149            params![duration_ms, exit_code, id],
150        )?;
151        Ok(())
152    }
153
154    /// Search history with FTS5 (fuzzy/substring matching)
155    pub fn search(&self, query: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
156        if query.is_empty() {
157            return self.recent(limit);
158        }
159
160        // Escape special FTS5 characters and use prefix matching
161        let escaped = query.replace('"', "\"\"");
162        let fts_query = format!("\"{}\"*", escaped);
163
164        let mut stmt = self.conn.prepare(
165            r#"SELECT h.id, h.command, h.timestamp, h.duration_ms, h.exit_code, h.cwd, h.frequency
166               FROM history h
167               JOIN history_fts f ON h.id = f.rowid
168               WHERE history_fts MATCH ?1
169               ORDER BY h.frequency DESC, h.timestamp DESC
170               LIMIT ?2"#,
171        )?;
172
173        let entries = stmt.query_map(params![fts_query, limit as i64], |row| {
174            Ok(HistoryEntry {
175                id: row.get(0)?,
176                command: row.get(1)?,
177                timestamp: row.get(2)?,
178                duration_ms: row.get(3)?,
179                exit_code: row.get(4)?,
180                cwd: row.get(5)?,
181                frequency: row.get(6)?,
182            })
183        })?;
184
185        entries.collect()
186    }
187
188    /// Search history with prefix matching (for up-arrow completion)
189    pub fn search_prefix(&self, prefix: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
190        if prefix.is_empty() {
191            return self.recent(limit);
192        }
193
194        let mut stmt = self.conn.prepare(
195            r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
196               FROM history
197               WHERE command LIKE ?1 || '%' ESCAPE '\'
198               ORDER BY timestamp DESC
199               LIMIT ?2"#,
200        )?;
201
202        // Escape SQL LIKE special chars
203        let escaped = prefix
204            .replace('\\', "\\\\")
205            .replace('%', "\\%")
206            .replace('_', "\\_");
207
208        let entries = stmt.query_map(params![escaped, limit as i64], |row| {
209            Ok(HistoryEntry {
210                id: row.get(0)?,
211                command: row.get(1)?,
212                timestamp: row.get(2)?,
213                duration_ms: row.get(3)?,
214                exit_code: row.get(4)?,
215                cwd: row.get(5)?,
216                frequency: row.get(6)?,
217            })
218        })?;
219
220        entries.collect()
221    }
222
223    /// Get recent history entries
224    pub fn recent(&self, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
225        let mut stmt = self.conn.prepare(
226            r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
227               FROM history
228               ORDER BY timestamp DESC
229               LIMIT ?1"#,
230        )?;
231
232        let entries = stmt.query_map(params![limit as i64], |row| {
233            Ok(HistoryEntry {
234                id: row.get(0)?,
235                command: row.get(1)?,
236                timestamp: row.get(2)?,
237                duration_ms: row.get(3)?,
238                exit_code: row.get(4)?,
239                cwd: row.get(5)?,
240                frequency: row.get(6)?,
241            })
242        })?;
243
244        entries.collect()
245    }
246
247    /// Get history for a specific directory
248    pub fn for_directory(&self, cwd: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
249        let mut stmt = self.conn.prepare(
250            r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
251               FROM history
252               WHERE cwd = ?1
253               ORDER BY frequency DESC, timestamp DESC
254               LIMIT ?2"#,
255        )?;
256
257        let entries = stmt.query_map(params![cwd, limit as i64], |row| {
258            Ok(HistoryEntry {
259                id: row.get(0)?,
260                command: row.get(1)?,
261                timestamp: row.get(2)?,
262                duration_ms: row.get(3)?,
263                exit_code: row.get(4)?,
264                cwd: row.get(5)?,
265                frequency: row.get(6)?,
266            })
267        })?;
268
269        entries.collect()
270    }
271
272    /// Delete a history entry
273    pub fn delete(&self, id: i64) -> rusqlite::Result<()> {
274        self.conn
275            .execute("DELETE FROM history WHERE id = ?1", params![id])?;
276        Ok(())
277    }
278
279    /// Clear all history
280    pub fn clear(&self) -> rusqlite::Result<()> {
281        self.conn.execute("DELETE FROM history", [])?;
282        Ok(())
283    }
284
285    /// Get total history count
286    pub fn count(&self) -> rusqlite::Result<i64> {
287        self.conn
288            .query_row("SELECT COUNT(*) FROM history", [], |row| row.get(0))
289    }
290
291    /// Get entry by index from end (0 = most recent, like !-1)
292    pub fn get_by_offset(&self, offset: usize) -> rusqlite::Result<Option<HistoryEntry>> {
293        let mut stmt = self.conn.prepare(
294            r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
295               FROM history
296               ORDER BY timestamp DESC
297               LIMIT 1 OFFSET ?1"#,
298        )?;
299
300        let mut rows = stmt.query(params![offset as i64])?;
301        if let Some(row) = rows.next()? {
302            Ok(Some(HistoryEntry {
303                id: row.get(0)?,
304                command: row.get(1)?,
305                timestamp: row.get(2)?,
306                duration_ms: row.get(3)?,
307                exit_code: row.get(4)?,
308                cwd: row.get(5)?,
309                frequency: row.get(6)?,
310            }))
311        } else {
312            Ok(None)
313        }
314    }
315
316    /// Get entry by absolute history number (like !123)
317    pub fn get_by_number(&self, num: i64) -> rusqlite::Result<Option<HistoryEntry>> {
318        let mut stmt = self.conn.prepare(
319            r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
320               FROM history
321               WHERE id = ?1"#,
322        )?;
323
324        let mut rows = stmt.query(params![num])?;
325        if let Some(row) = rows.next()? {
326            Ok(Some(HistoryEntry {
327                id: row.get(0)?,
328                command: row.get(1)?,
329                timestamp: row.get(2)?,
330                duration_ms: row.get(3)?,
331                exit_code: row.get(4)?,
332                cwd: row.get(5)?,
333                frequency: row.get(6)?,
334            }))
335        } else {
336            Ok(None)
337        }
338    }
339}
340
341/// Reedline history adapter
342pub struct ReedlineHistory {
343    engine: HistoryEngine,
344    session_history: Vec<String>,
345    cursor: usize,
346}
347
348impl ReedlineHistory {
349    pub fn new() -> rusqlite::Result<Self> {
350        Ok(Self {
351            engine: HistoryEngine::new()?,
352            session_history: Vec::new(),
353            cursor: 0,
354        })
355    }
356
357    pub fn add(&mut self, command: &str) -> rusqlite::Result<i64> {
358        self.session_history.push(command.to_string());
359        self.cursor = self.session_history.len();
360        let cwd = std::env::current_dir()
361            .ok()
362            .map(|p| p.to_string_lossy().to_string());
363        self.engine.add(command, cwd.as_deref())
364    }
365
366    pub fn search(&self, query: &str) -> Vec<String> {
367        self.engine
368            .search(query, 50)
369            .unwrap_or_default()
370            .into_iter()
371            .map(|e| e.command)
372            .collect()
373    }
374
375    pub fn previous(&mut self, prefix: &str) -> Option<String> {
376        if self.cursor == 0 {
377            return None;
378        }
379
380        // Search backwards in session history first
381        for i in (0..self.cursor).rev() {
382            if self.session_history[i].starts_with(prefix) {
383                self.cursor = i;
384                return Some(self.session_history[i].clone());
385            }
386        }
387
388        // Fall back to database
389        self.engine
390            .search_prefix(prefix, 1)
391            .ok()
392            .and_then(|v| v.into_iter().next())
393            .map(|e| e.command)
394    }
395
396    pub fn next(&mut self, prefix: &str) -> Option<String> {
397        if self.cursor >= self.session_history.len() {
398            return None;
399        }
400
401        for i in (self.cursor + 1)..self.session_history.len() {
402            if self.session_history[i].starts_with(prefix) {
403                self.cursor = i;
404                return Some(self.session_history[i].clone());
405            }
406        }
407
408        self.cursor = self.session_history.len();
409        None
410    }
411
412    pub fn reset_cursor(&mut self) {
413        self.cursor = self.session_history.len();
414    }
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    #[test]
422    fn test_add_and_search() {
423        let engine = HistoryEngine::in_memory().unwrap();
424
425        engine.add("ls -la", Some("/home/user")).unwrap();
426        engine.add("cd /tmp", Some("/home/user")).unwrap();
427        engine.add("echo hello", Some("/tmp")).unwrap();
428
429        // Use prefix search for short queries (trigram FTS5 needs 3+ chars)
430        let results = engine.search_prefix("ls", 10).unwrap();
431        assert_eq!(results.len(), 1);
432        assert_eq!(results[0].command, "ls -la");
433    }
434
435    #[test]
436    fn test_frequency_tracking() {
437        let engine = HistoryEngine::in_memory().unwrap();
438
439        engine.add("git status", None).unwrap();
440        engine.add("git status", None).unwrap();
441        engine.add("git status", None).unwrap();
442
443        let results = engine.recent(10).unwrap();
444        assert_eq!(results.len(), 1);
445        assert_eq!(results[0].frequency, 3);
446    }
447
448    #[test]
449    fn test_prefix_search() {
450        let engine = HistoryEngine::in_memory().unwrap();
451
452        engine.add("git status", None).unwrap();
453        engine.add("git commit -m 'test'", None).unwrap();
454        engine.add("grep foo bar", None).unwrap();
455
456        let results = engine.search_prefix("git", 10).unwrap();
457        assert_eq!(results.len(), 2);
458    }
459
460    #[test]
461    fn test_directory_history() {
462        let engine = HistoryEngine::in_memory().unwrap();
463
464        engine.add("make build", Some("/project")).unwrap();
465        engine.add("cargo test", Some("/project")).unwrap();
466        engine.add("ls", Some("/tmp")).unwrap();
467
468        let results = engine.for_directory("/project", 10).unwrap();
469        assert_eq!(results.len(), 2);
470    }
471}