use rusqlite::{params, Connection};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use std::io::Write as _;
use std::io::Read;
use std::io::Write;
use std::io::{Seek, SeekFrom};
pub struct HistoryEngine {
conn: Connection,
}
#[derive(Debug, Clone)]
pub struct HistoryEntry {
pub id: i64,
pub command: String,
pub timestamp: i64,
pub duration_ms: Option<i64>,
pub exit_code: Option<i32>,
pub cwd: Option<String>,
pub frequency: u32,
}
impl HistoryEngine {
pub fn new() -> rusqlite::Result<Self> {
let path = Self::db_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
if !path.exists() {
let prev_inplace = Self::root().join("zshrs_history");
if prev_inplace.exists() && is_sqlite_file(&prev_inplace) {
match std::fs::rename(&prev_inplace, &path) {
Ok(()) => tracing::info!(
from = %prev_inplace.display(),
to = %path.display(),
"history: renamed legacy zshrs_history -> zshrs_history.db"
),
Err(e) => tracing::warn!(
?e,
"history: rename legacy zshrs_history failed"
),
}
}
}
if !path.exists() {
if let Some(legacy) = legacy_db_path() {
if legacy.exists() {
if let Err(e) = std::fs::copy(&legacy, &path) {
tracing::warn!(
from = %legacy.display(),
to = %path.display(),
error = %e,
"history: migrate from legacy path failed; starting empty"
);
} else {
tracing::info!(
from = %legacy.display(),
to = %path.display(),
"history: migrated from legacy path"
);
}
}
}
}
let conn = Connection::open(&path)?;
let engine = Self { conn };
engine.init_schema()?;
let count = engine.count().unwrap_or(0);
let db_size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
tracing::info!(
entries = count,
db_bytes = db_size,
path = %path.display(),
"history: sqlite opened"
);
if let Err(e) = engine.rehydrate_text_if_stale() {
tracing::warn!(?e, "history: failed to rehydrate text mirror; continuing");
}
Ok(engine)
}
pub fn in_memory() -> rusqlite::Result<Self> {
let conn = Connection::open_in_memory()?;
let engine = Self { conn };
engine.init_schema()?;
Ok(engine)
}
fn db_path() -> PathBuf {
Self::root().join("zshrs_history.db")
}
pub fn text_path() -> PathBuf {
Self::root().join("zshrs_history")
}
fn root() -> PathBuf {
if let Some(custom) = std::env::var_os("ZSHRS_HOME") {
PathBuf::from(custom)
} else {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".zshrs")
}
}
fn init_schema(&self) -> rusqlite::Result<()> {
self.conn.execute_batch(r#"
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY,
command TEXT NOT NULL,
timestamp INTEGER NOT NULL,
duration_ms INTEGER,
exit_code INTEGER,
cwd TEXT,
frequency INTEGER DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_history_timestamp ON history(timestamp DESC);
CREATE INDEX IF NOT EXISTS idx_history_cwd ON history(cwd);
CREATE UNIQUE INDEX IF NOT EXISTS idx_history_command ON history(command);
CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5(
command,
content='history',
content_rowid='id',
tokenize='trigram'
);
CREATE TRIGGER IF NOT EXISTS history_ai AFTER INSERT ON history BEGIN
INSERT INTO history_fts(rowid, command) VALUES (new.id, new.command);
END;
CREATE TRIGGER IF NOT EXISTS history_ad AFTER DELETE ON history BEGIN
INSERT INTO history_fts(history_fts, rowid, command) VALUES('delete', old.id, old.command);
END;
CREATE TRIGGER IF NOT EXISTS history_au AFTER UPDATE ON history BEGIN
INSERT INTO history_fts(history_fts, rowid, command) VALUES('delete', old.id, old.command);
INSERT INTO history_fts(rowid, command) VALUES (new.id, new.command);
END;
"#)?;
Ok(())
}
fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
pub fn add(&self, command: &str, cwd: Option<&str>) -> rusqlite::Result<i64> {
let command = command.trim();
if command.is_empty() || command.starts_with(' ') {
return Ok(0);
}
let now = Self::now();
let updated = self.conn.execute(
"UPDATE history SET timestamp = ?1, frequency = frequency + 1, cwd = COALESCE(?2, cwd)
WHERE command = ?3",
params![now, cwd, command],
)?;
if updated > 0 {
let id: i64 = self.conn.query_row(
"SELECT id FROM history WHERE command = ?1",
params![command],
|row| row.get(0),
)?;
return Ok(id);
}
self.conn.execute(
"INSERT INTO history (command, timestamp, cwd) VALUES (?1, ?2, ?3)",
params![command, now, cwd],
)?;
let id = self.conn.last_insert_rowid();
if let Err(e) = append_text_line(now, 0, command) {
tracing::warn!(?e, "history: text mirror append failed");
}
Ok(id)
}
pub fn update_last(&self, id: i64, duration_ms: i64, exit_code: i32) -> rusqlite::Result<()> {
self.conn.execute(
"UPDATE history SET duration_ms = ?1, exit_code = ?2 WHERE id = ?3",
params![duration_ms, exit_code, id],
)?;
if let Ok((ts, command)) = self.conn.query_row(
"SELECT timestamp, command FROM history WHERE id = ?1",
params![id],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
) {
let duration_secs = (duration_ms / 1000).max(0);
if let Err(e) = rewrite_last_text_line(ts, duration_secs, &command) {
tracing::warn!(?e, "history: text mirror update failed");
}
}
Ok(())
}
fn rehydrate_text_if_stale(&self) -> rusqlite::Result<()> {
let text = Self::text_path();
let text_size = std::fs::metadata(&text).map(|m| m.len()).unwrap_or(0);
if text_size > 0 {
return Ok(());
}
let count: i64 = self.conn.query_row("SELECT COUNT(*) FROM history", [], |r| r.get(0))?;
if count == 0 {
return Ok(());
}
let mut stmt = self.conn.prepare(
"SELECT timestamp, COALESCE(duration_ms, 0), command \
FROM history ORDER BY timestamp ASC, id ASC",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, String>(2)?,
))
})?;
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&text)
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
let mut w = std::io::BufWriter::new(file);
let mut written: u64 = 0;
for row in rows {
let (ts, dur_ms, cmd) = row?;
let line = format_text_line(ts, (dur_ms / 1000).max(0), &cmd);
w.write_all(line.as_bytes())
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
written += 1;
}
w.flush()
.map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
tracing::info!(
entries = written,
path = %text.display(),
"history: rehydrated text mirror from sqlite index"
);
Ok(())
}
pub fn search(&self, query: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
if query.is_empty() {
return self.recent(limit);
}
let escaped = query.replace('"', "\"\"");
let fts_query = format!("\"{}\"*", escaped);
let mut stmt = self.conn.prepare(
r#"SELECT h.id, h.command, h.timestamp, h.duration_ms, h.exit_code, h.cwd, h.frequency
FROM history h
JOIN history_fts f ON h.id = f.rowid
WHERE history_fts MATCH ?1
ORDER BY h.frequency DESC, h.timestamp DESC
LIMIT ?2"#,
)?;
let entries = stmt.query_map(params![fts_query, limit as i64], |row| {
Ok(HistoryEntry {
id: row.get(0)?,
command: row.get(1)?,
timestamp: row.get(2)?,
duration_ms: row.get(3)?,
exit_code: row.get(4)?,
cwd: row.get(5)?,
frequency: row.get(6)?,
})
})?;
entries.collect()
}
pub fn search_prefix(&self, prefix: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
if prefix.is_empty() {
return self.recent(limit);
}
let mut stmt = self.conn.prepare(
r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
FROM history
WHERE command LIKE ?1 || '%' ESCAPE '\'
ORDER BY timestamp DESC
LIMIT ?2"#,
)?;
let escaped = prefix
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_");
let entries = stmt.query_map(params![escaped, limit as i64], |row| {
Ok(HistoryEntry {
id: row.get(0)?,
command: row.get(1)?,
timestamp: row.get(2)?,
duration_ms: row.get(3)?,
exit_code: row.get(4)?,
cwd: row.get(5)?,
frequency: row.get(6)?,
})
})?;
entries.collect()
}
pub fn recent(&self, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
let mut stmt = self.conn.prepare(
r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
FROM history
ORDER BY timestamp DESC
LIMIT ?1"#,
)?;
let entries = stmt.query_map(params![limit as i64], |row| {
Ok(HistoryEntry {
id: row.get(0)?,
command: row.get(1)?,
timestamp: row.get(2)?,
duration_ms: row.get(3)?,
exit_code: row.get(4)?,
cwd: row.get(5)?,
frequency: row.get(6)?,
})
})?;
entries.collect()
}
pub fn for_directory(&self, cwd: &str, limit: usize) -> rusqlite::Result<Vec<HistoryEntry>> {
let mut stmt = self.conn.prepare(
r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
FROM history
WHERE cwd = ?1
ORDER BY frequency DESC, timestamp DESC
LIMIT ?2"#,
)?;
let entries = stmt.query_map(params![cwd, limit as i64], |row| {
Ok(HistoryEntry {
id: row.get(0)?,
command: row.get(1)?,
timestamp: row.get(2)?,
duration_ms: row.get(3)?,
exit_code: row.get(4)?,
cwd: row.get(5)?,
frequency: row.get(6)?,
})
})?;
entries.collect()
}
pub fn delete(&self, id: i64) -> rusqlite::Result<()> {
self.conn
.execute("DELETE FROM history WHERE id = ?1", params![id])?;
Ok(())
}
pub fn clear(&self) -> rusqlite::Result<()> {
self.conn.execute("DELETE FROM history", [])?;
Ok(())
}
pub fn count(&self) -> rusqlite::Result<i64> {
self.conn
.query_row("SELECT COUNT(*) FROM history", [], |row| row.get(0))
}
pub fn get_by_offset(&self, offset: usize) -> rusqlite::Result<Option<HistoryEntry>> {
let mut stmt = self.conn.prepare(
r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
FROM history
ORDER BY timestamp DESC
LIMIT 1 OFFSET ?1"#,
)?;
let mut rows = stmt.query(params![offset as i64])?;
if let Some(row) = rows.next()? {
Ok(Some(HistoryEntry {
id: row.get(0)?,
command: row.get(1)?,
timestamp: row.get(2)?,
duration_ms: row.get(3)?,
exit_code: row.get(4)?,
cwd: row.get(5)?,
frequency: row.get(6)?,
}))
} else {
Ok(None)
}
}
pub fn get_by_number(&self, num: i64) -> rusqlite::Result<Option<HistoryEntry>> {
let mut stmt = self.conn.prepare(
r#"SELECT id, command, timestamp, duration_ms, exit_code, cwd, frequency
FROM history
WHERE id = ?1"#,
)?;
let mut rows = stmt.query(params![num])?;
if let Some(row) = rows.next()? {
Ok(Some(HistoryEntry {
id: row.get(0)?,
command: row.get(1)?,
timestamp: row.get(2)?,
duration_ms: row.get(3)?,
exit_code: row.get(4)?,
cwd: row.get(5)?,
frequency: row.get(6)?,
}))
} else {
Ok(None)
}
}
}
fn legacy_db_path() -> Option<PathBuf> {
Some(dirs::data_dir()?.join("zshrs").join("history.db"))
}
fn is_sqlite_file(path: &std::path::Path) -> bool {
let mut f = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return false,
};
let mut header = [0u8; 16];
if f.read_exact(&mut header).is_err() {
return false;
}
&header == b"SQLite format 3\0"
}
fn format_text_line(ts: i64, duration_secs: i64, command: &str) -> String {
let escaped = command.replace('\\', "\\\\").replace('\n', "\\\n");
format!(": {}:{};{}\n", ts, duration_secs, escaped)
}
fn append_text_line(ts: i64, duration_secs: i64, command: &str) -> std::io::Result<()> {
let path = HistoryEngine::text_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
let line = format_text_line(ts, duration_secs, command);
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
f.write_all(line.as_bytes())
}
fn rewrite_last_text_line(ts: i64, duration_secs: i64, command: &str) -> std::io::Result<()> {
let path = HistoryEngine::text_path();
let mut f = std::fs::OpenOptions::new().read(true).write(true).open(&path)?;
let len = f.metadata()?.len();
let max_tail = 65_536u64.min(len);
let read_from = len - max_tail;
f.seek(SeekFrom::Start(read_from))?;
let mut tail = Vec::with_capacity(max_tail as usize);
f.read_to_end(&mut tail)?;
let mut last_record_start = 0usize;
let mut nl_count = 0;
for (i, b) in tail.iter().enumerate().rev() {
if *b == b'\n' {
nl_count += 1;
if nl_count == 2 {
last_record_start = i + 1;
break;
}
}
}
let new_record = format_text_line(ts, duration_secs, command);
let new_abs = read_from + last_record_start as u64;
f.seek(SeekFrom::Start(new_abs))?;
f.write_all(new_record.as_bytes())?;
let new_len = new_abs + new_record.len() as u64;
if new_len < len {
f.set_len(new_len)?;
}
Ok(())
}
pub struct ReedlineHistory {
engine: HistoryEngine,
session_history: Vec<String>,
cursor: usize,
}
impl ReedlineHistory {
pub fn new() -> rusqlite::Result<Self> {
Ok(Self {
engine: HistoryEngine::new()?,
session_history: Vec::new(),
cursor: 0,
})
}
pub fn add(&mut self, command: &str) -> rusqlite::Result<i64> {
self.session_history.push(command.to_string());
self.cursor = self.session_history.len();
let cwd = std::env::current_dir()
.ok()
.map(|p| p.to_string_lossy().to_string());
self.engine.add(command, cwd.as_deref())
}
pub fn search(&self, query: &str) -> Vec<String> {
self.engine
.search(query, 50)
.unwrap_or_default()
.into_iter()
.map(|e| e.command)
.collect()
}
pub fn previous(&mut self, prefix: &str) -> Option<String> {
if self.cursor == 0 {
return None;
}
for i in (0..self.cursor).rev() {
if self.session_history[i].starts_with(prefix) {
self.cursor = i;
return Some(self.session_history[i].clone());
}
}
self.engine
.search_prefix(prefix, 1)
.ok()
.and_then(|v| v.into_iter().next())
.map(|e| e.command)
}
pub fn next(&mut self, prefix: &str) -> Option<String> {
if self.cursor >= self.session_history.len() {
return None;
}
for i in (self.cursor + 1)..self.session_history.len() {
if self.session_history[i].starts_with(prefix) {
self.cursor = i;
return Some(self.session_history[i].clone());
}
}
self.cursor = self.session_history.len();
None
}
pub fn reset_cursor(&mut self) {
self.cursor = self.session_history.len();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_and_search() {
let engine = HistoryEngine::in_memory().unwrap();
engine.add("ls -la", Some("/home/user")).unwrap();
engine.add("cd /tmp", Some("/home/user")).unwrap();
engine.add("echo hello", Some("/tmp")).unwrap();
let results = engine.search_prefix("ls", 10).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].command, "ls -la");
}
#[test]
fn test_frequency_tracking() {
let engine = HistoryEngine::in_memory().unwrap();
engine.add("git status", None).unwrap();
engine.add("git status", None).unwrap();
engine.add("git status", None).unwrap();
let results = engine.recent(10).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].frequency, 3);
}
#[test]
fn test_prefix_search() {
let engine = HistoryEngine::in_memory().unwrap();
engine.add("git status", None).unwrap();
engine.add("git commit -m 'test'", None).unwrap();
engine.add("grep foo bar", None).unwrap();
let results = engine.search_prefix("git", 10).unwrap();
assert_eq!(results.len(), 2);
}
#[test]
fn test_directory_history() {
let engine = HistoryEngine::in_memory().unwrap();
engine.add("make build", Some("/project")).unwrap();
engine.add("cargo test", Some("/project")).unwrap();
engine.add("ls", Some("/tmp")).unwrap();
let results = engine.for_directory("/project", 10).unwrap();
assert_eq!(results.len(), 2);
}
}