use std::path::PathBuf;
use chrono::{DateTime, Utc};
use reedline::{
CommandLineSearch, History, HistoryItem, HistoryItemId, HistorySessionId, ReedlineError,
SearchDirection, SearchQuery,
};
use rusqlite::{types::Value, Connection};
fn make_session_id(id: i64) -> HistorySessionId {
serde_json::from_value(serde_json::Value::Number(id.into()))
.expect("HistorySessionId deserialization from i64 should never fail")
}
pub struct BlackBoxHistory {
conn: Connection,
session_id: i64,
}
impl BlackBoxHistory {
pub fn open(db_path: PathBuf, session_id: i64) -> std::result::Result<Self, String> {
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("failed to create directory: {e}"))?;
}
let conn = Connection::open(&db_path)
.map_err(|e| format!("failed to open history database: {e}"))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS command_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
command TEXT NOT NULL,
cwd TEXT NOT NULL,
exit_code INTEGER NOT NULL,
stdout_hash TEXT,
stderr_hash TEXT,
created_at TEXT NOT NULL,
session_id INTEGER
);",
)
.map_err(|e| format!("failed to create command_history table: {e}"))?;
let has_session_id = conn
.prepare("SELECT session_id FROM command_history LIMIT 0")
.is_ok();
if !has_session_id {
conn.execute_batch("ALTER TABLE command_history ADD COLUMN session_id INTEGER;")
.map_err(|e| format!("failed to add session_id column: {e}"))?;
}
conn.execute_batch("PRAGMA journal_mode=WAL;")
.map_err(|e| format!("failed to enable WAL mode: {e}"))?;
Ok(Self { conn, session_id })
}
fn to_reedline_err(e: rusqlite::Error) -> ReedlineError {
std::io::Error::other(e.to_string()).into()
}
fn row_to_item(row: &rusqlite::Row) -> rusqlite::Result<HistoryItem> {
let id: i64 = row.get(0)?;
let command: String = row.get(1)?;
let cwd: String = row.get(2)?;
let exit_code: i32 = row.get(3)?;
let created_at: String = row.get(4)?;
let session_id: Option<i64> = row.get(5)?;
let timestamp = DateTime::parse_from_rfc3339(&created_at)
.ok()
.map(|dt| dt.with_timezone(&Utc));
Ok(HistoryItem {
id: Some(HistoryItemId::new(id)),
start_timestamp: timestamp,
command_line: command,
session_id: session_id.map(make_session_id),
hostname: None,
cwd: Some(cwd),
duration: None,
exit_status: Some(exit_code as i64),
more_info: None,
})
}
fn build_sql(&self, query: &SearchQuery, select: &str) -> (String, Vec<Value>) {
let mut conditions = Vec::new();
let mut params: Vec<Value> = Vec::new();
if let Some(ref cmd_search) = query.filter.command_line {
match cmd_search {
CommandLineSearch::Prefix(p) => {
conditions.push("command LIKE ?".to_string());
params.push(Value::Text(format!("{p}%")));
}
CommandLineSearch::Substring(s) => {
conditions.push("command LIKE ?".to_string());
params.push(Value::Text(format!("%{s}%")));
}
CommandLineSearch::Exact(e) => {
conditions.push("command = ?".to_string());
params.push(Value::Text(e.clone()));
}
}
}
if let Some(ref cwd) = query.filter.cwd_exact {
conditions.push("cwd = ?".to_string());
params.push(Value::Text(cwd.clone()));
}
if let Some(ref cwd_prefix) = query.filter.cwd_prefix {
conditions.push("cwd LIKE ?".to_string());
params.push(Value::Text(format!("{cwd_prefix}%")));
}
if query.filter.session.is_none() {
conditions.push("(session_id IS NULL OR session_id = ?)".to_string());
params.push(Value::Integer(self.session_id));
}
if let Some(success) = query.filter.exit_successful {
if success {
conditions.push("exit_code = 0".to_string());
} else {
conditions.push("exit_code != 0".to_string());
}
}
if let Some(start_id) = query.start_id {
match query.direction {
SearchDirection::Backward => {
conditions.push("id < ?".to_string());
}
SearchDirection::Forward => {
conditions.push("id > ?".to_string());
}
}
params.push(Value::Integer(start_id.0));
}
if let Some(end_id) = query.end_id {
match query.direction {
SearchDirection::Backward => {
conditions.push("id > ?".to_string());
}
SearchDirection::Forward => {
conditions.push("id < ?".to_string());
}
}
params.push(Value::Integer(end_id.0));
}
if let Some(ref start_time) = query.start_time {
conditions.push("created_at >= ?".to_string());
params.push(Value::Text(start_time.to_rfc3339()));
}
if let Some(ref end_time) = query.end_time {
conditions.push("created_at <= ?".to_string());
params.push(Value::Text(end_time.to_rfc3339()));
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!(" WHERE {}", conditions.join(" AND "))
};
let order = match query.direction {
SearchDirection::Forward => "ASC",
SearchDirection::Backward => "DESC",
};
let limit_clause = query
.limit
.map(|l| format!(" LIMIT {l}"))
.unwrap_or_default();
let sql = format!(
"SELECT {select} FROM command_history{where_clause} ORDER BY id {order}{limit_clause}"
);
(sql, params)
}
}
impl History for BlackBoxHistory {
fn save(&mut self, h: HistoryItem) -> Result<HistoryItem, ReedlineError> {
if h.command_line.trim().is_empty() {
return Ok(h);
}
if let Some(id) = h.id {
let cwd = h.cwd.as_deref().unwrap_or("");
let exit_code = h.exit_status.unwrap_or(0) as i32;
let created_at = h
.start_timestamp
.map(|t| t.to_rfc3339())
.unwrap_or_else(|| Utc::now().to_rfc3339());
self.conn
.execute(
"UPDATE command_history \
SET command = ?1, cwd = ?2, exit_code = ?3, created_at = ?4 \
WHERE id = ?5",
rusqlite::params![h.command_line, cwd, exit_code, created_at, id.0],
)
.map_err(Self::to_reedline_err)?;
Ok(h)
} else {
let cwd = h.cwd.clone().unwrap_or_else(|| {
std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default()
});
let exit_code = h.exit_status.unwrap_or(0) as i32;
let created_at = h
.start_timestamp
.map(|t| t.to_rfc3339())
.unwrap_or_else(|| Utc::now().to_rfc3339());
self.conn
.execute(
"INSERT INTO command_history (command, cwd, exit_code, created_at, session_id) \
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![h.command_line, cwd, exit_code, created_at, self.session_id],
)
.map_err(Self::to_reedline_err)?;
let new_id = self.conn.last_insert_rowid();
Ok(HistoryItem {
id: Some(HistoryItemId::new(new_id)),
start_timestamp: h.start_timestamp,
command_line: h.command_line,
session_id: Some(make_session_id(self.session_id)),
hostname: None,
cwd: Some(cwd),
duration: h.duration,
exit_status: h.exit_status,
more_info: None,
})
}
}
fn load(&self, id: HistoryItemId) -> Result<HistoryItem, ReedlineError> {
self.conn
.query_row(
"SELECT id, command, cwd, exit_code, created_at, session_id \
FROM command_history WHERE id = ?1",
rusqlite::params![id.0],
Self::row_to_item,
)
.map_err(Self::to_reedline_err)
}
fn count(&self, query: SearchQuery) -> Result<i64, ReedlineError> {
let (sql, params) = self.build_sql(&query, "COUNT(*)");
self.conn
.query_row(&sql, rusqlite::params_from_iter(params.iter()), |row| {
row.get(0)
})
.map_err(Self::to_reedline_err)
}
fn search(&self, query: SearchQuery) -> Result<Vec<HistoryItem>, ReedlineError> {
let (sql, params) = self.build_sql(
&query,
"id, command, cwd, exit_code, created_at, session_id",
);
let mut stmt = self.conn.prepare(&sql).map_err(Self::to_reedline_err)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(params.iter()), Self::row_to_item)
.map_err(Self::to_reedline_err)?;
let mut items = Vec::new();
for row in rows {
items.push(row.map_err(Self::to_reedline_err)?);
}
Ok(items)
}
fn update(
&mut self,
id: HistoryItemId,
updater: &dyn Fn(HistoryItem) -> HistoryItem,
) -> Result<(), ReedlineError> {
let item = self.load(id)?;
let updated = updater(item);
self.save(HistoryItem {
id: Some(id),
..updated
})?;
Ok(())
}
fn clear(&mut self) -> Result<(), ReedlineError> {
self.conn
.execute("DELETE FROM command_history", [])
.map_err(Self::to_reedline_err)?;
Ok(())
}
fn delete(&mut self, h: HistoryItemId) -> Result<(), ReedlineError> {
self.conn
.execute(
"DELETE FROM command_history WHERE id = ?1",
rusqlite::params![h.0],
)
.map_err(Self::to_reedline_err)?;
Ok(())
}
fn sync(&mut self) -> std::io::Result<()> {
Ok(())
}
fn session(&self) -> Option<HistorySessionId> {
Some(make_session_id(self.session_id))
}
}