1use super::items::{SettingControl, SettingItem, SettingsPage};
7
8#[derive(Debug, Clone)]
10pub enum DeepMatch {
11 MapKey {
13 key: String,
15 entry_index: usize,
17 },
18 MapValue {
20 key: String,
22 entry_index: usize,
24 field_path: String,
26 matched_text: String,
28 },
29 TextListItem {
31 text: String,
33 item_index: usize,
35 },
36}
37
38#[derive(Debug, Clone)]
40pub struct SearchResult {
41 pub page_index: usize,
43 pub item_index: usize,
45 pub item: SettingItem,
47 pub breadcrumb: String,
49 pub score: i32,
51 pub name_matches: Vec<usize>,
53 pub description_matches: Vec<usize>,
55 pub deep_match: Option<DeepMatch>,
57}
58
59pub 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 let (name_score, name_matches) = fuzzy_match(&item.name.to_lowercase(), &query_lower);
72
73 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 let (path_score, _) = fuzzy_match(&item.path.to_lowercase(), &query_lower);
82
83 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_composite_control(
101 &mut results,
102 page_index,
103 item_index,
104 item,
105 &page.name,
106 &query_lower,
107 );
108 }
109 }
110
111 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
121fn 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 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 continue;
152 }
153
154 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#[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 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
273fn 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 += 10; if let Some(prev) = prev_match_idx {
297 if text_idx == prev + 1 {
298 score += 15; }
300 }
301
302 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; }
309
310 if text_idx == pattern_idx {
312 score += 5; }
314
315 prev_match_idx = Some(text_idx);
316 pattern_idx += 1;
317 }
318 }
319
320 if pattern_idx == pattern_chars.len() {
322 let length_bonus = (100 - text_chars.len().min(100) as i32) / 10;
324 score += length_bonus;
325
326 if text == pattern {
328 score += 100;
329 }
330
331 (score, matched_indices)
332 } else {
333 (0, Vec::new())
335 }
336}
337
338pub 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
351pub 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 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"), make_item("Tab Size", None, "/tab_size"), make_item("Default Tab", None, "/default_tab"), ],
515 )];
516
517 let results = search_settings(&pages, "tab");
518 assert_eq!(results.len(), 3);
519 assert_eq!(results[0].item.name, "Tab");
521 assert_eq!(results[1].item.name, "Tab Size");
523 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 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 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 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 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 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}