use super::HistoryItemId;
use crate::{core_editor::LineBuffer, HistoryItem, HistorySessionId, Result};
use chrono::Utc;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HistoryNavigationQuery {
Normal(LineBuffer),
PrefixSearch(String),
SubstringSearch(String),
}
pub enum CommandLineSearch {
Prefix(String),
Substring(String),
Exact(String),
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum SearchDirection {
Backward,
Forward,
}
pub struct SearchFilter {
pub command_line: Option<CommandLineSearch>,
pub(crate) not_command_line: Option<String>, pub hostname: Option<String>,
pub cwd_exact: Option<String>,
pub cwd_prefix: Option<String>,
pub exit_successful: Option<bool>,
pub session: Option<HistorySessionId>,
}
impl SearchFilter {
pub fn from_text_search(
cmd: CommandLineSearch,
session: Option<HistorySessionId>,
) -> SearchFilter {
let mut s = SearchFilter::anything(session);
s.command_line = Some(cmd);
s
}
pub fn from_text_search_cwd(
cwd: String,
cmd: CommandLineSearch,
session: Option<HistorySessionId>,
) -> SearchFilter {
let mut s = SearchFilter::anything(session);
s.command_line = Some(cmd);
s.cwd_exact = Some(cwd);
s
}
pub fn anything(session: Option<HistorySessionId>) -> SearchFilter {
SearchFilter {
command_line: None,
not_command_line: None,
hostname: None,
cwd_exact: None,
cwd_prefix: None,
exit_successful: None,
session,
}
}
}
pub struct SearchQuery {
pub direction: SearchDirection,
pub start_time: Option<chrono::DateTime<Utc>>,
pub end_time: Option<chrono::DateTime<Utc>>,
pub start_id: Option<HistoryItemId>,
pub end_id: Option<HistoryItemId>,
pub limit: Option<i64>,
pub filter: SearchFilter,
}
impl SearchQuery {
pub fn all_that_contain_rev(contains: String) -> SearchQuery {
SearchQuery {
direction: SearchDirection::Backward,
start_time: None,
end_time: None,
start_id: None,
end_id: None,
limit: None,
filter: SearchFilter::from_text_search(CommandLineSearch::Substring(contains), None),
}
}
pub const fn last_with_search(filter: SearchFilter) -> SearchQuery {
SearchQuery {
direction: SearchDirection::Backward,
start_time: None,
end_time: None,
start_id: None,
end_id: None,
limit: Some(1),
filter,
}
}
pub fn last_with_prefix(prefix: String, session: Option<HistorySessionId>) -> SearchQuery {
SearchQuery::last_with_search(SearchFilter::from_text_search(
CommandLineSearch::Prefix(prefix),
session,
))
}
pub fn last_with_prefix_and_cwd(
prefix: String,
session: Option<HistorySessionId>,
) -> SearchQuery {
let cwd = std::env::current_dir();
if let Ok(cwd) = cwd {
SearchQuery::last_with_search(SearchFilter::from_text_search_cwd(
cwd.to_string_lossy().to_string(),
CommandLineSearch::Prefix(prefix),
session,
))
} else {
SearchQuery::last_with_search(SearchFilter::from_text_search(
CommandLineSearch::Prefix(prefix),
session,
))
}
}
pub fn everything(
direction: SearchDirection,
session: Option<HistorySessionId>,
) -> SearchQuery {
SearchQuery {
direction,
start_time: None,
end_time: None,
start_id: None,
end_id: None,
limit: None,
filter: SearchFilter::anything(session),
}
}
}
pub trait History: Send {
fn save(&mut self, h: HistoryItem) -> Result<HistoryItem>;
fn load(&self, id: HistoryItemId) -> Result<HistoryItem>;
fn count(&self, query: SearchQuery) -> Result<i64>;
fn count_all(&self) -> Result<i64> {
self.count(SearchQuery::everything(SearchDirection::Forward, None))
}
fn search(&self, query: SearchQuery) -> Result<Vec<HistoryItem>>;
fn update(
&mut self,
id: HistoryItemId,
updater: &dyn Fn(HistoryItem) -> HistoryItem,
) -> Result<()>;
fn clear(&mut self) -> Result<()>;
fn delete(&mut self, h: HistoryItemId) -> Result<()>;
fn sync(&mut self) -> std::io::Result<()>;
fn session(&self) -> Option<HistorySessionId>;
}
#[cfg(test)]
mod test {
#[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))]
const IS_FILE_BASED: bool = false;
#[cfg(not(any(feature = "sqlite", feature = "sqlite-dynlib")))]
const IS_FILE_BASED: bool = true;
use crate::HistorySessionId;
fn create_item(session: i64, cwd: &str, cmd: &str, exit_status: i64) -> HistoryItem {
HistoryItem {
id: None,
start_timestamp: None,
command_line: cmd.to_string(),
session_id: Some(HistorySessionId::new(session)),
hostname: Some("foohost".to_string()),
cwd: Some(cwd.to_string()),
duration: Some(Duration::from_millis(1000)),
exit_status: Some(exit_status),
more_info: None,
}
}
use std::time::Duration;
use super::*;
fn create_filled_example_history() -> Result<Box<dyn History>> {
#[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))]
let mut history = crate::SqliteBackedHistory::in_memory()?;
#[cfg(not(any(feature = "sqlite", feature = "sqlite-dynlib")))]
let mut history = crate::FileBackedHistory::default();
#[cfg(not(any(feature = "sqlite", feature = "sqlite-dynlib")))]
history.save(create_item(1, "/", "dummy", 0))?; history.save(create_item(1, "/home/me", "cd ~/Downloads", 0))?; history.save(create_item(1, "/home/me/Downloads", "unzp foo.zip", 1))?; history.save(create_item(1, "/home/me/Downloads", "unzip foo.zip", 0))?; history.save(create_item(1, "/home/me/Downloads", "cd foo", 0))?; history.save(create_item(1, "/home/me/Downloads/foo", "ls", 0))?; history.save(create_item(1, "/home/me/Downloads/foo", "ls -alh", 0))?; history.save(create_item(1, "/home/me/Downloads/foo", "cat x.txt", 0))?; history.save(create_item(1, "/home/me", "cd /etc/nginx", 0))?; history.save(create_item(1, "/etc/nginx", "ls -l", 0))?; history.save(create_item(1, "/etc/nginx", "vim nginx.conf", 0))?; history.save(create_item(1, "/etc/nginx", "vim htpasswd", 0))?; history.save(create_item(1, "/etc/nginx", "cat nginx.conf", 0))?; Ok(Box::new(history))
}
#[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))]
#[test]
fn update_item() -> Result<()> {
let mut history = create_filled_example_history()?;
let id = HistoryItemId::new(2);
let before = history.load(id)?;
history.update(id, &|mut e| {
e.exit_status = Some(1);
e
})?;
let after = history.load(id)?;
assert_eq!(
after,
HistoryItem {
exit_status: Some(1),
..before
}
);
Ok(())
}
fn search_returned(
history: &dyn History,
res: Vec<HistoryItem>,
wanted: Vec<i64>,
) -> Result<()> {
let wanted = wanted
.iter()
.map(|id| history.load(HistoryItemId::new(*id)))
.collect::<Result<Vec<HistoryItem>>>()?;
assert_eq!(res, wanted);
Ok(())
}
#[test]
fn count_all() -> Result<()> {
let history = create_filled_example_history()?;
println!(
"{:#?}",
history.search(SearchQuery::everything(SearchDirection::Forward, None))
);
assert_eq!(history.count_all()?, if IS_FILE_BASED { 13 } else { 12 });
Ok(())
}
#[test]
fn get_latest() -> Result<()> {
let history = create_filled_example_history()?;
let res = history.search(SearchQuery::last_with_search(SearchFilter::anything(None)))?;
search_returned(&*history, res, vec![12])?;
Ok(())
}
#[test]
fn get_earliest() -> Result<()> {
let history = create_filled_example_history()?;
let res = history.search(SearchQuery {
limit: Some(1),
..SearchQuery::everything(SearchDirection::Forward, None)
})?;
search_returned(&*history, res, vec![if IS_FILE_BASED { 0 } else { 1 }])?;
Ok(())
}
#[test]
fn search_prefix() -> Result<()> {
let history = create_filled_example_history()?;
let res = history.search(SearchQuery {
filter: SearchFilter::from_text_search(
CommandLineSearch::Prefix("ls ".to_string()),
None,
),
..SearchQuery::everything(SearchDirection::Backward, None)
})?;
search_returned(&*history, res, vec![9, 6])?;
Ok(())
}
#[test]
fn search_includes() -> Result<()> {
let history = create_filled_example_history()?;
let res = history.search(SearchQuery {
filter: SearchFilter::from_text_search(
CommandLineSearch::Substring("foo.zip".to_string()),
None,
),
..SearchQuery::everything(SearchDirection::Forward, None)
})?;
search_returned(&*history, res, vec![2, 3])?;
Ok(())
}
#[test]
fn search_includes_limit() -> Result<()> {
let history = create_filled_example_history()?;
let res = history.search(SearchQuery {
filter: SearchFilter::from_text_search(
CommandLineSearch::Substring("c".to_string()),
None,
),
limit: Some(2),
..SearchQuery::everything(SearchDirection::Forward, None)
})?;
search_returned(&*history, res, vec![1, 4])?;
Ok(())
}
#[test]
fn clear_history() -> Result<()> {
let mut history = create_filled_example_history()?;
assert_ne!(history.count_all()?, 0);
history.clear().unwrap();
assert_eq!(history.count_all()?, 0);
Ok(())
}
#[test]
fn clear_history_with_backing_file() -> Result<()> {
#[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))]
fn open_history() -> Box<dyn History> {
Box::new(
crate::SqliteBackedHistory::with_file("target/test-history.db".into(), None, None)
.unwrap(),
)
}
#[cfg(not(any(feature = "sqlite", feature = "sqlite-dynlib")))]
fn open_history() -> Box<dyn History> {
Box::new(
crate::FileBackedHistory::with_file(100, "target/test-history.txt".into()).unwrap(),
)
}
let mut history = open_history();
history.save(create_item(1, "/home/me", "cd ~/Downloads", 0))?; history.save(create_item(1, "/home/me/Downloads", "unzp foo.zip", 1))?; assert_eq!(history.count_all()?, 2);
drop(history);
let mut history = open_history();
assert_eq!(history.count_all()?, 2);
history.clear().unwrap();
assert_eq!(history.count_all()?, 0);
drop(history);
let history = open_history();
assert_eq!(history.count_all()?, 0);
Ok(())
}
#[cfg(not(any(feature = "sqlite", feature = "sqlite-dynlib")))]
#[test]
fn history_size_zero() -> Result<()> {
let mut history = crate::FileBackedHistory::new(0)?;
history.save(create_item(1, "/home/me", "cd ~/Downloads", 0))?;
assert_eq!(history.count_all()?, 0);
let _ = history.sync();
history.clear()?;
drop(history);
Ok(())
}
#[test]
fn create_file_backed_history() {
use crate::HISTORY_SIZE;
assert!(crate::FileBackedHistory::new(usize::MAX).is_err());
assert!(crate::FileBackedHistory::new(HISTORY_SIZE).is_ok());
}
}