sql_cli/core/
search_manager.rs

1//! `SearchManager` - Encapsulates all search logic for the TUI
2//!
3//! This module provides a clean separation between search logic and UI rendering.
4//! It handles case sensitivity, coordinate mapping, and iteration through matches.
5
6use tracing::warn;
7
8/// Represents a single search match
9#[derive(Debug, Clone, PartialEq)]
10pub struct SearchMatch {
11    /// Row index in the data (0-based)
12    pub row: usize,
13    /// Column index in the data (0-based)
14    pub column: usize,
15    /// The actual value that matched
16    pub value: String,
17    /// The highlighted portion of the match
18    pub highlight_range: (usize, usize),
19}
20
21/// Configuration for search behavior
22#[derive(Debug, Clone)]
23pub struct SearchConfig {
24    /// Whether search is case sensitive
25    pub case_sensitive: bool,
26    /// Whether to use regex matching
27    pub use_regex: bool,
28    /// Whether to search only visible columns
29    pub visible_columns_only: bool,
30    /// Whether to wrap around when reaching the end
31    pub wrap_around: bool,
32}
33
34impl Default for SearchConfig {
35    fn default() -> Self {
36        Self {
37            case_sensitive: false,
38            use_regex: false,
39            visible_columns_only: false,
40            wrap_around: true,
41        }
42    }
43}
44
45/// Manages search state and provides iteration through matches
46pub struct SearchManager {
47    /// Current search pattern
48    pattern: String,
49    /// All matches found
50    matches: Vec<SearchMatch>,
51    /// Current match index
52    current_index: usize,
53    /// Search configuration
54    config: SearchConfig,
55    /// Cached regex (if using regex mode)
56    regex: Option<regex::Regex>,
57}
58
59impl Default for SearchManager {
60    fn default() -> Self {
61        Self::new()
62    }
63}
64
65impl SearchManager {
66    /// Create a new `SearchManager` with default config
67    #[must_use]
68    pub fn new() -> Self {
69        Self {
70            pattern: String::new(),
71            matches: Vec::new(),
72            current_index: 0,
73            config: SearchConfig::default(),
74            regex: None,
75        }
76    }
77
78    /// Create with custom configuration
79    #[must_use]
80    pub fn with_config(config: SearchConfig) -> Self {
81        Self {
82            pattern: String::new(),
83            matches: Vec::new(),
84            current_index: 0,
85            config,
86            regex: None,
87        }
88    }
89
90    /// Update search configuration
91    pub fn set_config(&mut self, config: SearchConfig) {
92        // Clear regex cache if switching modes
93        if !config.use_regex {
94            self.regex = None;
95        }
96        self.config = config;
97    }
98
99    /// Set case sensitivity
100    pub fn set_case_sensitive(&mut self, case_sensitive: bool) {
101        self.config.case_sensitive = case_sensitive;
102        // Re-compile regex if needed
103        if self.config.use_regex && !self.pattern.is_empty() {
104            self.compile_regex();
105        }
106    }
107
108    /// Perform a search on the given data
109    /// Returns the number of matches found
110    pub fn search(
111        &mut self,
112        pattern: &str,
113        data: &[Vec<String>],
114        visible_columns: Option<&[usize]>,
115    ) -> usize {
116        self.pattern = pattern.to_string();
117        self.matches.clear();
118        self.current_index = 0;
119
120        if pattern.is_empty() {
121            return 0;
122        }
123
124        // Compile regex if needed
125        if self.config.use_regex {
126            self.compile_regex();
127            if self.regex.is_none() {
128                return 0; // Invalid regex
129            }
130        }
131
132        // Determine which columns to search
133        let columns_to_search: Vec<usize> = if self.config.visible_columns_only {
134            visible_columns.map(<[usize]>::to_vec).unwrap_or_else(|| {
135                // If no visible columns specified, search all
136                if data.is_empty() {
137                    vec![]
138                } else {
139                    (0..data[0].len()).collect()
140                }
141            })
142        } else {
143            // Search all columns
144            if data.is_empty() {
145                vec![]
146            } else {
147                (0..data[0].len()).collect()
148            }
149        };
150
151        // Search through data
152        for (row_idx, row) in data.iter().enumerate() {
153            for &col_idx in &columns_to_search {
154                if col_idx >= row.len() {
155                    continue;
156                }
157
158                let cell_value = &row[col_idx];
159                if let Some(range) = self.matches_pattern(cell_value, pattern) {
160                    self.matches.push(SearchMatch {
161                        row: row_idx,
162                        column: col_idx,
163                        value: cell_value.clone(),
164                        highlight_range: range,
165                    });
166                }
167            }
168        }
169
170        self.matches.len()
171    }
172
173    /// Check if a value matches the pattern and return highlight range
174    fn matches_pattern(&self, value: &str, pattern: &str) -> Option<(usize, usize)> {
175        if self.config.use_regex {
176            // Use regex matching
177            if let Some(ref regex) = self.regex {
178                if let Some(m) = regex.find(value) {
179                    return Some((m.start(), m.end()));
180                }
181            }
182        } else {
183            // Use substring matching
184            let search_value = if self.config.case_sensitive {
185                value.to_string()
186            } else {
187                value.to_lowercase()
188            };
189
190            let search_pattern = if self.config.case_sensitive {
191                pattern.to_string()
192            } else {
193                pattern.to_lowercase()
194            };
195
196            if let Some(pos) = search_value.find(&search_pattern) {
197                return Some((pos, pos + pattern.len()));
198            }
199        }
200        None
201    }
202
203    /// Compile regex pattern
204    fn compile_regex(&mut self) {
205        let pattern = if self.config.case_sensitive {
206            self.pattern.clone()
207        } else {
208            format!("(?i){}", self.pattern)
209        };
210
211        match regex::Regex::new(&pattern) {
212            Ok(regex) => self.regex = Some(regex),
213            Err(e) => {
214                warn!("Invalid regex pattern: {}", e);
215                self.regex = None;
216            }
217        }
218    }
219
220    /// Get the current match (if any)
221    #[must_use]
222    pub fn current_match(&self) -> Option<&SearchMatch> {
223        if self.matches.is_empty() {
224            None
225        } else {
226            self.matches.get(self.current_index)
227        }
228    }
229
230    /// Move to the next match
231    pub fn next_match(&mut self) -> Option<&SearchMatch> {
232        if self.matches.is_empty() {
233            return None;
234        }
235
236        if self.current_index + 1 < self.matches.len() {
237            self.current_index += 1;
238        } else if self.config.wrap_around {
239            self.current_index = 0;
240        }
241
242        self.current_match()
243    }
244
245    /// Move to the previous match
246    pub fn previous_match(&mut self) -> Option<&SearchMatch> {
247        if self.matches.is_empty() {
248            return None;
249        }
250
251        if self.current_index > 0 {
252            self.current_index -= 1;
253        } else if self.config.wrap_around {
254            self.current_index = self.matches.len() - 1;
255        }
256
257        self.current_match()
258    }
259
260    /// Jump to a specific match index
261    pub fn jump_to_match(&mut self, index: usize) -> Option<&SearchMatch> {
262        if index < self.matches.len() {
263            self.current_index = index;
264            self.current_match()
265        } else {
266            None
267        }
268    }
269
270    /// Get the first match (useful for initial navigation)
271    #[must_use]
272    pub fn first_match(&self) -> Option<&SearchMatch> {
273        self.matches.first()
274    }
275
276    /// Get all matches
277    #[must_use]
278    pub fn all_matches(&self) -> &[SearchMatch] {
279        &self.matches
280    }
281
282    /// Get the total number of matches
283    #[must_use]
284    pub fn match_count(&self) -> usize {
285        self.matches.len()
286    }
287
288    /// Get current match index (1-based for display)
289    #[must_use]
290    pub fn current_match_number(&self) -> usize {
291        if self.matches.is_empty() {
292            0
293        } else {
294            self.current_index + 1
295        }
296    }
297
298    /// Clear all search state
299    pub fn clear(&mut self) {
300        self.pattern.clear();
301        self.matches.clear();
302        self.current_index = 0;
303        self.regex = None;
304    }
305
306    /// Check if there's an active search
307    #[must_use]
308    pub fn has_active_search(&self) -> bool {
309        !self.pattern.is_empty()
310    }
311
312    /// Get the current search pattern
313    #[must_use]
314    pub fn pattern(&self) -> &str {
315        &self.pattern
316    }
317
318    /// Calculate scroll offset needed to show a match in viewport
319    #[must_use]
320    pub fn calculate_scroll_offset(
321        &self,
322        match_pos: &SearchMatch,
323        viewport_height: usize,
324        current_offset: usize,
325    ) -> usize {
326        let row = match_pos.row;
327
328        // If match is above current view, scroll up to it
329        if row < current_offset {
330            row
331        }
332        // If match is below current view, center it
333        else if row >= current_offset + viewport_height {
334            row.saturating_sub(viewport_height / 2)
335        }
336        // Match is already visible
337        else {
338            current_offset
339        }
340    }
341
342    /// Find the next match from a given position
343    #[must_use]
344    pub fn find_next_from(&self, current_row: usize, current_col: usize) -> Option<&SearchMatch> {
345        // Find matches after current position
346        for match_item in &self.matches {
347            if match_item.row > current_row
348                || (match_item.row == current_row && match_item.column > current_col)
349            {
350                return Some(match_item);
351            }
352        }
353
354        // Wrap around if enabled
355        if self.config.wrap_around && !self.matches.is_empty() {
356            return self.matches.first();
357        }
358
359        None
360    }
361
362    /// Find the previous match from a given position
363    #[must_use]
364    pub fn find_previous_from(
365        &self,
366        current_row: usize,
367        current_col: usize,
368    ) -> Option<&SearchMatch> {
369        // Find matches before current position (in reverse)
370        for match_item in self.matches.iter().rev() {
371            if match_item.row < current_row
372                || (match_item.row == current_row && match_item.column < current_col)
373            {
374                return Some(match_item);
375            }
376        }
377
378        // Wrap around if enabled
379        if self.config.wrap_around && !self.matches.is_empty() {
380            return self.matches.last();
381        }
382
383        None
384    }
385}
386
387/// Iterator for search matches
388pub struct SearchIterator<'a> {
389    manager: &'a SearchManager,
390    index: usize,
391}
392
393impl<'a> Iterator for SearchIterator<'a> {
394    type Item = &'a SearchMatch;
395
396    fn next(&mut self) -> Option<Self::Item> {
397        if self.index < self.manager.matches.len() {
398            let result = &self.manager.matches[self.index];
399            self.index += 1;
400            Some(result)
401        } else {
402            None
403        }
404    }
405}
406
407impl SearchManager {
408    /// Get an iterator over all matches
409    #[must_use]
410    pub fn iter(&self) -> SearchIterator {
411        SearchIterator {
412            manager: self,
413            index: 0,
414        }
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn test_case_insensitive_search() {
424        let mut manager = SearchManager::new();
425        manager.set_case_sensitive(false);
426
427        let data = vec![
428            vec!["Unconfirmed".to_string(), "data1".to_string()],
429            vec!["unconfirmed".to_string(), "data2".to_string()],
430            vec!["UNCONFIRMED".to_string(), "data3".to_string()],
431            vec!["confirmed".to_string(), "data4".to_string()],
432        ];
433
434        let count = manager.search("unconfirmed", &data, None);
435        assert_eq!(count, 3);
436
437        // All case variations should match
438        let matches: Vec<_> = manager.iter().collect();
439        assert_eq!(matches.len(), 3);
440        assert_eq!(matches[0].row, 0);
441        assert_eq!(matches[1].row, 1);
442        assert_eq!(matches[2].row, 2);
443    }
444
445    #[test]
446    fn test_case_sensitive_search() {
447        let mut manager = SearchManager::new();
448        manager.set_case_sensitive(true);
449
450        let data = vec![
451            vec!["Unconfirmed".to_string(), "data1".to_string()],
452            vec!["unconfirmed".to_string(), "data2".to_string()],
453            vec!["UNCONFIRMED".to_string(), "data3".to_string()],
454        ];
455
456        let count = manager.search("Unconfirmed", &data, None);
457        assert_eq!(count, 1);
458
459        let first_match = manager.first_match().unwrap();
460        assert_eq!(first_match.row, 0);
461        assert_eq!(first_match.value, "Unconfirmed");
462    }
463
464    #[test]
465    fn test_navigation() {
466        let mut manager = SearchManager::new();
467
468        let data = vec![
469            vec!["apple".to_string(), "banana".to_string()],
470            vec!["apple pie".to_string(), "cherry".to_string()],
471            vec!["orange".to_string(), "apple juice".to_string()],
472        ];
473
474        manager.search("apple", &data, None);
475        assert_eq!(manager.match_count(), 3);
476
477        // Test next navigation
478        let first = manager.current_match().unwrap();
479        assert_eq!((first.row, first.column), (0, 0));
480
481        let second = manager.next_match().unwrap();
482        assert_eq!((second.row, second.column), (1, 0));
483
484        let third = manager.next_match().unwrap();
485        assert_eq!((third.row, third.column), (2, 1));
486
487        // Test wrap around
488        let wrapped = manager.next_match().unwrap();
489        assert_eq!((wrapped.row, wrapped.column), (0, 0));
490
491        // Test previous navigation
492        let prev = manager.previous_match().unwrap();
493        assert_eq!((prev.row, prev.column), (2, 1));
494    }
495
496    #[test]
497    fn test_visible_columns_filter() {
498        let mut config = SearchConfig::default();
499        config.visible_columns_only = true;
500        let mut manager = SearchManager::with_config(config);
501
502        let data = vec![
503            vec![
504                "apple".to_string(),
505                "hidden".to_string(),
506                "banana".to_string(),
507            ],
508            vec![
509                "orange".to_string(),
510                "apple".to_string(),
511                "cherry".to_string(),
512            ],
513        ];
514
515        // Search only in columns 0 and 2 (column 1 is hidden)
516        let visible = vec![0, 2];
517        let count = manager.search("apple", &data, Some(&visible));
518
519        // Should only find apple in column 0 of row 0, not in column 1 of row 1
520        assert_eq!(count, 1);
521        let match_item = manager.first_match().unwrap();
522        assert_eq!(match_item.row, 0);
523        assert_eq!(match_item.column, 0);
524    }
525
526    #[test]
527    fn test_scroll_offset_calculation() {
528        let manager = SearchManager::new();
529
530        let match_item = SearchMatch {
531            row: 50,
532            column: 0,
533            value: String::new(),
534            highlight_range: (0, 0),
535        };
536
537        // Match below viewport - should center
538        let offset = manager.calculate_scroll_offset(&match_item, 20, 10);
539        assert_eq!(offset, 40); // 50 - 20/2
540
541        // Match above viewport - should scroll to it
542        let offset = manager.calculate_scroll_offset(&match_item, 20, 60);
543        assert_eq!(offset, 50);
544
545        // Match already visible - keep current offset
546        let offset = manager.calculate_scroll_offset(&match_item, 20, 45);
547        assert_eq!(offset, 45);
548    }
549
550    #[test]
551    fn test_find_from_position() {
552        let mut manager = SearchManager::new();
553
554        let data = vec![
555            vec!["a".to_string(), "b".to_string(), "match".to_string()],
556            vec!["match".to_string(), "c".to_string(), "d".to_string()],
557            vec!["e".to_string(), "match".to_string(), "f".to_string()],
558        ];
559
560        manager.search("match", &data, None);
561
562        // Find next from position (0, 1)
563        let next = manager.find_next_from(0, 1).unwrap();
564        assert_eq!((next.row, next.column), (0, 2));
565
566        // Find next from position (1, 0)
567        let next = manager.find_next_from(1, 0).unwrap();
568        assert_eq!((next.row, next.column), (2, 1));
569
570        // Find previous from position (2, 0)
571        let prev = manager.find_previous_from(2, 0).unwrap();
572        assert_eq!((prev.row, prev.column), (1, 0));
573    }
574}