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