Skip to content

Commit ab1b47e

Browse files
authored
Remove duplicates from file based history search (#741)
1 parent e0aa40a commit ab1b47e

File tree

1 file changed

+113
-23
lines changed

1 file changed

+113
-23
lines changed

src/completion/history.rs

Lines changed: 113 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
use std::ops::Deref;
1+
use std::{collections::HashSet, ops::Deref};
22

33
use crate::{
4-
history::SearchQuery, menu_functions::parse_selection_char, Completer, History, Span,
5-
Suggestion,
4+
history::SearchQuery, menu_functions::parse_selection_char, Completer, History, HistoryItem,
5+
Result, Span, Suggestion,
66
};
77

88
const SELECTION_CHAR: char = '!';
@@ -15,33 +15,35 @@ pub(crate) struct HistoryCompleter<'menu>(&'menu dyn History);
1515
// updating the menu and that must happen in the same thread
1616
unsafe impl<'menu> Send for HistoryCompleter<'menu> {}
1717

18+
fn search_unique(
19+
completer: &HistoryCompleter,
20+
line: &str,
21+
) -> Result<impl Iterator<Item = HistoryItem>> {
22+
let parsed = parse_selection_char(line, SELECTION_CHAR);
23+
let values = completer.0.search(SearchQuery::all_that_contain_rev(
24+
parsed.remainder.to_string(),
25+
))?;
26+
27+
let mut seen_matching_command_lines = HashSet::new();
28+
Ok(values
29+
.into_iter()
30+
.filter(move |value| seen_matching_command_lines.insert(value.command_line.clone())))
31+
}
32+
1833
impl<'menu> Completer for HistoryCompleter<'menu> {
1934
fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
20-
let parsed = parse_selection_char(line, SELECTION_CHAR);
21-
let values = self
22-
.0
23-
.search(SearchQuery::all_that_contain_rev(
24-
parsed.remainder.to_string(),
25-
))
26-
.expect("todo: error handling");
27-
28-
values
29-
.into_iter()
30-
.map(|value| self.create_suggestion(line, pos, value.command_line.deref()))
31-
.collect()
35+
match search_unique(self, line) {
36+
Err(_) => vec![],
37+
Ok(search_results) => search_results
38+
.map(|value| self.create_suggestion(line, pos, value.command_line.deref()))
39+
.collect(),
40+
}
3241
}
3342

3443
// TODO: Implement `fn partial_complete()`
3544

3645
fn total_completions(&mut self, line: &str, _pos: usize) -> usize {
37-
let parsed = parse_selection_char(line, SELECTION_CHAR);
38-
let count = self
39-
.0
40-
.count(SearchQuery::all_that_contain_rev(
41-
parsed.remainder.to_string(),
42-
))
43-
.expect("todo: error handling");
44-
count as usize
46+
search_unique(self, line).map(|i| i.count()).unwrap_or(0)
4547
}
4648
}
4749

@@ -66,3 +68,91 @@ impl<'menu> HistoryCompleter<'menu> {
6668
}
6769
}
6870
}
71+
72+
#[cfg(test)]
73+
mod tests {
74+
use rstest::rstest;
75+
76+
use super::*;
77+
use crate::*;
78+
79+
fn new_history_item(command_line: &str) -> HistoryItem {
80+
HistoryItem {
81+
id: None,
82+
start_timestamp: None,
83+
command_line: command_line.to_string(),
84+
session_id: None,
85+
hostname: None,
86+
cwd: None,
87+
duration: None,
88+
exit_status: None,
89+
more_info: None,
90+
}
91+
}
92+
93+
#[test]
94+
fn duplicates_in_history() -> Result<()> {
95+
let command_line_history = vec![
96+
"git stash drop",
97+
"git add .",
98+
"git status | something | else",
99+
"git status",
100+
"git commit -m 'something'",
101+
"ls",
102+
"git push",
103+
"git status",
104+
];
105+
let expected_history_size = command_line_history.len();
106+
let mut history = FileBackedHistory::new(command_line_history.len())?;
107+
for command_line in command_line_history {
108+
history.save(new_history_item(command_line))?;
109+
}
110+
let input = "git s";
111+
let mut sut = HistoryCompleter::new(&history);
112+
113+
let actual = sut.complete(input, input.len());
114+
let num_completions = sut.total_completions(input, input.len());
115+
116+
assert_eq!(actual[0].value, "git status", "it was the last command");
117+
assert_eq!(
118+
actual[1].value, "git status | something | else",
119+
"next match that is not 'git status' again even though it is next in history"
120+
);
121+
assert_eq!(actual[2].value, "git stash drop", "last match");
122+
assert_eq!(actual.get(3), None);
123+
assert_eq!(
124+
3, num_completions,
125+
"total_completions is consistent with complete"
126+
);
127+
128+
assert_eq!(
129+
history.count_all().expect("no error") as usize,
130+
expected_history_size,
131+
"History contains duplicates."
132+
);
133+
Ok(())
134+
}
135+
136+
#[rstest]
137+
#[case(vec![], "any", vec![])]
138+
#[case(vec!["old match","recent match","between","recent match"], "match", vec!["recent match","old match"])]
139+
#[case(vec!["a","b","c","a","b","c"], "", vec!["c","b","a"])]
140+
fn complete_doesnt_return_duplicates(
141+
#[case] history_items: Vec<&str>,
142+
#[case] line: &str,
143+
#[case] expected: Vec<&str>,
144+
) -> Result<()> {
145+
let mut history = FileBackedHistory::new(history_items.len())?;
146+
for history_item in history_items {
147+
history.save(new_history_item(history_item))?;
148+
}
149+
let mut sut = HistoryCompleter::new(&history);
150+
let actual: Vec<String> = sut
151+
.complete(line, line.len())
152+
.into_iter()
153+
.map(|suggestion| suggestion.value)
154+
.collect();
155+
assert_eq!(actual, expected);
156+
Ok(())
157+
}
158+
}

0 commit comments

Comments
 (0)