search_history.rs

  1use std::collections::VecDeque;
  2
  3/// Determines the behavior to use when inserting a new query into the search history.
  4#[derive(Default, Debug, Clone, PartialEq)]
  5pub enum QueryInsertionBehavior {
  6    #[default]
  7    /// Always insert the query to the search history.
  8    AlwaysInsert,
  9    /// Replace the previous query in the search history, if the new query contains the previous query.
 10    ReplacePreviousIfContains,
 11}
 12
 13/// A cursor that stores an index to the currently selected query in the search history.
 14/// This can be passed to the search history to update the selection accordingly,
 15/// e.g. when using the up and down arrow keys to navigate the search history.
 16///
 17/// Note: The cursor can point to the wrong query, if the maximum length of the history is exceeded
 18/// and the old query is overwritten.
 19#[derive(Default, Debug, Clone, PartialEq, Eq, Hash)]
 20pub struct SearchHistoryCursor {
 21    selection: Option<usize>,
 22}
 23
 24impl SearchHistoryCursor {
 25    /// Resets the selection to `None`.
 26    pub fn reset(&mut self) {
 27        self.selection = None;
 28    }
 29}
 30
 31#[derive(Debug, Clone)]
 32pub struct SearchHistory {
 33    history: VecDeque<String>,
 34    max_history_len: Option<usize>,
 35    insertion_behavior: QueryInsertionBehavior,
 36}
 37
 38impl SearchHistory {
 39    pub fn new(max_history_len: Option<usize>, insertion_behavior: QueryInsertionBehavior) -> Self {
 40        SearchHistory {
 41            max_history_len,
 42            insertion_behavior,
 43            history: VecDeque::new(),
 44        }
 45    }
 46
 47    pub fn add(&mut self, cursor: &mut SearchHistoryCursor, search_string: String) {
 48        if self.insertion_behavior == QueryInsertionBehavior::ReplacePreviousIfContains
 49            && let Some(previously_searched) = self.history.back_mut()
 50                && search_string.contains(previously_searched.as_str()) {
 51                    *previously_searched = search_string;
 52                    cursor.selection = Some(self.history.len() - 1);
 53                    return;
 54                }
 55
 56        if let Some(max_history_len) = self.max_history_len
 57            && self.history.len() >= max_history_len {
 58                self.history.pop_front();
 59            }
 60        self.history.push_back(search_string);
 61
 62        cursor.selection = Some(self.history.len() - 1);
 63    }
 64
 65    pub fn next(&mut self, cursor: &mut SearchHistoryCursor) -> Option<&str> {
 66        let selected = cursor.selection?;
 67        let next_index = selected + 1;
 68
 69        let next = self.history.get(next_index)?;
 70        cursor.selection = Some(next_index);
 71        Some(next)
 72    }
 73
 74    pub fn current(&self, cursor: &SearchHistoryCursor) -> Option<&str> {
 75        cursor
 76            .selection
 77            .and_then(|selected_ix| self.history.get(selected_ix).map(|s| s.as_str()))
 78    }
 79
 80    /// Get the previous history entry using the given `SearchHistoryCursor`.
 81    /// Uses the last element in the history when there is no cursor.
 82    pub fn previous(&mut self, cursor: &mut SearchHistoryCursor) -> Option<&str> {
 83        let prev_index = match cursor.selection {
 84            Some(index) => index.checked_sub(1)?,
 85            None => self.history.len().checked_sub(1)?,
 86        };
 87
 88        let previous = self.history.get(prev_index)?;
 89        cursor.selection = Some(prev_index);
 90        Some(previous)
 91    }
 92}
 93
 94#[cfg(test)]
 95mod tests {
 96    use super::*;
 97
 98    #[test]
 99    fn test_add() {
100        const MAX_HISTORY_LEN: usize = 20;
101        let mut search_history = SearchHistory::new(
102            Some(MAX_HISTORY_LEN),
103            QueryInsertionBehavior::ReplacePreviousIfContains,
104        );
105        let mut cursor = SearchHistoryCursor::default();
106
107        assert_eq!(
108            search_history.current(&cursor),
109            None,
110            "No current selection should be set for the default search history"
111        );
112
113        search_history.add(&mut cursor, "rust".to_string());
114        assert_eq!(
115            search_history.current(&cursor),
116            Some("rust"),
117            "Newly added item should be selected"
118        );
119
120        // check if duplicates are not added
121        search_history.add(&mut cursor, "rust".to_string());
122        assert_eq!(
123            search_history.history.len(),
124            1,
125            "Should not add a duplicate"
126        );
127        assert_eq!(search_history.current(&cursor), Some("rust"));
128
129        // check if new string containing the previous string replaces it
130        search_history.add(&mut cursor, "rustlang".to_string());
131        assert_eq!(
132            search_history.history.len(),
133            1,
134            "Should replace previous item if it's a substring"
135        );
136        assert_eq!(search_history.current(&cursor), Some("rustlang"));
137
138        // add item when it equals to current item if it's not the last one
139        search_history.add(&mut cursor, "php".to_string());
140        search_history.previous(&mut cursor);
141        assert_eq!(search_history.current(&cursor), Some("rustlang"));
142        search_history.add(&mut cursor, "rustlang".to_string());
143        assert_eq!(search_history.history.len(), 3, "Should add item");
144        assert_eq!(search_history.current(&cursor), Some("rustlang"));
145
146        // push enough items to test SEARCH_HISTORY_LIMIT
147        for i in 0..MAX_HISTORY_LEN * 2 {
148            search_history.add(&mut cursor, format!("item{i}"));
149        }
150        assert!(search_history.history.len() <= MAX_HISTORY_LEN);
151    }
152
153    #[test]
154    fn test_next_and_previous() {
155        let mut search_history = SearchHistory::new(None, QueryInsertionBehavior::AlwaysInsert);
156        let mut cursor = SearchHistoryCursor::default();
157
158        assert_eq!(
159            search_history.next(&mut cursor),
160            None,
161            "Default search history should not have a next item"
162        );
163
164        search_history.add(&mut cursor, "Rust".to_string());
165        assert_eq!(search_history.next(&mut cursor), None);
166        search_history.add(&mut cursor, "JavaScript".to_string());
167        assert_eq!(search_history.next(&mut cursor), None);
168        search_history.add(&mut cursor, "TypeScript".to_string());
169        assert_eq!(search_history.next(&mut cursor), None);
170
171        assert_eq!(search_history.current(&cursor), Some("TypeScript"));
172
173        assert_eq!(search_history.previous(&mut cursor), Some("JavaScript"));
174        assert_eq!(search_history.current(&cursor), Some("JavaScript"));
175
176        assert_eq!(search_history.previous(&mut cursor), Some("Rust"));
177        assert_eq!(search_history.current(&cursor), Some("Rust"));
178
179        assert_eq!(search_history.previous(&mut cursor), None);
180        assert_eq!(search_history.current(&cursor), Some("Rust"));
181
182        assert_eq!(search_history.next(&mut cursor), Some("JavaScript"));
183        assert_eq!(search_history.current(&cursor), Some("JavaScript"));
184
185        assert_eq!(search_history.next(&mut cursor), Some("TypeScript"));
186        assert_eq!(search_history.current(&cursor), Some("TypeScript"));
187
188        assert_eq!(search_history.next(&mut cursor), None);
189        assert_eq!(search_history.current(&cursor), Some("TypeScript"));
190    }
191
192    #[test]
193    fn test_reset_selection() {
194        let mut search_history = SearchHistory::new(None, QueryInsertionBehavior::AlwaysInsert);
195        let mut cursor = SearchHistoryCursor::default();
196
197        search_history.add(&mut cursor, "Rust".to_string());
198        search_history.add(&mut cursor, "JavaScript".to_string());
199        search_history.add(&mut cursor, "TypeScript".to_string());
200
201        assert_eq!(search_history.current(&cursor), Some("TypeScript"));
202        cursor.reset();
203        assert_eq!(search_history.current(&mut cursor), None);
204        assert_eq!(
205            search_history.previous(&mut cursor),
206            Some("TypeScript"),
207            "Should start from the end after reset on previous item query"
208        );
209
210        search_history.previous(&mut cursor);
211        assert_eq!(search_history.current(&cursor), Some("JavaScript"));
212        search_history.previous(&mut cursor);
213        assert_eq!(search_history.current(&cursor), Some("Rust"));
214
215        cursor.reset();
216        assert_eq!(search_history.current(&cursor), None);
217    }
218
219    #[test]
220    fn test_multiple_cursors() {
221        let mut search_history = SearchHistory::new(None, QueryInsertionBehavior::AlwaysInsert);
222        let mut cursor1 = SearchHistoryCursor::default();
223        let mut cursor2 = SearchHistoryCursor::default();
224
225        search_history.add(&mut cursor1, "Rust".to_string());
226        search_history.add(&mut cursor1, "JavaScript".to_string());
227        search_history.add(&mut cursor1, "TypeScript".to_string());
228
229        search_history.add(&mut cursor2, "Python".to_string());
230        search_history.add(&mut cursor2, "Java".to_string());
231        search_history.add(&mut cursor2, "C++".to_string());
232
233        assert_eq!(search_history.current(&cursor1), Some("TypeScript"));
234        assert_eq!(search_history.current(&cursor2), Some("C++"));
235
236        assert_eq!(search_history.previous(&mut cursor1), Some("JavaScript"));
237        assert_eq!(search_history.previous(&mut cursor2), Some("Java"));
238
239        assert_eq!(search_history.next(&mut cursor1), Some("TypeScript"));
240        assert_eq!(search_history.next(&mut cursor1), Some("Python"));
241
242        cursor1.reset();
243        cursor2.reset();
244
245        assert_eq!(search_history.current(&cursor1), None);
246        assert_eq!(search_history.current(&cursor2), None);
247    }
248}