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