Skip to main content

sbom_tools/tui/viewmodel/
search.rs

1//! Generic search state for TUI views.
2//!
3//! Provides a reusable search state that can work with any result type,
4//! eliminating duplication between `DiffSearchState` and `SearchState`.
5
6use crate::tui::state::ListNavigation;
7
8/// Generic search state that works with any result type.
9///
10/// This replaces the duplicate `DiffSearchState` and `SearchState` structs
11/// with a single, type-parameterized implementation.
12///
13/// # Type Parameter
14///
15/// - `R`: The result type for search matches (e.g., `DiffSearchResult`, `SearchResult`)
16///
17/// # Example
18///
19/// ```ignore
20/// use crate::tui::viewmodel::SearchState;
21///
22/// // For diff mode
23/// let mut search: SearchState<DiffSearchResult> = SearchState::new();
24///
25/// // For view mode
26/// let mut search: SearchState<ViewSearchResult> = SearchState::new();
27/// ```
28#[derive(Debug, Clone)]
29pub struct SearchState<R> {
30    /// Whether search mode is active
31    pub active: bool,
32    /// Current search query
33    pub query: String,
34    /// Search results
35    pub results: Vec<R>,
36    /// Selected result index
37    pub selected: usize,
38}
39
40impl<R> Default for SearchState<R> {
41    fn default() -> Self {
42        Self::new()
43    }
44}
45
46impl<R> SearchState<R> {
47    /// Create a new empty search state.
48    pub fn new() -> Self {
49        Self {
50            active: false,
51            query: String::new(),
52            results: Vec::new(),
53            selected: 0,
54        }
55    }
56
57    /// Start search mode.
58    pub fn start(&mut self) {
59        self.active = true;
60        self.query.clear();
61        self.results.clear();
62        self.selected = 0;
63    }
64
65    /// Stop search mode.
66    pub fn stop(&mut self) {
67        self.active = false;
68    }
69
70    /// Clear query and results.
71    pub fn clear(&mut self) {
72        self.query.clear();
73        self.results.clear();
74        self.selected = 0;
75    }
76
77    /// Add a character to the query.
78    pub fn push_char(&mut self, c: char) {
79        self.query.push(c);
80    }
81
82    /// Remove the last character from the query.
83    pub fn pop_char(&mut self) {
84        self.query.pop();
85    }
86
87    /// Check if the query is long enough to search.
88    pub fn has_valid_query(&self) -> bool {
89        self.query.len() >= 2
90    }
91
92    /// Get the lowercased query for case-insensitive matching.
93    pub fn query_lower(&self) -> String {
94        self.query.to_lowercase()
95    }
96
97    /// Set results and reset selection.
98    pub fn set_results(&mut self, results: Vec<R>) {
99        self.results = results;
100        self.selected = 0;
101    }
102
103    /// Get the currently selected result.
104    pub fn selected_result(&self) -> Option<&R> {
105        self.results.get(self.selected)
106    }
107
108    /// Select the next result.
109    pub fn select_next(&mut self) {
110        if !self.results.is_empty() && self.selected < self.results.len() - 1 {
111            self.selected += 1;
112        }
113    }
114
115    /// Select the previous result.
116    pub fn select_prev(&mut self) {
117        if self.selected > 0 {
118            self.selected -= 1;
119        }
120    }
121
122    /// Check if there are any results.
123    pub fn has_results(&self) -> bool {
124        !self.results.is_empty()
125    }
126
127    /// Get the result count.
128    pub fn result_count(&self) -> usize {
129        self.results.len()
130    }
131}
132
133impl<R> ListNavigation for SearchState<R> {
134    fn selected(&self) -> usize {
135        self.selected
136    }
137
138    fn set_selected(&mut self, idx: usize) {
139        self.selected = idx;
140    }
141
142    fn total(&self) -> usize {
143        self.results.len()
144    }
145
146    fn set_total(&mut self, _total: usize) {
147        // Results are managed via set_results, not directly
148    }
149}
150
151/// Non-generic core of search state for when result type isn't needed.
152///
153/// Useful for extracting just the active/query state without results.
154#[derive(Debug, Clone, Default)]
155pub struct SearchStateCore {
156    /// Whether search mode is active
157    pub active: bool,
158    /// Current search query
159    pub query: String,
160}
161
162impl SearchStateCore {
163    pub fn new() -> Self {
164        Self::default()
165    }
166
167    pub fn start(&mut self) {
168        self.active = true;
169        self.query.clear();
170    }
171
172    pub fn stop(&mut self) {
173        self.active = false;
174    }
175
176    pub fn push_char(&mut self, c: char) {
177        self.query.push(c);
178    }
179
180    pub fn pop_char(&mut self) {
181        self.query.pop();
182    }
183
184    pub fn has_valid_query(&self) -> bool {
185        self.query.len() >= 2
186    }
187
188    pub fn query_lower(&self) -> String {
189        self.query.to_lowercase()
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn test_search_state_lifecycle() {
199        let mut state: SearchState<String> = SearchState::new();
200
201        assert!(!state.active);
202        assert!(state.query.is_empty());
203
204        state.start();
205        assert!(state.active);
206        assert!(state.query.is_empty());
207
208        state.push_char('t');
209        state.push_char('e');
210        state.push_char('s');
211        state.push_char('t');
212        assert_eq!(state.query, "test");
213        assert!(state.has_valid_query());
214
215        state.pop_char();
216        assert_eq!(state.query, "tes");
217
218        state.stop();
219        assert!(!state.active);
220    }
221
222    #[test]
223    fn test_search_state_results() {
224        let mut state: SearchState<String> = SearchState::new();
225
226        state.set_results(vec![
227            "result1".to_string(),
228            "result2".to_string(),
229            "result3".to_string(),
230        ]);
231
232        assert!(state.has_results());
233        assert_eq!(state.result_count(), 3);
234        assert_eq!(state.selected, 0);
235        assert_eq!(state.selected_result(), Some(&"result1".to_string()));
236
237        state.select_next();
238        assert_eq!(state.selected, 1);
239        assert_eq!(state.selected_result(), Some(&"result2".to_string()));
240
241        state.select_next();
242        assert_eq!(state.selected, 2);
243
244        // Can't go past end
245        state.select_next();
246        assert_eq!(state.selected, 2);
247
248        state.select_prev();
249        assert_eq!(state.selected, 1);
250
251        state.select_prev();
252        assert_eq!(state.selected, 0);
253
254        // Can't go below 0
255        state.select_prev();
256        assert_eq!(state.selected, 0);
257    }
258
259    #[test]
260    fn test_search_state_clear() {
261        let mut state: SearchState<String> = SearchState::new();
262        state.query = "test".to_string();
263        state.set_results(vec!["a".to_string(), "b".to_string()]);
264        state.selected = 1;
265
266        state.clear();
267
268        assert!(state.query.is_empty());
269        assert!(state.results.is_empty());
270        assert_eq!(state.selected, 0);
271    }
272
273    #[test]
274    fn test_search_state_list_navigation() {
275        let mut state: SearchState<i32> = SearchState::new();
276        state.set_results(vec![1, 2, 3, 4, 5]);
277
278        // Test ListNavigation trait
279        assert_eq!(state.selected(), 0);
280        assert_eq!(state.total(), 5);
281
282        state.set_selected(2);
283        assert_eq!(state.selected(), 2);
284
285        // Page navigation
286        state.page_down();
287        assert_eq!(state.selected(), 4); // Clamped to max
288
289        state.go_first();
290        assert_eq!(state.selected(), 0);
291
292        state.go_last();
293        assert_eq!(state.selected(), 4);
294    }
295
296    #[test]
297    fn test_search_state_core() {
298        let mut core = SearchStateCore::new();
299
300        assert!(!core.active);
301        assert!(core.query.is_empty());
302
303        core.start();
304        assert!(core.active);
305
306        core.push_char('A');
307        core.push_char('B');
308        assert_eq!(core.query, "AB");
309        assert!(core.has_valid_query());
310        assert_eq!(core.query_lower(), "ab");
311
312        core.stop();
313        assert!(!core.active);
314    }
315}