diff --git a/.gitignore b/.gitignore index c190c686..900bae06 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ target/ history.txt +history.sqlite3 .DS_Store target-coverage/ tarpaulin-report.html diff --git a/Cargo.lock b/Cargo.lock index 2f1525e0..fd3b1861 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,17 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "ahash" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +dependencies = [ + "getrandom", + "once_cell", + "version_check", +] + [[package]] name = "ansi_term" version = "0.12.1" @@ -145,6 +156,18 @@ dependencies = [ "libc", ] +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "1.7.0" @@ -165,6 +188,35 @@ dependencies = [ "windows-sys 0.30.0", ] +[[package]] +name = "getrandom" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39cd93900197114fa1fcb7ae84ca742095eed9442088988ae74fa744e930e77" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7249a3129cbc1ffccd74857f81464a323a152173cdb134e0fd81bc803b29facf" +dependencies = [ + "hashbrown", +] + [[package]] name = "heck" version = "0.4.0" @@ -186,12 +238,29 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec58677acfea8a15352d42fc87d11d63596ade9239e0a7c9352914417515dbe6" +[[package]] +name = "itoa" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aab8fc367588b89dcee83ab0fd66b72b50b72fa1904d7095045ace2b0c81c35" + [[package]] name = "libc" version = "0.2.119" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bf2e165bb3457c8e098ea76f3e3bc9db55f87aa90d52d0e6be741470916aaa4" +[[package]] +name = "libsqlite3-sys" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "898745e570c7d0453cc1fbc4a701eb6c662ed54e8fec8b7d14be137ebeeb9d14" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.0.42" @@ -225,6 +294,12 @@ dependencies = [ "libc", ] +[[package]] +name = "memchr" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308cc39be01b73d0d18f82a0e7b2a3df85245f84af96fdddc5d202d27e47b86a" + [[package]] name = "mio" version = "0.7.14" @@ -314,6 +389,12 @@ dependencies = [ "objc", ] +[[package]] +name = "once_cell" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f3e037eac156d1775da914196f0f37741a274155e34a0b7e427c35d2a2ecb9" + [[package]] name = "output_vt100" version = "0.1.3" @@ -352,6 +433,12 @@ dependencies = [ "windows-sys 0.32.0", ] +[[package]] +name = "pkg-config" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" + [[package]] name = "pretty_assertions" version = "1.1.0" @@ -402,7 +489,9 @@ dependencies = [ "nu-ansi-term", "pretty_assertions", "rstest", + "rusqlite", "serde", + "serde_json", "strip-ansi-escapes", "strum", "strum_macros", @@ -433,6 +522,21 @@ dependencies = [ "syn", ] +[[package]] +name = "rusqlite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85127183a999f7db96d1a976a309eebbfb6ea3b0b400ddd8340190129de6eb7a" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "memchr", + "smallvec", +] + [[package]] name = "rustc_version" version = "0.4.0" @@ -462,6 +566,12 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2cc38e8fa666e2de3c4aba7edeb5ffc5246c1c2ed0e3d17e560aeeba736b23f" +[[package]] +name = "ryu" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" + [[package]] name = "scopeguard" version = "1.1.0" @@ -494,6 +604,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e8d9fa5c3b304765ce1fd9c4c8a3de2c8db365a5b91be52f186efc675681d95" +dependencies = [ + "itoa", + "ryu", + "serde", +] + [[package]] name = "signal-hook" version = "0.3.13" @@ -618,6 +739,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "936e4b492acfd135421d8dca4b1aa80a7bfc26e702ef3af710e0752684df5372" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + [[package]] name = "vte" version = "0.10.1" diff --git a/Cargo.toml b/Cargo.toml index a032465c..9f8d7d4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,8 @@ strip-ansi-escapes = "0.1.1" strum = "0.24" strum_macros = "0.24" fd-lock = "3.0.3" +rusqlite = { version = "0.27.0", optional = true, features = ["bundled"] } +serde_json = { version = "1.0.79", optional = true } [dev-dependencies] tempfile = "3.3.0" @@ -36,3 +38,4 @@ rstest = "0.12.0" [features] system_clipboard = ["clipboard"] bashisms = [] +sqlite = ["rusqlite", "serde_json"] diff --git a/src/engine.rs b/src/engine.rs index 6f5891d2..cceef8ba 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -320,7 +320,6 @@ impl Reedline { let history: Vec<_> = self .history .iter_chronologic() - .cloned() .enumerate() .collect(); diff --git a/src/history/base.rs b/src/history/base.rs index ad029498..1030c5cb 100644 --- a/src/history/base.rs +++ b/src/history/base.rs @@ -1,5 +1,4 @@ use crate::core_editor::LineBuffer; -use std::collections::vec_deque::Iter; /// Browsing modes for a [`History`] #[derive(Debug, Clone, PartialEq, Eq)] @@ -20,7 +19,7 @@ pub trait History: Send { fn append(&mut self, entry: &str); /// Chronologic interaction over all entries present in the history - fn iter_chronologic(&self) -> Iter<'_, String>; + fn iter_chronologic(&self) -> Box + '_>; /// This moves the cursor backwards respecting the navigation query that is set /// - Results in a no-op if the cursor is at the initial point diff --git a/src/history/file_backed.rs b/src/history/file_backed.rs index d14ee86b..4bb6af8c 100644 --- a/src/history/file_backed.rs +++ b/src/history/file_backed.rs @@ -68,8 +68,8 @@ impl History for FileBackedHistory { self.reset_cursor(); } - fn iter_chronologic(&self) -> Iter<'_, String> { - self.entries.iter() + fn iter_chronologic(&self) -> Box<(dyn DoubleEndedIterator + '_)> { + Box::new(self.entries.iter().map(|e| e.to_string())) } fn back(&mut self) { @@ -121,7 +121,6 @@ impl History for FileBackedHistory { self.iter_chronologic() .rev() .filter(|entry| entry.contains(search)) - .cloned() .collect::>() } diff --git a/src/history/mod.rs b/src/history/mod.rs index 5f1b20a4..8c48c03e 100644 --- a/src/history/mod.rs +++ b/src/history/mod.rs @@ -1,5 +1,9 @@ mod base; mod file_backed; +#[cfg(feature="sqlite")] +mod sqlite_backed; +#[cfg(feature="sqlite")] +pub use sqlite_backed::SqliteBackedHistory; pub use base::{History, HistoryNavigationQuery}; -pub use file_backed::{FileBackedHistory, HISTORY_SIZE}; +pub use file_backed::{FileBackedHistory, HISTORY_SIZE}; \ No newline at end of file diff --git a/src/history/sqlite_backed.rs b/src/history/sqlite_backed.rs new file mode 100644 index 00000000..1d492a7a --- /dev/null +++ b/src/history/sqlite_backed.rs @@ -0,0 +1,671 @@ +use rusqlite::{named_params, params, Connection, MappedRows, OptionalExtension, Row}; +use serde::{de::DeserializeOwned, Serialize}; + +use super::{base::HistoryNavigationQuery, History}; +use crate::core_editor::LineBuffer; +use std::{ + path::PathBuf, + sync::{Arc, Mutex}, +}; + +/// This trait represents additional context to be added to a history (see [SqliteBackedHistory]) +pub trait HistoryEntryContext: Serialize + DeserializeOwned + Default + Send {} + +impl HistoryEntryContext for T where T: Serialize + DeserializeOwned + Default + Send {} + +/// A history that stores the values to an SQLite database. +/// In addition to storing the command, the history can store an additional arbitrary HistoryEntryContext, +/// to add information such as a timestamp, running directory, result... +pub struct SqliteBackedHistory { + db: rusqlite::Connection, + last_run_command_id: Option, + last_run_command_context: Option, + cursor: SqliteHistoryCursor, +} + +// for arrow-navigation +struct SqliteHistoryCursor { + id: i64, + command: Option, + query: HistoryNavigationQuery, +} + +/// Allow wrapping any history in Arc> so the creator of a Reedline can keep a reference to the history. +/// That way the library user can call history-specific methods ([`SqliteBackedHistory::update_last_command_context`]) +/// The alternative would be that Reedline would have to be generic over the history type. +impl History for Arc> { + fn append(&mut self, entry: &str) { + self.lock().expect("lock poisoned").append(entry) + } + + fn iter_chronologic(&self) -> Box<(dyn DoubleEndedIterator + '_)> { + let inner = self.lock().expect("lock poisoned"); + // TODO: performance :) + Box::new(inner.iter_chronologic().collect::>().into_iter()) + } + + fn back(&mut self) { + self.lock().expect("lock poisoned").back() + } + + fn forward(&mut self) { + self.lock().expect("lock poisoned").forward() + } + + fn string_at_cursor(&self) -> Option { + self.lock().expect("lock poisoned").string_at_cursor() + } + + fn set_navigation(&mut self, navigation: HistoryNavigationQuery) { + self.lock() + .expect("lock poisoned") + .set_navigation(navigation) + } + + fn get_navigation(&self) -> HistoryNavigationQuery { + self.lock().expect("lock poisoned").get_navigation() + } + + fn query_entries(&self, search: &str) -> Vec { + self.lock().expect("lock poisoned").query_entries(search) + } + + fn max_values(&self) -> usize { + self.lock().expect("lock poisoned").max_values() + } + + fn sync(&mut self) -> std::io::Result<()> { + self.lock().expect("lock poisoned").sync() + } + + fn reset_cursor(&mut self) { + self.lock().expect("lock poisoned").reset_cursor() + } +} + +impl History for SqliteBackedHistory { + /// Appends an entry if non-empty and not repetition of the previous entry. + /// Resets the browsing cursor to the default state in front of the most recent entry. + /// + fn append(&mut self, entry: &str) { + let ctx = ContextType::default(); + let ret: i64 = self + .db + .prepare( + "insert into history (command, context) values (:command, :context) returning id", + ) + .unwrap() + .query_row( + named_params! { + ":command": entry, + ":context": serde_json::to_string(&ctx).unwrap() + }, + |row| row.get(0), + ) + .unwrap(); + self.last_run_command_id = Some(ret); + self.last_run_command_context = Some(ctx); + self.reset_cursor(); + } + + fn iter_chronologic(&self) -> Box<(dyn DoubleEndedIterator + '_)> { + /*let mapper = |r: &Row| Ok((r.get(0)?, r.get(1)?)); + let fwd = inner + .db + .prepare("select id, command from history order by id asc").unwrap() + .query_map(params![], mapper) + .unwrap(); + let bwd = inner + .db + .prepare("select id, command from history order by id desc").unwrap() + .query_map(params![], mapper) + .unwrap(); + let de = SqliteDoubleEnded { + fwd, + bwd + };*/ + // todo: read in chunks or dynamically (?) + let fwd = self + .db + .prepare("select command from history order by id asc") + .unwrap() + .query_map(params![], |row| row.get(0)) + .unwrap() + .collect::>>() + .unwrap(); + return Box::new(fwd.into_iter()); + } + + fn back(&mut self) { + self.navigate_in_direction(true) + // self.cursor.id + } + + fn forward(&mut self) { + self.navigate_in_direction(false) + } + + fn string_at_cursor(&self) -> Option { + self.cursor.command.clone() + } + + fn set_navigation(&mut self, navigation: HistoryNavigationQuery) { + self.cursor.query = navigation; + self.reset_cursor(); + } + + fn get_navigation(&self) -> HistoryNavigationQuery { + self.cursor.query.clone() + } + + fn query_entries(&self, search: &str) -> Vec { + self.iter_chronologic() + .rev() + .filter(|entry| entry.contains(search)) + .collect::>() + } + + fn max_values(&self) -> usize { + self.last_run_command_id.unwrap_or(0) as usize + } + + /// Writes unwritten history contents to disk. + /// + /// If file would exceed `capacity` truncates the oldest entries. + fn sync(&mut self) -> std::io::Result<()> { + Ok(()) + } + + /// Reset the internal browsing cursor + fn reset_cursor(&mut self) { + // if no command run yet, fetch last id from db + if self.last_run_command_id == None { + self.last_run_command_id = self + .db + .prepare("select coalesce(max(id), 0) from history") + .unwrap() + .query_row(params![], |e| e.get(0)) + .optional() + .unwrap(); + } + self.cursor.id = self.last_run_command_id.unwrap_or(0) + 1; + self.cursor.command = None; + } +} +fn map_sqlite_err(err: rusqlite::Error) -> std::io::Error { + // todo: better error mapping + std::io::Error::new(std::io::ErrorKind::Other, err) +} + +impl SqliteBackedHistory { + /// Creates a new history with an associated history file. + /// + /// + /// **Side effects:** creates all nested directories to the file + /// + pub fn with_file(file: PathBuf) -> std::io::Result { + if let Some(base_dir) = file.parent() { + std::fs::create_dir_all(base_dir)?; + } + let db = Connection::open(&file).map_err(map_sqlite_err)?; + Self::from_connection(db) + } + /// Creates a new history in memory + pub fn in_memory() -> std::io::Result { + Self::from_connection(Connection::open_in_memory().map_err(map_sqlite_err)?) + } + /// initialize a new database / migrate an existing one + fn from_connection(db: Connection) -> std::io::Result { + // https://phiresky.github.io/blog/2020/sqlite-performance-tuning/ + db.pragma_update(None, "journal_mode", "wal") + .map_err(map_sqlite_err)?; + db.pragma_update(None, "synchronous", "normal") + .map_err(map_sqlite_err)?; + db.pragma_update(None, "mmap_size", "1000000000") + .map_err(map_sqlite_err)?; + db.pragma_update(None, "foreign_keys", "on") + .map_err(map_sqlite_err)?; + db.execute( + " + create table if not exists history ( + id integer primary key autoincrement, + command text not null, + context text not null + ) strict; + ", + params![], + ) + .map_err(map_sqlite_err)?; + let mut hist = SqliteBackedHistory { + db, + last_run_command_id: None, + last_run_command_context: None, + cursor: SqliteHistoryCursor { + id: 0, + command: None, + query: HistoryNavigationQuery::Normal(LineBuffer::default()), + }, + }; + hist.reset_cursor(); + Ok(hist) + } + + // todo: better error type (which one?) + /// updates the context stored for the last ran command + pub fn update_last_command_context(&mut self, callback: F) -> Result<(), String> + where + F: FnOnce(ContextType) -> ContextType, + { + if let (Some(id), Some(ctx)) = ( + self.last_run_command_id, + self.last_run_command_context.take(), + ) { + let mapped_ctx = callback(ctx); + self.db + .execute( + "update history set context = :context where id = :id", + named_params! { + ":context": serde_json::to_string(&mapped_ctx).map_err(|e| format!("{e}"))?, + ":id": id + }, + ) + .map_err(|e| format!("{e}"))?; + self.last_run_command_context.replace(mapped_ctx); + Ok(()) + } else { + Err(format!("No command has been executed yet")) + } + } + + fn navigate_in_direction(&mut self, backward: bool) { + let like_str = match &self.cursor.query { + HistoryNavigationQuery::Normal(_) => format!("%"), + HistoryNavigationQuery::PrefixSearch(prefix) => format!("{prefix}%"), + HistoryNavigationQuery::SubstringSearch(cont) => format!("%{cont}%"), + }; + let query = if backward { + "select id, command from history where id < :id and command like :like and command != :prev_result order by id desc limit 1" + } else { + "select id, command from history where id > :id and command like :like and command != :prev_result order by id asc limit 1" + }; + let next_id: Option<(i64, String)> = self + .db + .prepare(query) + .unwrap() + .query_row( + named_params! { + ":id": self.cursor.id, + ":like": like_str, + ":prev_result": self.cursor.command.clone().unwrap_or(String::new()) + }, + |e| Ok((e.get(0)?, e.get(1)?)), + ) + .optional() + .unwrap(); + if let Some((next_id, next_command)) = next_id { + self.cursor.id = next_id; + self.cursor.command = Some(next_command); + } else { + if !backward { + // forward search resets to none, backwards search doesn't + self.cursor.command = None; + } + } + } +} +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + fn in_memory_for_test() -> SqliteBackedHistory<()> { + SqliteBackedHistory::in_memory().unwrap() + } + fn with_file_for_test( + capacity: i32, + file: PathBuf, + ) -> std::io::Result> { + SqliteBackedHistory::with_file(file) + } + + #[test] + fn accessing_empty_history_returns_nothing() { + let hist = in_memory_for_test(); + assert_eq!(hist.string_at_cursor(), None); + } + + #[test] + fn going_forward_in_empty_history_does_not_error_out() { + let mut hist = in_memory_for_test(); + hist.forward(); + assert_eq!(hist.string_at_cursor(), None); + } + + #[test] + fn going_backwards_in_empty_history_does_not_error_out() { + let mut hist = in_memory_for_test(); + hist.back(); + assert_eq!(hist.string_at_cursor(), None); + } + + #[test] + fn going_backwards_bottoms_out() { + let mut hist = in_memory_for_test(); + hist.append("command1"); + hist.append("command2"); + hist.back(); + hist.back(); + hist.back(); + hist.back(); + hist.back(); + assert_eq!(hist.string_at_cursor(), Some("command1".to_string())); + } + + #[test] + fn going_forwards_bottoms_out() { + let mut hist = in_memory_for_test(); + hist.append("command1"); + hist.append("command2"); + hist.forward(); + hist.forward(); + hist.forward(); + hist.forward(); + hist.forward(); + assert_eq!(hist.string_at_cursor(), None); + } + + /*#[test] + fn appends_only_unique() { + let mut hist = in_memory_for_test(); + hist.append("unique_old"); + hist.append("test"); + hist.append("test"); + hist.append("unique"); + assert_eq!(hist.entries.len(), 3); + } + #[test] + fn appends_no_empties() { + let mut hist = in_memory_for_test(); + hist.append(""); + assert_eq!(hist.entries.len(), 0); + }*/ + + #[test] + fn prefix_search_works() { + let mut hist = in_memory_for_test(); + hist.append("find me as well"); + hist.append("test"); + hist.append("find me"); + + hist.set_navigation(HistoryNavigationQuery::PrefixSearch("find".to_string())); + + hist.back(); + assert_eq!(hist.string_at_cursor(), Some("find me".to_string())); + hist.back(); + assert_eq!(hist.string_at_cursor(), Some("find me as well".to_string())); + } + + #[test] + fn prefix_search_bottoms_out() { + let mut hist = in_memory_for_test(); + hist.append("find me as well"); + hist.append("test"); + hist.append("find me"); + + hist.set_navigation(HistoryNavigationQuery::PrefixSearch("find".to_string())); + hist.back(); + assert_eq!(hist.string_at_cursor(), Some("find me".to_string())); + hist.back(); + assert_eq!(hist.string_at_cursor(), Some("find me as well".to_string())); + hist.back(); + hist.back(); + hist.back(); + hist.back(); + assert_eq!(hist.string_at_cursor(), Some("find me as well".to_string())); + } + #[test] + fn prefix_search_returns_to_none() { + let mut hist = in_memory_for_test(); + hist.append("find me as well"); + hist.append("test"); + hist.append("find me"); + + hist.set_navigation(HistoryNavigationQuery::PrefixSearch("find".to_string())); + hist.back(); + assert_eq!(hist.string_at_cursor(), Some("find me".to_string())); + hist.back(); + assert_eq!(hist.string_at_cursor(), Some("find me as well".to_string())); + hist.forward(); + assert_eq!(hist.string_at_cursor(), Some("find me".to_string())); + hist.forward(); + assert_eq!(hist.string_at_cursor(), None); + hist.forward(); + assert_eq!(hist.string_at_cursor(), None); + } + + #[test] + fn prefix_search_ignores_consecutive_equivalent_entries_going_backwards() { + let mut hist = in_memory_for_test(); + hist.append("find me as well"); + hist.append("find me once"); + hist.append("test"); + hist.append("find me once"); + + hist.set_navigation(HistoryNavigationQuery::PrefixSearch("find".to_string())); + hist.back(); + assert_eq!(hist.string_at_cursor(), Some("find me once".to_string())); + hist.back(); + assert_eq!(hist.string_at_cursor(), Some("find me as well".to_string())); + } + + #[test] + fn prefix_search_ignores_consecutive_equivalent_entries_going_forwards() { + let mut hist = in_memory_for_test(); + hist.append("find me once"); + hist.append("test"); + hist.append("find me once"); + hist.append("find me as well"); + + hist.set_navigation(HistoryNavigationQuery::PrefixSearch("find".to_string())); + hist.back(); + hist.back(); + assert_eq!(hist.string_at_cursor(), Some("find me once".to_string())); + hist.forward(); + assert_eq!(hist.string_at_cursor(), Some("find me as well".to_string())); + hist.forward(); + assert_eq!(hist.string_at_cursor(), None); + } + + #[test] + fn substring_search_works() { + let mut hist = in_memory_for_test(); + hist.append("substring"); + hist.append("don't find me either"); + hist.append("prefix substring"); + hist.append("don't find me"); + hist.append("prefix substring suffix"); + + hist.set_navigation(HistoryNavigationQuery::SubstringSearch( + "substring".to_string(), + )); + hist.back(); + assert_eq!( + hist.string_at_cursor(), + Some("prefix substring suffix".to_string()) + ); + hist.back(); + assert_eq!( + hist.string_at_cursor(), + Some("prefix substring".to_string()) + ); + hist.back(); + assert_eq!(hist.string_at_cursor(), Some("substring".to_string())); + } + + #[test] + fn substring_search_with_empty_value_returns_none() { + let mut hist = in_memory_for_test(); + hist.append("substring"); + + hist.set_navigation(HistoryNavigationQuery::SubstringSearch("".to_string())); + + assert_eq!(hist.string_at_cursor(), None); + } + + #[test] + fn writes_to_new_file() { + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + // check that it also works for a path where the directory has not been created yet + let histfile = tmp.path().join("nested_path").join(".history"); + + let entries = vec!["test", "text", "more test text"]; + + { + let mut hist = with_file_for_test(5, histfile.clone()).unwrap(); + + entries.iter().for_each(|e| hist.append(e)); + + // As `hist` goes out of scope and get's dropped, its contents are flushed to disk + } + + let reading_hist = with_file_for_test(5, histfile).unwrap(); + + let actual: Vec<_> = reading_hist.iter_chronologic().collect(); + assert_eq!(entries, actual); + + tmp.close().unwrap(); + } + + #[test] + fn persists_newlines_in_entries() { + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let histfile = tmp.path().join(".history"); + + let entries = vec![ + "test", + "multiline\nentry\nunix", + "multiline\r\nentry\r\nwindows", + "more test text", + ]; + + { + let mut writing_hist = with_file_for_test(5, histfile.clone()).unwrap(); + + entries.iter().for_each(|e| writing_hist.append(e)); + + // As `hist` goes out of scope and get's dropped, its contents are flushed to disk + } + + let reading_hist = with_file_for_test(5, histfile).unwrap(); + + let actual: Vec<_> = reading_hist.iter_chronologic().collect(); + assert_eq!(entries, actual); + + tmp.close().unwrap(); + } + + #[test] + fn concurrent_histories_dont_erase_eachother() { + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let histfile = tmp.path().join(".history"); + + let capacity = 7; + let initial_entries = vec!["test 1", "test 2", "test 3", "test 4", "test 5"]; + let entries_a = vec!["A1", "A2", "A3"]; + let entries_b = vec!["B1", "B2", "B3"]; + let expected_entries = vec![ + "test 1", "test 2", "test 3", "test 4", "test 5", "B1", "B2", "B3", "A1", "A2", "A3", + ]; + + { + let mut writing_hist = SqliteBackedHistory::<()>::with_file(histfile.clone()).unwrap(); + + initial_entries.iter().for_each(|e| writing_hist.append(e)); + + // As `hist` goes out of scope and get's dropped, its contents are flushed to disk + } + + { + let mut hist_a = with_file_for_test(capacity, histfile.clone()).unwrap(); + + { + let mut hist_b = with_file_for_test(capacity, histfile.clone()).unwrap(); + + entries_b.iter().for_each(|e| hist_b.append(e)); + + // As `hist` goes out of scope and get's dropped, its contents are flushed to disk + } + entries_a.iter().for_each(|e| hist_a.append(e)); + + // As `hist` goes out of scope and get's dropped, its contents are flushed to disk + } + + let reading_hist = with_file_for_test(capacity, histfile).unwrap(); + + let actual: Vec<_> = reading_hist.iter_chronologic().collect(); + assert_eq!(expected_entries, actual); + + tmp.close().unwrap(); + } + + #[test] + fn concurrent_histories_are_threadsafe() { + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let histfile = tmp.path().join(".history"); + + let num_threads = 16; + let capacity = 2 * num_threads + 1; + + let initial_entries = (0..capacity).map(|i| format!("initial {i}")); + + { + let mut writing_hist = with_file_for_test(capacity, histfile.clone()).unwrap(); + + initial_entries.for_each(|e| writing_hist.append(&e)); + + // As `hist` goes out of scope and get's dropped, its contents are flushed to disk + } + + let threads = (0..num_threads) + .map(|i| { + let cap = capacity; + let hfile = histfile.clone(); + std::thread::spawn(move || { + let mut hist = with_file_for_test(cap, hfile).unwrap(); + hist.append(&format!("A{}", i)); + hist.sync().unwrap(); + hist.append(&format!("B{}", i)); + }) + }) + .collect::>(); + + for t in threads { + t.join().unwrap(); + } + + let reading_hist = with_file_for_test(capacity, histfile).unwrap(); + + let actual: Vec<_> = reading_hist.iter_chronologic().collect(); + + assert!( + actual.contains(&&format!("initial {}", capacity - 1)), + "Overwrote entry from before threading test" + ); + + for i in 0..num_threads { + assert!(actual.contains(&&format!("A{}", i)),); + assert!(actual.contains(&&format!("B{}", i)),); + } + + tmp.close().unwrap(); + } +} diff --git a/src/lib.rs b/src/lib.rs index 99c38310..c9484afd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -192,6 +192,8 @@ pub use engine::Reedline; mod history; pub use history::{FileBackedHistory, History, HistoryNavigationQuery, HISTORY_SIZE}; +#[cfg(feature="sqlite")] +pub use history::{SqliteBackedHistory}; mod prompt; pub use prompt::{ diff --git a/src/main.rs b/src/main.rs index b1597905..be74648b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +use std::time::SystemTime; + use { crossterm::{ event::{poll, Event, KeyCode, KeyEvent, KeyModifiers}, @@ -18,6 +20,10 @@ use { }, }; +#[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)] +struct TestContext { + timestamp: u64, +} fn main() -> Result<()> { // quick command like parameter handling let vi_mode = matches!(std::env::args().nth(1), Some(x) if x == "--vi"); @@ -35,6 +41,14 @@ fn main() -> Result<()> { return Ok(()); } + #[cfg(feature = "sqlite")] + let (history, history_clone) = { + let history = Box::new(std::sync::Arc::new(std::sync::Mutex::new( + reedline::SqliteBackedHistory::::with_file("history.sqlite3".into())?, + ))); + (history.clone(), history) + }; + #[cfg(not(feature = "sqlite"))] let history = Box::new(FileBackedHistory::with_file(50, "history.txt".into())?); let commands = vec![ "test".into(), @@ -112,6 +126,18 @@ fn main() -> Result<()> { break; } Ok(Signal::Success(buffer)) => { + #[cfg(feature = "sqlite")] + { + // save timestamp to history + history_clone + .lock() + .expect("lock poisoned") + .update_last_command_context(|mut c| { + c.timestamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_millis() as u64; + c + }) + .unwrap(); + } if (buffer.trim() == "exit") || (buffer.trim() == "logout") { break; } diff --git a/src/menu/history_menu.rs b/src/menu/history_menu.rs index 76a4b562..1d4fc4fc 100644 --- a/src/menu/history_menu.rs +++ b/src/menu/history_menu.rs @@ -160,7 +160,6 @@ impl HistoryMenu { .rev() .skip(skip) .take(take) - .cloned() .collect::>() }