Skip to main content

soma_palette/
search.rs

1//! In-memory search over the mapped Palette catalog.
2//!
3//! Search is intentionally simple: a case-insensitive substring match over
4//! `id`, `title`, `category`, and `description`, ranked by which field
5//! matched first (`id`, then `title`, then `category`, then `description`),
6//! with original catalog order as the tiebreak. Callers that need fuzzier
7//! matching should do it client-side over the full catalog — this exists to
8//! keep `GET /v1/palette/search?q=` cheap and predictable.
9
10use crate::dto::LauncherCatalogEntry;
11
12const DEFAULT_LIMIT: usize = 50;
13
14#[must_use]
15pub fn search_entries(
16    entries: &[LauncherCatalogEntry],
17    query: &str,
18    limit: Option<usize>,
19) -> Vec<LauncherCatalogEntry> {
20    let limit = limit.unwrap_or(DEFAULT_LIMIT).max(1);
21    let query = query.trim();
22    if query.is_empty() {
23        return entries.iter().take(limit).cloned().collect();
24    }
25    let needle = query.to_ascii_lowercase();
26
27    let mut scored: Vec<(u8, usize, &LauncherCatalogEntry)> = entries
28        .iter()
29        .enumerate()
30        .filter_map(|(index, entry)| match_rank(entry, &needle).map(|rank| (rank, index, entry)))
31        .collect();
32    scored.sort_by_key(|(rank, index, _)| (*rank, *index));
33    scored
34        .into_iter()
35        .take(limit)
36        .map(|(_, _, entry)| entry.clone())
37        .collect()
38}
39
40/// Lower rank sorts first. `None` means no match at all.
41fn match_rank(entry: &LauncherCatalogEntry, needle: &str) -> Option<u8> {
42    if entry.id.to_ascii_lowercase().contains(needle) {
43        return Some(0);
44    }
45    if entry.title.to_ascii_lowercase().contains(needle) {
46        return Some(1);
47    }
48    if entry
49        .category
50        .as_deref()
51        .is_some_and(|category| category.to_ascii_lowercase().contains(needle))
52    {
53        return Some(2);
54    }
55    if entry.description.to_ascii_lowercase().contains(needle) {
56        return Some(3);
57    }
58    None
59}
60
61#[cfg(test)]
62#[path = "search_tests.rs"]
63mod tests;