reedline/history/
base.rs

1use super::HistoryItemId;
2use crate::{core_editor::LineBuffer, HistoryItem, HistorySessionId, Result};
3use chrono::Utc;
4
5/// Browsing modes for a [`History`]
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum HistoryNavigationQuery {
8    /// `bash` style browsing through the history. Contained `LineBuffer` is used to store the state of manual entry before browsing through the history
9    Normal(LineBuffer),
10    /// Search for entries starting with a particular string.
11    PrefixSearch(String),
12    /// Full exact search for all entries containing a string.
13    SubstringSearch(String),
14    // Suffix Search
15    // Fuzzy Search
16}
17
18/// Ways to search for a particular command line in the [`History`]
19// todo: merge with [HistoryNavigationQuery]
20pub enum CommandLineSearch {
21    /// Command line starts with the same string
22    Prefix(String),
23    /// Command line contains the string
24    Substring(String),
25    /// Command line is the string.
26    ///
27    /// Useful to gather statistics
28    Exact(String),
29}
30
31/// Defines how to traverse the history when executing a [`SearchQuery`]
32#[derive(Clone, Copy, PartialEq, Eq)]
33pub enum SearchDirection {
34    /// From the most recent entry backward
35    Backward,
36    /// From the least recent entry forward
37    Forward,
38}
39
40/// Defines additional filters for querying the [`History`]
41pub struct SearchFilter {
42    /// Query for the command line content
43    pub command_line: Option<CommandLineSearch>,
44    /// Considered implementation detail for now
45    pub(crate) not_command_line: Option<String>, // to skip the currently shown value in up-arrow navigation
46    /// Filter based on the executing systems hostname
47    pub hostname: Option<String>,
48    /// Exact filter for the working directory
49    pub cwd_exact: Option<String>,
50    /// Prefix filter for the working directory
51    pub cwd_prefix: Option<String>,
52    /// Filter whether the command completed
53    pub exit_successful: Option<bool>,
54    /// Filter on the session id
55    pub session: Option<HistorySessionId>,
56}
57
58impl SearchFilter {
59    /// Create a search filter with a [`CommandLineSearch`]
60    pub fn from_text_search(
61        cmd: CommandLineSearch,
62        session: Option<HistorySessionId>,
63    ) -> SearchFilter {
64        let mut s = SearchFilter::anything(session);
65        s.command_line = Some(cmd);
66        s
67    }
68
69    /// Create a search filter with a [`CommandLineSearch`] and `cwd`
70    pub fn from_text_search_cwd(
71        cwd: String,
72        cmd: CommandLineSearch,
73        session: Option<HistorySessionId>,
74    ) -> SearchFilter {
75        let mut s = SearchFilter::anything(session);
76        s.command_line = Some(cmd);
77        s.cwd_exact = Some(cwd);
78        s
79    }
80
81    /// anything within this session
82    pub fn anything(session: Option<HistorySessionId>) -> SearchFilter {
83        SearchFilter {
84            command_line: None,
85            not_command_line: None,
86            hostname: None,
87            cwd_exact: None,
88            cwd_prefix: None,
89            exit_successful: None,
90            session,
91        }
92    }
93}
94
95/// Query for search in the potentially rich [`History`]
96pub struct SearchQuery {
97    /// Direction to search in
98    pub direction: SearchDirection,
99    /// if given, only get results after/before this time (depending on direction)
100    pub start_time: Option<chrono::DateTime<Utc>>,
101    /// if given, only get results after/before this time (depending on direction)
102    pub end_time: Option<chrono::DateTime<Utc>>,
103    /// if given, only get results after/before this id (depending on direction)
104    pub start_id: Option<HistoryItemId>,
105    /// if given, only get results after/before this id (depending on direction)
106    pub end_id: Option<HistoryItemId>,
107    /// How many results to get
108    pub limit: Option<i64>,
109    /// Additional filters defined with [`SearchFilter`]
110    pub filter: SearchFilter,
111}
112
113/// Currently `pub` ways to construct a query
114impl SearchQuery {
115    /// all that contain string in reverse chronological order
116    pub fn all_that_contain_rev(contains: String) -> SearchQuery {
117        SearchQuery {
118            direction: SearchDirection::Backward,
119            start_time: None,
120            end_time: None,
121            start_id: None,
122            end_id: None,
123            limit: None,
124            filter: SearchFilter::from_text_search(CommandLineSearch::Substring(contains), None),
125        }
126    }
127
128    /// Get the most recent entry matching [`SearchFilter`]
129    pub const fn last_with_search(filter: SearchFilter) -> SearchQuery {
130        SearchQuery {
131            direction: SearchDirection::Backward,
132            start_time: None,
133            end_time: None,
134            start_id: None,
135            end_id: None,
136            limit: Some(1),
137            filter,
138        }
139    }
140
141    /// Get the most recent entry starting with the `prefix`
142    pub fn last_with_prefix(prefix: String, session: Option<HistorySessionId>) -> SearchQuery {
143        SearchQuery::last_with_search(SearchFilter::from_text_search(
144            CommandLineSearch::Prefix(prefix),
145            session,
146        ))
147    }
148
149    /// Get the most recent entry starting with the `prefix` and `cwd` same as the current cwd
150    pub fn last_with_prefix_and_cwd(
151        prefix: String,
152        session: Option<HistorySessionId>,
153    ) -> SearchQuery {
154        let cwd = std::env::current_dir();
155        if let Ok(cwd) = cwd {
156            SearchQuery::last_with_search(SearchFilter::from_text_search_cwd(
157                cwd.to_string_lossy().to_string(),
158                CommandLineSearch::Prefix(prefix),
159                session,
160            ))
161        } else {
162            SearchQuery::last_with_search(SearchFilter::from_text_search(
163                CommandLineSearch::Prefix(prefix),
164                session,
165            ))
166        }
167    }
168
169    /// Query to get all entries in the given [`SearchDirection`]
170    pub fn everything(
171        direction: SearchDirection,
172        session: Option<HistorySessionId>,
173    ) -> SearchQuery {
174        SearchQuery {
175            direction,
176            start_time: None,
177            end_time: None,
178            start_id: None,
179            end_id: None,
180            limit: None,
181            filter: SearchFilter::anything(session),
182        }
183    }
184}
185
186/// Represents a history file or database
187/// Data could be stored e.g. in a plain text file, in a `JSONL` file, in a `SQLite` database
188pub trait History: Send {
189    /// save a history item to the database
190    /// if given id is None, a new id is created and set in the return value
191    /// if given id is Some, the existing entry is updated
192    fn save(&mut self, h: HistoryItem) -> Result<HistoryItem>;
193    /// load a history item by its id
194    fn load(&self, id: HistoryItemId) -> Result<HistoryItem>;
195
196    /// retrieves the next unused session id
197
198    /// count the results of a query
199    fn count(&self, query: SearchQuery) -> Result<i64>;
200    /// return the total number of history items
201    fn count_all(&self) -> Result<i64> {
202        self.count(SearchQuery::everything(SearchDirection::Forward, None))
203    }
204    /// return the results of a query
205    fn search(&self, query: SearchQuery) -> Result<Vec<HistoryItem>>;
206
207    /// update an item atomically
208    fn update(
209        &mut self,
210        id: HistoryItemId,
211        updater: &dyn Fn(HistoryItem) -> HistoryItem,
212    ) -> Result<()>;
213    /// delete all history items
214    fn clear(&mut self) -> Result<()>;
215    /// remove an item from this history
216    fn delete(&mut self, h: HistoryItemId) -> Result<()>;
217    /// ensure that this history is written to disk
218    fn sync(&mut self) -> std::io::Result<()>;
219    /// get the history session id
220    fn session(&self) -> Option<HistorySessionId>;
221}
222
223#[cfg(test)]
224mod test {
225    #[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))]
226    const IS_FILE_BASED: bool = false;
227    #[cfg(not(any(feature = "sqlite", feature = "sqlite-dynlib")))]
228    const IS_FILE_BASED: bool = true;
229
230    use crate::HistorySessionId;
231
232    fn create_item(session: i64, cwd: &str, cmd: &str, exit_status: i64) -> HistoryItem {
233        HistoryItem {
234            id: None,
235            start_timestamp: None,
236            command_line: cmd.to_string(),
237            session_id: Some(HistorySessionId::new(session)),
238            hostname: Some("foohost".to_string()),
239            cwd: Some(cwd.to_string()),
240            duration: Some(Duration::from_millis(1000)),
241            exit_status: Some(exit_status),
242            more_info: None,
243        }
244    }
245    use std::time::Duration;
246
247    use super::*;
248    fn create_filled_example_history() -> Result<Box<dyn History>> {
249        #[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))]
250        let mut history = crate::SqliteBackedHistory::in_memory()?;
251        #[cfg(not(any(feature = "sqlite", feature = "sqlite-dynlib")))]
252        let mut history = crate::FileBackedHistory::default();
253        #[cfg(not(any(feature = "sqlite", feature = "sqlite-dynlib")))]
254        history.save(create_item(1, "/", "dummy", 0))?; // add dummy item so ids start with 1
255        history.save(create_item(1, "/home/me", "cd ~/Downloads", 0))?; // 1
256        history.save(create_item(1, "/home/me/Downloads", "unzp foo.zip", 1))?; // 2
257        history.save(create_item(1, "/home/me/Downloads", "unzip foo.zip", 0))?; // 3
258        history.save(create_item(1, "/home/me/Downloads", "cd foo", 0))?; // 4
259        history.save(create_item(1, "/home/me/Downloads/foo", "ls", 0))?; // 5
260        history.save(create_item(1, "/home/me/Downloads/foo", "ls -alh", 0))?; // 6
261        history.save(create_item(1, "/home/me/Downloads/foo", "cat x.txt", 0))?; // 7
262
263        history.save(create_item(1, "/home/me", "cd /etc/nginx", 0))?; // 8
264        history.save(create_item(1, "/etc/nginx", "ls -l", 0))?; // 9
265        history.save(create_item(1, "/etc/nginx", "vim nginx.conf", 0))?; // 10
266        history.save(create_item(1, "/etc/nginx", "vim htpasswd", 0))?; // 11
267        history.save(create_item(1, "/etc/nginx", "cat nginx.conf", 0))?; // 12
268        Ok(Box::new(history))
269    }
270
271    #[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))]
272    #[test]
273    fn update_item() -> Result<()> {
274        let mut history = create_filled_example_history()?;
275        let id = HistoryItemId::new(2);
276        let before = history.load(id)?;
277        history.update(id, &|mut e| {
278            e.exit_status = Some(1);
279            e
280        })?;
281        let after = history.load(id)?;
282        assert_eq!(
283            after,
284            HistoryItem {
285                exit_status: Some(1),
286                ..before
287            }
288        );
289        Ok(())
290    }
291
292    fn search_returned(
293        history: &dyn History,
294        res: Vec<HistoryItem>,
295        wanted: Vec<i64>,
296    ) -> Result<()> {
297        let wanted = wanted
298            .iter()
299            .map(|id| history.load(HistoryItemId::new(*id)))
300            .collect::<Result<Vec<HistoryItem>>>()?;
301        assert_eq!(res, wanted);
302        Ok(())
303    }
304
305    #[test]
306    fn count_all() -> Result<()> {
307        let history = create_filled_example_history()?;
308        println!(
309            "{:#?}",
310            history.search(SearchQuery::everything(SearchDirection::Forward, None))
311        );
312
313        assert_eq!(history.count_all()?, if IS_FILE_BASED { 13 } else { 12 });
314        Ok(())
315    }
316
317    #[test]
318    fn get_latest() -> Result<()> {
319        let history = create_filled_example_history()?;
320        let res = history.search(SearchQuery::last_with_search(SearchFilter::anything(None)))?;
321
322        search_returned(&*history, res, vec![12])?;
323        Ok(())
324    }
325
326    #[test]
327    fn get_earliest() -> Result<()> {
328        let history = create_filled_example_history()?;
329        let res = history.search(SearchQuery {
330            limit: Some(1),
331            ..SearchQuery::everything(SearchDirection::Forward, None)
332        })?;
333        search_returned(&*history, res, vec![if IS_FILE_BASED { 0 } else { 1 }])?;
334        Ok(())
335    }
336
337    #[test]
338    fn search_prefix() -> Result<()> {
339        let history = create_filled_example_history()?;
340        let res = history.search(SearchQuery {
341            filter: SearchFilter::from_text_search(
342                CommandLineSearch::Prefix("ls ".to_string()),
343                None,
344            ),
345            ..SearchQuery::everything(SearchDirection::Backward, None)
346        })?;
347        search_returned(&*history, res, vec![9, 6])?;
348
349        Ok(())
350    }
351
352    #[test]
353    fn search_includes() -> Result<()> {
354        let history = create_filled_example_history()?;
355        let res = history.search(SearchQuery {
356            filter: SearchFilter::from_text_search(
357                CommandLineSearch::Substring("foo.zip".to_string()),
358                None,
359            ),
360            ..SearchQuery::everything(SearchDirection::Forward, None)
361        })?;
362        search_returned(&*history, res, vec![2, 3])?;
363        Ok(())
364    }
365
366    #[test]
367    fn search_includes_limit() -> Result<()> {
368        let history = create_filled_example_history()?;
369        let res = history.search(SearchQuery {
370            filter: SearchFilter::from_text_search(
371                CommandLineSearch::Substring("c".to_string()),
372                None,
373            ),
374            limit: Some(2),
375            ..SearchQuery::everything(SearchDirection::Forward, None)
376        })?;
377        search_returned(&*history, res, vec![1, 4])?;
378
379        Ok(())
380    }
381
382    #[test]
383    fn clear_history() -> Result<()> {
384        let mut history = create_filled_example_history()?;
385        assert_ne!(history.count_all()?, 0);
386        history.clear().unwrap();
387        assert_eq!(history.count_all()?, 0);
388
389        Ok(())
390    }
391
392    // test that clear() works as expected across multiple instances of History
393    #[test]
394    fn clear_history_with_backing_file() -> Result<()> {
395        #[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))]
396        fn open_history() -> Box<dyn History> {
397            Box::new(
398                crate::SqliteBackedHistory::with_file("target/test-history.db".into(), None, None)
399                    .unwrap(),
400            )
401        }
402
403        #[cfg(not(any(feature = "sqlite", feature = "sqlite-dynlib")))]
404        fn open_history() -> Box<dyn History> {
405            Box::new(
406                crate::FileBackedHistory::with_file(100, "target/test-history.txt".into()).unwrap(),
407            )
408        }
409
410        // create history, add a few entries
411        let mut history = open_history();
412        history.save(create_item(1, "/home/me", "cd ~/Downloads", 0))?; // 1
413        history.save(create_item(1, "/home/me/Downloads", "unzp foo.zip", 1))?; // 2
414        assert_eq!(history.count_all()?, 2);
415        drop(history);
416
417        // open it again and clear it
418        let mut history = open_history();
419        assert_eq!(history.count_all()?, 2);
420        history.clear().unwrap();
421        assert_eq!(history.count_all()?, 0);
422        drop(history);
423
424        // open it once more and confirm that the cleared data is gone forever
425        let history = open_history();
426        assert_eq!(history.count_all()?, 0);
427
428        Ok(())
429    }
430}