Skip to main content

fresh/view/settings/
search.rs

1//! Search functionality for settings
2//!
3//! Provides fuzzy search over setting names and descriptions,
4//! with support for highlighting matching categories.
5
6use super::items::{SettingControl, SettingItem, SettingsPage};
7
8/// Describes a match found inside a composite control (Map, TextList)
9#[derive(Debug, Clone)]
10pub enum DeepMatch {
11    /// Matched a map entry key (e.g., "python" in the languages map)
12    MapKey {
13        /// The key that matched
14        key: String,
15        /// Index of the entry in the map
16        entry_index: usize,
17    },
18    /// Matched a value nested inside a map entry (e.g., a field value)
19    MapValue {
20        /// The parent map key
21        key: String,
22        /// Index of the entry in the map
23        entry_index: usize,
24        /// JSON pointer to the matched field within the entry value
25        field_path: String,
26        /// The matched text
27        matched_text: String,
28    },
29    /// Matched a TextList item
30    TextListItem {
31        /// The item text that matched
32        text: String,
33        /// Index of the item in the list
34        item_index: usize,
35    },
36}
37
38/// A search result with match information
39#[derive(Debug, Clone)]
40pub struct SearchResult {
41    /// Index of the page (category) containing this result
42    pub page_index: usize,
43    /// Index of the item within the page
44    pub item_index: usize,
45    /// The setting item
46    pub item: SettingItem,
47    /// Breadcrumb path (e.g., "Editor > Appearance")
48    pub breadcrumb: String,
49    /// Match score (higher = better match)
50    pub score: i32,
51    /// Character indices that matched in the name (for highlighting)
52    pub name_matches: Vec<usize>,
53    /// Character indices that matched in the description (for highlighting)
54    pub description_matches: Vec<usize>,
55    /// If this result matched something inside a composite control
56    pub deep_match: Option<DeepMatch>,
57}
58
59/// Perform fuzzy search over all settings
60pub fn search_settings(pages: &[SettingsPage], query: &str) -> Vec<SearchResult> {
61    if query.is_empty() {
62        return Vec::new();
63    }
64
65    let query_lower = query.to_lowercase();
66    let mut results = Vec::new();
67
68    for (page_index, page) in pages.iter().enumerate() {
69        for (item_index, item) in page.items.iter().enumerate() {
70            // Try to match the name
71            let (name_score, name_matches) = fuzzy_match(&item.name.to_lowercase(), &query_lower);
72
73            // Try to match the description
74            let (desc_score, desc_matches) = item
75                .description
76                .as_ref()
77                .map(|d| fuzzy_match(&d.to_lowercase(), &query_lower))
78                .unwrap_or((0, Vec::new()));
79
80            // Also check path for matches
81            let (path_score, _) = fuzzy_match(&item.path.to_lowercase(), &query_lower);
82
83            // Total score is the best of the three
84            let total_score = name_score.max(desc_score).max(path_score);
85
86            if total_score > 0 {
87                results.push(SearchResult {
88                    page_index,
89                    item_index,
90                    item: item.clone(),
91                    breadcrumb: page.name.clone(),
92                    score: total_score,
93                    name_matches,
94                    description_matches: desc_matches,
95                    deep_match: None,
96                });
97            }
98
99            // Search inside composite controls (Map entries, TextList items)
100            search_composite_control(
101                &mut results,
102                page_index,
103                item_index,
104                item,
105                &page.name,
106                &query_lower,
107            );
108        }
109    }
110
111    // Sort by score (descending), then by name (ascending)
112    results.sort_by(|a, b| {
113        b.score
114            .cmp(&a.score)
115            .then_with(|| a.item.name.cmp(&b.item.name))
116    });
117
118    results
119}
120
121/// Search inside composite controls (Map, TextList) for deep matches
122fn search_composite_control(
123    results: &mut Vec<SearchResult>,
124    page_index: usize,
125    item_index: usize,
126    item: &SettingItem,
127    page_name: &str,
128    query_lower: &str,
129) {
130    match &item.control {
131        SettingControl::Map(map_state) => {
132            for (entry_idx, (key, value)) in map_state.entries.iter().enumerate() {
133                // Match on map key
134                let (key_score, key_matches) = fuzzy_match(&key.to_lowercase(), query_lower);
135                if key_score > 0 {
136                    results.push(SearchResult {
137                        page_index,
138                        item_index,
139                        item: item.clone(),
140                        breadcrumb: format!("{} > {}", page_name, key),
141                        score: key_score,
142                        name_matches: key_matches,
143                        description_matches: Vec::new(),
144                        deep_match: Some(DeepMatch::MapKey {
145                            key: key.clone(),
146                            entry_index: entry_idx,
147                        }),
148                    });
149                    // Skip searching nested values when the key already matches,
150                    // to avoid duplicate results like "grammar: bash" alongside "bash"
151                    continue;
152                }
153
154                // Match on nested string values within the map entry
155                search_json_value(
156                    results,
157                    page_index,
158                    item_index,
159                    item,
160                    page_name,
161                    key,
162                    entry_idx,
163                    value,
164                    "",
165                    query_lower,
166                );
167            }
168        }
169        SettingControl::TextList(list_state) => {
170            for (list_idx, text) in list_state.items.iter().enumerate() {
171                let (score, matches) = fuzzy_match(&text.to_lowercase(), query_lower);
172                if score > 0 {
173                    results.push(SearchResult {
174                        page_index,
175                        item_index,
176                        item: item.clone(),
177                        breadcrumb: format!("{} > {}", page_name, item.name),
178                        score,
179                        name_matches: matches,
180                        description_matches: Vec::new(),
181                        deep_match: Some(DeepMatch::TextListItem {
182                            text: text.clone(),
183                            item_index: list_idx,
184                        }),
185                    });
186                }
187            }
188        }
189        _ => {}
190    }
191}
192
193/// Recursively search JSON values for string matches
194#[allow(clippy::too_many_arguments)]
195fn search_json_value(
196    results: &mut Vec<SearchResult>,
197    page_index: usize,
198    item_index: usize,
199    item: &SettingItem,
200    page_name: &str,
201    map_key: &str,
202    entry_index: usize,
203    value: &serde_json::Value,
204    path: &str,
205    query_lower: &str,
206) {
207    match value {
208        serde_json::Value::String(s) => {
209            let (score, _) = fuzzy_match(&s.to_lowercase(), query_lower);
210            if score > 0 {
211                // Use the field name from path as the display name
212                let field_name = path.rsplit('/').next().unwrap_or(path).to_string();
213                let display_name = if field_name.is_empty() {
214                    s.clone()
215                } else {
216                    format!("{}: {}", field_name, s)
217                };
218                results.push(SearchResult {
219                    page_index,
220                    item_index,
221                    item: item.clone(),
222                    breadcrumb: format!("{} > {}", page_name, map_key),
223                    score,
224                    name_matches: Vec::new(),
225                    description_matches: Vec::new(),
226                    deep_match: Some(DeepMatch::MapValue {
227                        key: map_key.to_string(),
228                        entry_index,
229                        field_path: path.to_string(),
230                        matched_text: display_name,
231                    }),
232                });
233            }
234        }
235        serde_json::Value::Object(obj) => {
236            for (k, v) in obj {
237                let child_path = format!("{}/{}", path, k);
238                search_json_value(
239                    results,
240                    page_index,
241                    item_index,
242                    item,
243                    page_name,
244                    map_key,
245                    entry_index,
246                    v,
247                    &child_path,
248                    query_lower,
249                );
250            }
251        }
252        serde_json::Value::Array(arr) => {
253            for (i, v) in arr.iter().enumerate() {
254                let child_path = format!("{}/{}", path, i);
255                search_json_value(
256                    results,
257                    page_index,
258                    item_index,
259                    item,
260                    page_name,
261                    map_key,
262                    entry_index,
263                    v,
264                    &child_path,
265                    query_lower,
266                );
267            }
268        }
269        _ => {}
270    }
271}
272
273/// Perform fuzzy matching on a string
274/// Returns (score, matched_indices)
275fn fuzzy_match(text: &str, pattern: &str) -> (i32, Vec<usize>) {
276    if pattern.is_empty() {
277        return (0, Vec::new());
278    }
279
280    let text_chars: Vec<char> = text.chars().collect();
281    let pattern_chars: Vec<char> = pattern.chars().collect();
282
283    let mut score = 0;
284    let mut matched_indices = Vec::new();
285    let mut pattern_idx = 0;
286    let mut prev_match_idx: Option<usize> = None;
287
288    for (text_idx, &text_char) in text_chars.iter().enumerate() {
289        if pattern_idx < pattern_chars.len() && text_char == pattern_chars[pattern_idx] {
290            matched_indices.push(text_idx);
291
292            // Score bonuses
293            score += 10; // Base match score
294
295            // Consecutive matches bonus
296            if let Some(prev) = prev_match_idx {
297                if text_idx == prev + 1 {
298                    score += 15; // Consecutive match bonus
299                }
300            }
301
302            // Word boundary bonus (start of word)
303            if text_idx == 0
304                || text_chars.get(text_idx.wrapping_sub(1)) == Some(&' ')
305                || text_chars.get(text_idx.wrapping_sub(1)) == Some(&'_')
306            {
307                score += 20; // Word start bonus
308            }
309
310            // Exact prefix bonus
311            if text_idx == pattern_idx {
312                score += 5; // Matches in same position as pattern
313            }
314
315            prev_match_idx = Some(text_idx);
316            pattern_idx += 1;
317        }
318    }
319
320    // Did we match all pattern characters?
321    if pattern_idx == pattern_chars.len() {
322        // Bonus for shorter matches (more specific)
323        let length_bonus = (100 - text_chars.len().min(100) as i32) / 10;
324        score += length_bonus;
325
326        // Exact match bonus
327        if text == pattern {
328            score += 100;
329        }
330
331        (score, matched_indices)
332    } else {
333        // Didn't match all characters
334        (0, Vec::new())
335    }
336}
337
338/// Check if a query matches a setting (simple substring match)
339pub fn matches_query(item: &SettingItem, query: &str) -> bool {
340    let query_lower = query.to_lowercase();
341
342    item.name.to_lowercase().contains(&query_lower)
343        || item
344            .description
345            .as_ref()
346            .map(|d| d.to_lowercase().contains(&query_lower))
347            .unwrap_or(false)
348        || item.path.to_lowercase().contains(&query_lower)
349}
350
351/// Get indices of categories that have matching items
352pub fn matching_categories(pages: &[SettingsPage], query: &str) -> Vec<usize> {
353    if query.is_empty() {
354        return Vec::new();
355    }
356
357    pages
358        .iter()
359        .enumerate()
360        .filter(|(_, page)| page.items.iter().any(|item| matches_query(item, query)))
361        .map(|(idx, _)| idx)
362        .collect()
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::view::controls::ToggleState;
369    use crate::view::settings::items::SettingControl;
370
371    fn make_item(name: &str, description: Option<&str>, path: &str) -> SettingItem {
372        SettingItem {
373            path: path.to_string(),
374            name: name.to_string(),
375            description: description.map(String::from),
376            control: SettingControl::Toggle(ToggleState::new(false, name)),
377            default: None,
378            modified: false,
379            layer_source: crate::config_io::ConfigLayer::System,
380            read_only: false,
381            is_auto_managed: false,
382            nullable: false,
383            is_null: false,
384            section: None,
385            is_section_start: false,
386            layout_width: 0,
387            dual_list_sibling: None,
388        }
389    }
390
391    fn make_page(name: &str, items: Vec<SettingItem>) -> SettingsPage {
392        SettingsPage {
393            name: name.to_string(),
394            path: format!("/{}", name.to_lowercase()),
395            description: None,
396            nullable: false,
397            items,
398            subpages: Vec::new(),
399        }
400    }
401
402    #[test]
403    fn test_fuzzy_match_exact() {
404        let (score, indices) = fuzzy_match("line_numbers", "line");
405        assert!(score > 0);
406        assert_eq!(indices, vec![0, 1, 2, 3]);
407    }
408
409    #[test]
410    fn test_fuzzy_match_prefix() {
411        let (score, indices) = fuzzy_match("tab_size", "tab");
412        assert!(score > 0);
413        assert_eq!(indices, vec![0, 1, 2]);
414    }
415
416    #[test]
417    fn test_fuzzy_match_scattered() {
418        let (score, indices) = fuzzy_match("line_numbers", "lnm");
419        assert!(score > 0);
420        // 'l' at 0, 'n' at 2 (first n in "line"), 'm' at 7 (in "numbers")
421        assert_eq!(indices, vec![0, 2, 7]);
422    }
423
424    #[test]
425    fn test_fuzzy_match_no_match() {
426        let (score, indices) = fuzzy_match("hello", "xyz");
427        assert_eq!(score, 0);
428        assert!(indices.is_empty());
429    }
430
431    #[test]
432    fn test_search_settings_empty_query() {
433        let pages = vec![make_page(
434            "Editor",
435            vec![make_item(
436                "Line Numbers",
437                Some("Show line numbers"),
438                "/line_numbers",
439            )],
440        )];
441
442        let results = search_settings(&pages, "");
443        assert!(results.is_empty());
444    }
445
446    #[test]
447    fn test_search_settings_name_match() {
448        let pages = vec![make_page(
449            "Editor",
450            vec![
451                make_item("Line Numbers", Some("Show line numbers"), "/line_numbers"),
452                make_item("Tab Size", Some("Spaces per tab"), "/tab_size"),
453            ],
454        )];
455
456        let results = search_settings(&pages, "line");
457        assert_eq!(results.len(), 1);
458        assert_eq!(results[0].item.name, "Line Numbers");
459        assert_eq!(results[0].breadcrumb, "Editor");
460    }
461
462    #[test]
463    fn test_search_settings_description_match() {
464        let pages = vec![make_page(
465            "Editor",
466            vec![make_item(
467                "Tab Size",
468                Some("Number of spaces per tab character"),
469                "/tab_size",
470            )],
471        )];
472
473        let results = search_settings(&pages, "spaces");
474        assert_eq!(results.len(), 1);
475        assert_eq!(results[0].item.name, "Tab Size");
476    }
477
478    #[test]
479    fn test_search_settings_path_match() {
480        let pages = vec![make_page(
481            "Editor",
482            vec![make_item("Tab Size", None, "/editor/tab_size")],
483        )];
484
485        let results = search_settings(&pages, "editor");
486        assert_eq!(results.len(), 1);
487    }
488
489    #[test]
490    fn test_matching_categories() {
491        let pages = vec![
492            make_page(
493                "Editor",
494                vec![make_item("Line Numbers", None, "/line_numbers")],
495            ),
496            make_page("Theme", vec![make_item("Theme Name", None, "/theme")]),
497        ];
498
499        let matches = matching_categories(&pages, "line");
500        assert_eq!(matches, vec![0]);
501
502        let matches = matching_categories(&pages, "theme");
503        assert_eq!(matches, vec![1]);
504    }
505
506    #[test]
507    fn test_search_ranking() {
508        let pages = vec![make_page(
509            "Editor",
510            vec![
511                make_item("Tab", None, "/tab"),                 // Exact match
512                make_item("Tab Size", None, "/tab_size"),       // Prefix match
513                make_item("Default Tab", None, "/default_tab"), // Contains match
514            ],
515        )];
516
517        let results = search_settings(&pages, "tab");
518        assert_eq!(results.len(), 3);
519        // Exact match should be first
520        assert_eq!(results[0].item.name, "Tab");
521        // Then prefix match
522        assert_eq!(results[1].item.name, "Tab Size");
523        // Then contains match (scored lower due to position)
524        assert_eq!(results[2].item.name, "Default Tab");
525    }
526
527    fn make_map_item(
528        name: &str,
529        path: &str,
530        entries: Vec<(String, serde_json::Value)>,
531    ) -> SettingItem {
532        use crate::view::controls::MapState;
533        let mut map_state = MapState::new(name);
534        map_state.entries = entries;
535        SettingItem {
536            path: path.to_string(),
537            name: name.to_string(),
538            description: None,
539            control: SettingControl::Map(map_state),
540            default: None,
541            modified: false,
542            layer_source: crate::config_io::ConfigLayer::System,
543            read_only: false,
544            is_auto_managed: false,
545            nullable: false,
546            is_null: false,
547            section: None,
548            is_section_start: false,
549            layout_width: 0,
550            dual_list_sibling: None,
551        }
552    }
553
554    fn make_text_list_item(name: &str, path: &str, items: Vec<String>) -> SettingItem {
555        use crate::view::controls::TextListState;
556        let mut list_state = TextListState::new(name);
557        list_state.items = items;
558        SettingItem {
559            path: path.to_string(),
560            name: name.to_string(),
561            description: None,
562            control: SettingControl::TextList(list_state),
563            default: None,
564            modified: false,
565            layer_source: crate::config_io::ConfigLayer::System,
566            read_only: false,
567            is_auto_managed: false,
568            nullable: false,
569            is_null: false,
570            section: None,
571            is_section_start: false,
572            layout_width: 0,
573            dual_list_sibling: None,
574        }
575    }
576
577    #[test]
578    fn test_search_map_key() {
579        let pages = vec![make_page(
580            "Languages",
581            vec![make_map_item(
582                "Languages",
583                "/languages",
584                vec![
585                    ("python".to_string(), serde_json::json!({})),
586                    ("rust".to_string(), serde_json::json!({})),
587                ],
588            )],
589        )];
590
591        let results = search_settings(&pages, "python");
592        // Should find a deep match on the map key "python"
593        let deep_results: Vec<_> = results.iter().filter(|r| r.deep_match.is_some()).collect();
594        assert!(!deep_results.is_empty(), "Should find map key 'python'");
595        assert!(
596            matches!(&deep_results[0].deep_match, Some(DeepMatch::MapKey { key, .. }) if key == "python")
597        );
598        assert_eq!(deep_results[0].breadcrumb, "Languages > python");
599    }
600
601    #[test]
602    fn test_search_map_key_no_duplicate_nested_values() {
603        // When a map key matches, nested values inside that entry should NOT
604        // produce additional results (avoids duplicates like "grammar: bash"
605        // alongside the "bash" key match).
606        let pages = vec![make_page(
607            "General",
608            vec![make_map_item(
609                "Languages",
610                "/languages",
611                vec![(
612                    "bash".to_string(),
613                    serde_json::json!({"grammar": "bash", "files": ["1.bash"]}),
614                )],
615            )],
616        )];
617
618        let results = search_settings(&pages, "bash");
619        let deep_results: Vec<_> = results.iter().filter(|r| r.deep_match.is_some()).collect();
620        // Should only have the MapKey match, not MapValue matches for "grammar: bash" etc.
621        assert_eq!(
622            deep_results.len(),
623            1,
624            "Expected exactly 1 deep match (MapKey), got {}: {:?}",
625            deep_results.len(),
626            deep_results
627                .iter()
628                .map(|r| &r.deep_match)
629                .collect::<Vec<_>>()
630        );
631        assert!(
632            matches!(&deep_results[0].deep_match, Some(DeepMatch::MapKey { key, .. }) if key == "bash")
633        );
634    }
635
636    #[test]
637    fn test_search_map_nested_value() {
638        let pages = vec![make_page(
639            "LSP",
640            vec![make_map_item(
641                "LSP",
642                "/lsp",
643                vec![(
644                    "rust".to_string(),
645                    serde_json::json!({"command": "rust-analyzer", "args": ["--stdio"]}),
646                )],
647            )],
648        )];
649
650        let results = search_settings(&pages, "rust-analyzer");
651        let deep_results: Vec<_> = results
652            .iter()
653            .filter(|r| matches!(&r.deep_match, Some(DeepMatch::MapValue { .. })))
654            .collect();
655        assert!(
656            !deep_results.is_empty(),
657            "Should find nested value 'rust-analyzer'"
658        );
659    }
660
661    #[test]
662    fn test_search_text_list_item() {
663        let pages = vec![make_page(
664            "Editor",
665            vec![make_text_list_item(
666                "File Extensions",
667                "/file_extensions",
668                vec!["py".to_string(), "rs".to_string(), "js".to_string()],
669            )],
670        )];
671
672        let results = search_settings(&pages, "py");
673        let deep_results: Vec<_> = results
674            .iter()
675            .filter(|r| matches!(&r.deep_match, Some(DeepMatch::TextListItem { .. })))
676            .collect();
677        assert!(!deep_results.is_empty(), "Should find text list item 'py'");
678    }
679
680    #[test]
681    fn test_deep_match_ranks_higher_than_fuzzy_noise() {
682        let pages = vec![
683            make_page(
684                "Editor",
685                vec![
686                    // "python" fuzzy-matches scattered chars in long names
687                    make_item(
688                        "Leading Spaces",
689                        Some("Show space indicators for leading whitespace"),
690                        "/editor/whitespace_spaces_leading",
691                    ),
692                ],
693            ),
694            make_page(
695                "Languages",
696                vec![make_map_item(
697                    "Languages",
698                    "/languages",
699                    vec![("python".to_string(), serde_json::json!({}))],
700                )],
701            ),
702        ];
703
704        let results = search_settings(&pages, "python");
705        assert!(!results.is_empty());
706        // The map key exact match on "python" should rank first
707        assert!(
708            matches!(&results[0].deep_match, Some(DeepMatch::MapKey { key, .. }) if key == "python"),
709            "Map key 'python' should rank above fuzzy noise, got: {:?}",
710            results[0].deep_match
711        );
712    }
713}