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::{ItemBoxStyle, 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            style: ItemBoxStyle::default(),
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            sections: Vec::new(),
400        }
401    }
402
403    #[test]
404    fn test_fuzzy_match_exact() {
405        let (score, indices) = fuzzy_match("line_numbers", "line");
406        assert!(score > 0);
407        assert_eq!(indices, vec![0, 1, 2, 3]);
408    }
409
410    #[test]
411    fn test_fuzzy_match_prefix() {
412        let (score, indices) = fuzzy_match("tab_size", "tab");
413        assert!(score > 0);
414        assert_eq!(indices, vec![0, 1, 2]);
415    }
416
417    #[test]
418    fn test_fuzzy_match_scattered() {
419        let (score, indices) = fuzzy_match("line_numbers", "lnm");
420        assert!(score > 0);
421        // 'l' at 0, 'n' at 2 (first n in "line"), 'm' at 7 (in "numbers")
422        assert_eq!(indices, vec![0, 2, 7]);
423    }
424
425    #[test]
426    fn test_fuzzy_match_no_match() {
427        let (score, indices) = fuzzy_match("hello", "xyz");
428        assert_eq!(score, 0);
429        assert!(indices.is_empty());
430    }
431
432    #[test]
433    fn test_search_settings_empty_query() {
434        let pages = vec![make_page(
435            "Editor",
436            vec![make_item(
437                "Line Numbers",
438                Some("Show line numbers"),
439                "/line_numbers",
440            )],
441        )];
442
443        let results = search_settings(&pages, "");
444        assert!(results.is_empty());
445    }
446
447    #[test]
448    fn test_search_settings_name_match() {
449        let pages = vec![make_page(
450            "Editor",
451            vec![
452                make_item("Line Numbers", Some("Show line numbers"), "/line_numbers"),
453                make_item("Tab Size", Some("Spaces per tab"), "/tab_size"),
454            ],
455        )];
456
457        let results = search_settings(&pages, "line");
458        assert_eq!(results.len(), 1);
459        assert_eq!(results[0].item.name, "Line Numbers");
460        assert_eq!(results[0].breadcrumb, "Editor");
461    }
462
463    #[test]
464    fn test_search_settings_description_match() {
465        let pages = vec![make_page(
466            "Editor",
467            vec![make_item(
468                "Tab Size",
469                Some("Number of spaces per tab character"),
470                "/tab_size",
471            )],
472        )];
473
474        let results = search_settings(&pages, "spaces");
475        assert_eq!(results.len(), 1);
476        assert_eq!(results[0].item.name, "Tab Size");
477    }
478
479    #[test]
480    fn test_search_settings_path_match() {
481        let pages = vec![make_page(
482            "Editor",
483            vec![make_item("Tab Size", None, "/editor/tab_size")],
484        )];
485
486        let results = search_settings(&pages, "editor");
487        assert_eq!(results.len(), 1);
488    }
489
490    #[test]
491    fn test_matching_categories() {
492        let pages = vec![
493            make_page(
494                "Editor",
495                vec![make_item("Line Numbers", None, "/line_numbers")],
496            ),
497            make_page("Theme", vec![make_item("Theme Name", None, "/theme")]),
498        ];
499
500        let matches = matching_categories(&pages, "line");
501        assert_eq!(matches, vec![0]);
502
503        let matches = matching_categories(&pages, "theme");
504        assert_eq!(matches, vec![1]);
505    }
506
507    #[test]
508    fn test_search_ranking() {
509        let pages = vec![make_page(
510            "Editor",
511            vec![
512                make_item("Tab", None, "/tab"),                 // Exact match
513                make_item("Tab Size", None, "/tab_size"),       // Prefix match
514                make_item("Default Tab", None, "/default_tab"), // Contains match
515            ],
516        )];
517
518        let results = search_settings(&pages, "tab");
519        assert_eq!(results.len(), 3);
520        // Exact match should be first
521        assert_eq!(results[0].item.name, "Tab");
522        // Then prefix match
523        assert_eq!(results[1].item.name, "Tab Size");
524        // Then contains match (scored lower due to position)
525        assert_eq!(results[2].item.name, "Default Tab");
526    }
527
528    fn make_map_item(
529        name: &str,
530        path: &str,
531        entries: Vec<(String, serde_json::Value)>,
532    ) -> SettingItem {
533        use crate::view::controls::MapState;
534        let mut map_state = MapState::new(name);
535        map_state.entries = entries;
536        SettingItem {
537            path: path.to_string(),
538            name: name.to_string(),
539            description: None,
540            control: SettingControl::Map(map_state),
541            default: None,
542            modified: false,
543            layer_source: crate::config_io::ConfigLayer::System,
544            read_only: false,
545            is_auto_managed: false,
546            nullable: false,
547            is_null: false,
548            section: None,
549            is_section_start: false,
550            style: ItemBoxStyle::default(),
551            dual_list_sibling: None,
552        }
553    }
554
555    fn make_text_list_item(name: &str, path: &str, items: Vec<String>) -> SettingItem {
556        use crate::view::controls::TextListState;
557        let mut list_state = TextListState::new(name);
558        list_state.items = items;
559        SettingItem {
560            path: path.to_string(),
561            name: name.to_string(),
562            description: None,
563            control: SettingControl::TextList(list_state),
564            default: None,
565            modified: false,
566            layer_source: crate::config_io::ConfigLayer::System,
567            read_only: false,
568            is_auto_managed: false,
569            nullable: false,
570            is_null: false,
571            section: None,
572            is_section_start: false,
573            style: ItemBoxStyle::default(),
574            dual_list_sibling: None,
575        }
576    }
577
578    #[test]
579    fn test_search_map_key() {
580        let pages = vec![make_page(
581            "Languages",
582            vec![make_map_item(
583                "Languages",
584                "/languages",
585                vec![
586                    ("python".to_string(), serde_json::json!({})),
587                    ("rust".to_string(), serde_json::json!({})),
588                ],
589            )],
590        )];
591
592        let results = search_settings(&pages, "python");
593        // Should find a deep match on the map key "python"
594        let deep_results: Vec<_> = results.iter().filter(|r| r.deep_match.is_some()).collect();
595        assert!(!deep_results.is_empty(), "Should find map key 'python'");
596        assert!(
597            matches!(&deep_results[0].deep_match, Some(DeepMatch::MapKey { key, .. }) if key == "python")
598        );
599        assert_eq!(deep_results[0].breadcrumb, "Languages > python");
600    }
601
602    #[test]
603    fn test_search_map_key_no_duplicate_nested_values() {
604        // When a map key matches, nested values inside that entry should NOT
605        // produce additional results (avoids duplicates like "grammar: bash"
606        // alongside the "bash" key match).
607        let pages = vec![make_page(
608            "General",
609            vec![make_map_item(
610                "Languages",
611                "/languages",
612                vec![(
613                    "bash".to_string(),
614                    serde_json::json!({"grammar": "bash", "files": ["1.bash"]}),
615                )],
616            )],
617        )];
618
619        let results = search_settings(&pages, "bash");
620        let deep_results: Vec<_> = results.iter().filter(|r| r.deep_match.is_some()).collect();
621        // Should only have the MapKey match, not MapValue matches for "grammar: bash" etc.
622        assert_eq!(
623            deep_results.len(),
624            1,
625            "Expected exactly 1 deep match (MapKey), got {}: {:?}",
626            deep_results.len(),
627            deep_results
628                .iter()
629                .map(|r| &r.deep_match)
630                .collect::<Vec<_>>()
631        );
632        assert!(
633            matches!(&deep_results[0].deep_match, Some(DeepMatch::MapKey { key, .. }) if key == "bash")
634        );
635    }
636
637    #[test]
638    fn test_search_map_nested_value() {
639        let pages = vec![make_page(
640            "LSP",
641            vec![make_map_item(
642                "LSP",
643                "/lsp",
644                vec![(
645                    "rust".to_string(),
646                    serde_json::json!({"command": "rust-analyzer", "args": ["--stdio"]}),
647                )],
648            )],
649        )];
650
651        let results = search_settings(&pages, "rust-analyzer");
652        let deep_results: Vec<_> = results
653            .iter()
654            .filter(|r| matches!(&r.deep_match, Some(DeepMatch::MapValue { .. })))
655            .collect();
656        assert!(
657            !deep_results.is_empty(),
658            "Should find nested value 'rust-analyzer'"
659        );
660    }
661
662    #[test]
663    fn test_search_text_list_item() {
664        let pages = vec![make_page(
665            "Editor",
666            vec![make_text_list_item(
667                "File Extensions",
668                "/file_extensions",
669                vec!["py".to_string(), "rs".to_string(), "js".to_string()],
670            )],
671        )];
672
673        let results = search_settings(&pages, "py");
674        let deep_results: Vec<_> = results
675            .iter()
676            .filter(|r| matches!(&r.deep_match, Some(DeepMatch::TextListItem { .. })))
677            .collect();
678        assert!(!deep_results.is_empty(), "Should find text list item 'py'");
679    }
680
681    #[test]
682    fn test_deep_match_ranks_higher_than_fuzzy_noise() {
683        let pages = vec![
684            make_page(
685                "Editor",
686                vec![
687                    // "python" fuzzy-matches scattered chars in long names
688                    make_item(
689                        "Leading Spaces",
690                        Some("Show space indicators for leading whitespace"),
691                        "/editor/whitespace_spaces_leading",
692                    ),
693                ],
694            ),
695            make_page(
696                "Languages",
697                vec![make_map_item(
698                    "Languages",
699                    "/languages",
700                    vec![("python".to_string(), serde_json::json!({}))],
701                )],
702            ),
703        ];
704
705        let results = search_settings(&pages, "python");
706        assert!(!results.is_empty());
707        // The map key exact match on "python" should rank first
708        assert!(
709            matches!(&results[0].deep_match, Some(DeepMatch::MapKey { key, .. }) if key == "python"),
710            "Map key 'python' should rank above fuzzy noise, got: {:?}",
711            results[0].deep_match
712        );
713    }
714}