use super::artifact::{self, HistoryKind};
use super::metadata::HistoryExtraInfo;
use reedline::{
History, HistoryItem, HistoryItemExtraInfo, HistoryItemId, HistorySessionId, Reedline, Result,
SearchQuery, SqliteBackedHistory,
};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
#[derive(Clone)]
pub struct HistoryStore {
inner: Arc<Mutex<SqliteBackedHistory>>,
path: Option<PathBuf>,
session: Option<HistorySessionId>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HistorySaveOutcome {
Saved(HistoryItemId),
Failed,
}
#[derive(Clone)]
pub struct HistorySaveReceipt {
inner: Arc<Mutex<Option<HistorySaveOutcome>>>,
}
impl HistorySaveReceipt {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(None)),
}
}
pub fn take(&self) -> Option<HistorySaveOutcome> {
self.inner
.lock()
.ok()
.and_then(|mut outcome| outcome.take())
}
pub(crate) fn record(&self, outcome: HistorySaveOutcome) {
if let Ok(mut latest) = self.inner.lock() {
*latest = Some(outcome);
}
}
}
#[derive(Clone)]
pub struct HistoryHandle {
pub store: HistoryStore,
pub receipt: HistorySaveReceipt,
}
#[derive(Clone)]
pub enum HistoryRuntime {
Persistent(HistoryHandle),
Volatile {
handle: HistoryHandle,
reason: VolatileHistoryReason,
},
Unavailable {
failure: HistoryFailureDetail,
previous_failure: Option<HistoryFailureDetail>,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum VolatileHistoryReason {
Configured,
Fallback {
persistent_failure: HistoryFailureDetail,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HistoryFailureDetail {
stage: HistoryFailureStage,
path: Option<PathBuf>,
message: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum HistoryFailureStage {
PersistentPathResolution,
PersistentOpen,
MemoryInitialization,
}
impl HistoryRuntime {
pub(crate) fn initialize(
mode: &crate::config::HistoryMode,
requested_path: Option<PathBuf>,
history_kind: HistoryKind,
session_id: Option<HistorySessionId>,
session_timestamp: Option<chrono::DateTime<chrono::Utc>>,
) -> Self {
Self::initialize_with_factories(
mode,
requested_path,
history_kind,
session_id,
session_timestamp,
HistoryStore::open,
HistoryStore::in_memory,
)
}
fn initialize_with_factories<Open, Memory>(
mode: &crate::config::HistoryMode,
requested_path: Option<PathBuf>,
history_kind: HistoryKind,
session_id: Option<HistorySessionId>,
session_timestamp: Option<chrono::DateTime<chrono::Utc>>,
mut open: Open,
mut in_memory: Memory,
) -> Self
where
Open: FnMut(
PathBuf,
HistoryKind,
Option<HistorySessionId>,
Option<chrono::DateTime<chrono::Utc>>,
) -> Result<HistoryStore>,
Memory: FnMut(
Option<HistorySessionId>,
Option<chrono::DateTime<chrono::Utc>>,
) -> Result<HistoryStore>,
{
let make_handle = |store: HistoryStore| HistoryHandle {
store,
receipt: HistorySaveReceipt::new(),
};
if matches!(mode, crate::config::HistoryMode::Volatile) {
return match in_memory(session_id, session_timestamp) {
Ok(store) => Self::Volatile {
handle: make_handle(store),
reason: VolatileHistoryReason::Configured,
},
Err(error) => Self::Unavailable {
failure: HistoryFailureDetail::memory(error),
previous_failure: None,
},
};
}
let persistent_failure = match requested_path.clone() {
Some(path) => match open(path.clone(), history_kind, session_id, session_timestamp) {
Ok(store) => return Self::Persistent(make_handle(store)),
Err(error) => HistoryFailureDetail::persistent_open(path, error),
},
None => HistoryFailureDetail::persistent_path(),
};
match in_memory(session_id, session_timestamp) {
Ok(store) => Self::Volatile {
handle: make_handle(store),
reason: VolatileHistoryReason::Fallback { persistent_failure },
},
Err(error) => Self::Unavailable {
failure: HistoryFailureDetail::memory(error),
previous_failure: Some(persistent_failure),
},
}
}
pub fn attach_to_editor(&self, line_editor: Reedline) -> Reedline {
let Some(handle) = self.handle() else {
return line_editor;
};
let adapter =
super::ReedlineHistoryAdapter::new(handle.store.clone(), handle.receipt.clone());
line_editor
.with_history_session_id(handle.store.session())
.with_history(Box::new(adapter))
}
pub fn handle(&self) -> Option<&HistoryHandle> {
match self {
Self::Persistent(handle) | Self::Volatile { handle, .. } => Some(handle),
Self::Unavailable { .. } => None,
}
}
pub fn store(&self) -> Option<HistoryStore> {
self.handle().map(|handle| handle.store.clone())
}
pub fn receipt_outcome(&self) -> Option<HistorySaveOutcome> {
self.handle().and_then(|handle| handle.receipt.take())
}
pub fn is_available(&self) -> bool {
self.handle().is_some()
}
pub fn state_name(&self) -> &'static str {
match self {
Self::Persistent(_) => "persistent",
Self::Volatile { .. } => "volatile",
Self::Unavailable { .. } => "unavailable",
}
}
pub fn reason_name(&self) -> Option<&'static str> {
match self {
Self::Persistent(_) => None,
Self::Volatile { reason, .. } => Some(match reason {
VolatileHistoryReason::Configured => "configured",
VolatileHistoryReason::Fallback { .. } => "fallback",
}),
Self::Unavailable { .. } => Some("initialization_failed"),
}
}
pub fn startup_warning(&self) -> Option<String> {
let detail = self.diagnostic_detail()?;
Some(match self.requested_path() {
Some(path) => format!("{detail} (requested path: {})", path.display()),
None => detail,
})
}
pub fn diagnostic_detail(&self) -> Option<String> {
match self {
Self::Persistent(_) => None,
Self::Volatile { reason, .. } => match reason {
VolatileHistoryReason::Configured => None,
VolatileHistoryReason::Fallback { persistent_failure } => Some(format!(
"{}; using volatile fallback",
persistent_failure.describe(),
)),
},
Self::Unavailable {
failure,
previous_failure,
} => {
let mut details = previous_failure
.as_ref()
.map(|failure| failure.describe())
.unwrap_or_default();
if !details.is_empty() {
details.push_str("; ");
}
details.push_str(&failure.describe());
Some(details)
}
}
}
pub fn requested_path(&self) -> Option<&std::path::Path> {
match self {
Self::Persistent(handle) => handle.store.path(),
Self::Volatile { reason, .. } => match reason {
VolatileHistoryReason::Configured => None,
VolatileHistoryReason::Fallback { persistent_failure } => persistent_failure.path(),
},
Self::Unavailable {
previous_failure, ..
} => previous_failure
.as_ref()
.and_then(HistoryFailureDetail::path),
}
}
}
impl HistoryFailureDetail {
#[cfg(test)]
pub(crate) fn test_memory() -> Self {
Self {
stage: HistoryFailureStage::MemoryInitialization,
path: None,
message: "test memory initialization failure".to_string(),
}
}
#[cfg(test)]
pub(crate) fn test_persistent_open(path: PathBuf) -> Self {
Self {
stage: HistoryFailureStage::PersistentOpen,
path: Some(path),
message: "test persistent open failure".to_string(),
}
}
#[cfg(test)]
pub(crate) fn test_path_resolution() -> Self {
Self::persistent_path()
}
fn persistent_path() -> Self {
Self {
stage: HistoryFailureStage::PersistentPathResolution,
path: None,
message: "no persistent history path is available".to_string(),
}
}
fn persistent_open(path: PathBuf, error: reedline::ReedlineError) -> Self {
Self {
stage: HistoryFailureStage::PersistentOpen,
path: Some(path),
message: error.to_string(),
}
}
fn memory(error: reedline::ReedlineError) -> Self {
Self {
stage: HistoryFailureStage::MemoryInitialization,
path: None,
message: error.to_string(),
}
}
fn describe(&self) -> String {
match self.stage {
HistoryFailureStage::PersistentPathResolution => {
format!(
"persistent history path resolution failed: {}",
self.message
)
}
HistoryFailureStage::PersistentOpen => {
format!("persistent history open failed: {}", self.message)
}
HistoryFailureStage::MemoryInitialization => {
format!("volatile history initialization failed: {}", self.message)
}
}
}
fn path(&self) -> Option<&std::path::Path> {
self.path.as_deref()
}
}
impl HistoryStore {
pub(crate) fn open(
path: PathBuf,
history_kind: HistoryKind,
session_id: Option<HistorySessionId>,
session_timestamp: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<Self> {
artifact::prepare_history_path(&path, history_kind).map_err(|error| {
reedline::ReedlineError(reedline::ReedlineErrorVariants::HistoryDatabaseError(
format!("invalid history database artifact: {error:#}"),
))
})?;
let history = SqliteBackedHistory::with_file(path.clone(), session_id, session_timestamp)?;
Ok(Self {
inner: Arc::new(Mutex::new(history)),
path: Some(path),
session: session_id,
})
}
pub fn in_memory(
session_id: Option<HistorySessionId>,
_session_timestamp: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<Self> {
Ok(Self {
inner: Arc::new(Mutex::new(SqliteBackedHistory::in_memory()?)),
path: None,
session: session_id,
})
}
pub(crate) fn same_owner(&self, other: &Self) -> bool {
Arc::ptr_eq(&self.inner, &other.inner)
}
pub(crate) fn path(&self) -> Option<&std::path::Path> {
self.path.as_deref()
}
pub fn save_unknown(&self, item: HistoryItem) -> Result<HistoryItem> {
let mut item = item;
if item.session_id.is_none() {
item.session_id = self.session;
}
let typed = convert_history_item(item, None::<HistoryExtraInfo>);
let saved = self
.inner
.lock()
.map_err(|_| lock_error())?
.save_with_extra(typed)?;
Ok(convert_history_item(saved, None))
}
pub fn save_known(&self, item: HistoryItem, metadata: HistoryExtraInfo) -> Result<HistoryItem> {
let mut item = item;
if item.session_id.is_none() {
item.session_id = self.session;
}
let typed = convert_history_item(item, Some(metadata));
let saved = self
.inner
.lock()
.map_err(|_| lock_error())?
.save_with_extra(typed)?;
Ok(convert_history_item(saved, None))
}
pub(crate) fn save_imported(
&self,
item: HistoryItem<HistoryExtraInfo>,
) -> Result<HistoryItem<HistoryExtraInfo>> {
let mut item = item;
if item.session_id.is_none() {
item.session_id = self.session;
}
self.inner
.lock()
.map_err(|_| lock_error())?
.save_with_extra(item)
}
pub(crate) fn set_missing_fields_if_empty(
&self,
id: HistoryItemId,
source: HistoryItem<HistoryExtraInfo>,
) -> Result<bool> {
let _store_lock = self.inner.lock().map_err(|_| lock_error())?;
self.set_missing_fields_with_raw_transaction(id, source)
}
fn set_missing_fields_with_raw_transaction(
&self,
id: HistoryItemId,
source: HistoryItem<HistoryExtraInfo>,
) -> Result<bool> {
let serialized_metadata = source
.more_info
.as_ref()
.map(serde_json::to_string)
.transpose()
.map_err(|error| {
reedline::ReedlineError(reedline::ReedlineErrorVariants::HistoryDatabaseError(
format!("could not serialize more_info: {error}"),
))
})?;
let Some(path) = self.path.as_ref() else {
return Ok(false);
};
let mut connection = rusqlite::Connection::open(path).map_err(sqlite_error)?;
connection
.busy_timeout(std::time::Duration::from_secs(5))
.map_err(sqlite_error)?;
let transaction = connection
.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
.map_err(sqlite_error)?;
let changed = transaction
.execute(
"UPDATE history SET
session_id = COALESCE(session_id, :session_id),
hostname = COALESCE(hostname, :hostname),
cwd = COALESCE(cwd, :cwd),
duration_ms = COALESCE(duration_ms, :duration_ms),
exit_status = COALESCE(exit_status, :exit_status),
more_info = COALESCE(more_info, :more_info)
WHERE id = :id
AND command_line = :command_line
AND (
:start_timestamp IS NULL
OR start_timestamp = :start_timestamp
)
AND (
(:session_id IS NOT NULL AND session_id IS NULL)
OR (:hostname IS NOT NULL AND hostname IS NULL)
OR (:cwd IS NOT NULL AND cwd IS NULL)
OR (:duration_ms IS NOT NULL AND duration_ms IS NULL)
OR (:exit_status IS NOT NULL AND exit_status IS NULL)
OR (:more_info IS NOT NULL AND more_info IS NULL)
)",
rusqlite::named_params! {
":id": id.0,
":command_line": source.command_line,
":start_timestamp": source.start_timestamp.map(|value| value.timestamp_millis()),
":session_id": source.session_id.map(i64::from),
":hostname": source.hostname,
":cwd": source.cwd,
":duration_ms": source.duration.map(|value| value.as_millis() as i64),
":exit_status": source.exit_status,
":more_info": serialized_metadata,
},
)
.map_err(sqlite_error)?;
transaction.commit().map_err(sqlite_error)?;
Ok(changed != 0)
}
pub fn finalize_meta_command(&self, id: HistoryItemId, is_meta_command: bool) -> Result<()> {
self.inner
.lock()
.map_err(|_| lock_error())?
.update_with_extra::<HistoryExtraInfo>(id, &|mut item| {
let mut metadata: HistoryExtraInfo = item.more_info.unwrap_or_default();
metadata.set_meta_command(is_meta_command);
item.more_info = Some(metadata);
item
})
}
#[allow(dead_code)]
pub fn set_metadata(&self, id: HistoryItemId, metadata: HistoryExtraInfo) -> Result<()> {
self.inner
.lock()
.map_err(|_| lock_error())?
.update_with_extra::<HistoryExtraInfo>(id, &|mut item| {
item.more_info = Some(metadata.clone());
item
})
}
pub fn set_exit_status(&self, id: HistoryItemId, exit_status: i64) -> Result<()> {
self.inner
.lock()
.map_err(|_| lock_error())?
.update_with_extra::<HistoryExtraInfo>(id, &|mut item| {
item.exit_status = Some(exit_status);
item
})
}
pub fn count_all(&self) -> Result<i64> {
self.count(SearchQuery::everything(
reedline::SearchDirection::Backward,
None,
))
}
pub fn load(&self, id: HistoryItemId) -> Result<HistoryItem> {
self.inner.lock().map_err(|_| lock_error())?.load(id)
}
#[allow(dead_code)]
pub fn load_with_metadata(&self, id: HistoryItemId) -> Result<HistoryItem<HistoryExtraInfo>> {
self.inner
.lock()
.map_err(|_| lock_error())?
.load_with_extra(id)
}
pub fn count(&self, query: SearchQuery) -> Result<i64> {
self.inner.lock().map_err(|_| lock_error())?.count(query)
}
pub fn search(&self, query: SearchQuery) -> Result<Vec<HistoryItem>> {
self.inner.lock().map_err(|_| lock_error())?.search(query)
}
pub(crate) fn search_strict_session<F>(
&self,
mut make_query: F,
session_id: Option<HistorySessionId>,
all_sessions: bool,
limit: i64,
start_time: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<Vec<HistoryItem>>
where
F: FnMut(Option<HistoryItemId>) -> SearchQuery,
{
const PAGE_SIZE: i64 = 128;
if limit <= 0 {
return Ok(Vec::new());
}
if all_sessions && start_time.is_none() {
let mut query = make_query(None);
query.limit = Some(limit);
return self.search(query);
}
let mut matched = Vec::new();
let mut start_id = None;
loop {
let mut query = make_query(start_id);
query.filter.session = None;
query.limit = Some(PAGE_SIZE);
let page = self.search(query)?;
let page_len = page.len();
let next_start_id = page.last().and_then(|item| item.id);
matched.extend(page.into_iter().filter(|item| {
(all_sessions || item.session_id == session_id)
&& start_time.is_none_or(|start| {
item.start_timestamp
.is_some_and(|timestamp| timestamp >= start)
})
}));
if matched.len() >= limit as usize || page_len < PAGE_SIZE as usize {
matched.truncate(limit as usize);
return Ok(matched);
}
let Some(next_start_id) = next_start_id else {
matched.truncate(limit as usize);
return Ok(matched);
};
start_id = Some(next_start_id);
}
}
pub fn update(
&self,
id: HistoryItemId,
updater: &dyn Fn(HistoryItem) -> HistoryItem,
) -> Result<()> {
self.inner
.lock()
.map_err(|_| lock_error())?
.update(id, updater)
}
pub fn clear(&self) -> Result<()> {
self.inner.lock().map_err(|_| lock_error())?.clear()
}
pub fn delete(&self, id: HistoryItemId) -> Result<()> {
self.inner.lock().map_err(|_| lock_error())?.delete(id)
}
pub(crate) fn delete_many(&self, ids: &[HistoryItemId]) -> Result<usize> {
let mut history = self.inner.lock().map_err(|_| lock_error())?;
let mut deleted = 0;
for id in ids {
history.delete(*id)?;
deleted += 1;
}
Ok(deleted)
}
pub fn sync(&self) -> std::io::Result<()> {
self.inner
.lock()
.map_err(|_| std::io::Error::other("history store lock poisoned"))?
.sync()
}
pub fn session(&self) -> Option<HistorySessionId> {
self.session
}
#[cfg(test)]
pub(crate) fn drop_table_for_test(&self, path: &std::path::Path) {
rusqlite::Connection::open(path)
.unwrap()
.execute_batch("DROP TABLE history")
.unwrap();
}
}
pub(crate) fn convert_history_item<A: HistoryItemExtraInfo, B: HistoryItemExtraInfo>(
item: HistoryItem<A>,
more_info: Option<B>,
) -> HistoryItem<B> {
HistoryItem {
id: item.id,
start_timestamp: item.start_timestamp,
command_line: item.command_line,
session_id: item.session_id,
hostname: item.hostname,
cwd: item.cwd,
duration: item.duration,
exit_status: item.exit_status,
more_info,
}
}
fn lock_error() -> reedline::ReedlineError {
reedline::ReedlineError(reedline::ReedlineErrorVariants::HistoryDatabaseError(
"history store lock poisoned".to_string(),
))
}
fn sqlite_error(error: rusqlite::Error) -> reedline::ReedlineError {
reedline::ReedlineError(reedline::ReedlineErrorVariants::HistoryDatabaseError(
format!("{error:?}"),
))
}
#[cfg(test)]
#[path = "store/tests.rs"]
mod tests;