feat(search): make cluster package search fuzzy (#686)

This commit is contained in:
LynithDev 2026-08-06 12:41:36 +02:00 • committed by GitHub
parent b60ce5af12
commit e99437f1b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 476 additions and 241 deletions

View file

@ -2,6 +2,7 @@ use std::time::Duration;
use freya::query::{Query, QueryCapability, UseQuery, use_query};
use oneclient_common::domain::GameLoader;
use oneclient_common::search::normalize_query;
use oneclient_content::packages::types::{
DEFAULT_PAGE_SIZE, Page, ProjectDetail, ProjectSummary, SearchFilters, SearchSort,
VersionSummary,
@ -53,7 +54,7 @@ impl QueryCapability for PackageSearchQuery {
Ok(provider
.search(
&SearchFilters {
query: (!keys.query.trim().is_empty()).then(|| keys.query.trim().to_string()),
query: (!keys.query.is_empty()).then(|| keys.query.clone()),
content_type: Some(keys.content_type),
game_versions: (!keys.game_versions.is_empty())
.then(|| keys.game_versions.clone()),
@ -84,7 +85,10 @@ pub fn use_package_search(
PackageSearchKeys {
provider,
content_type,
query,
// Canonical form, because the key is also the cache key: without it
// "sodium" and "sodium " are two entries, and adding a trailing
// space throws the results away and refetches for nothing.
query: normalize_query(&query),
game_versions,
loaders,
categories,

View file

@ -5,6 +5,7 @@ use freya::animation::*;
use freya::prelude::*;
use freya::router::*;
use freya::text_edit::Clipboard;
use oneclient_common::search::{MatchScore, SearchQuery};
use sysinfo::CpuRefreshKind;
use sysinfo::MemoryRefreshKind;
use sysinfo::RefreshKind;
@ -391,7 +392,7 @@ impl Component for SettingsShell {
let changelog_unread = has_unread_changelog();
let search = use_state(String::new);
let query = search.read().trim().to_string();
let query = SearchQuery::new(&search.read());
let scroll = use_scroll_controller(ScrollConfig::default);
SETTINGS_SCROLL_CONTROLLER.with(|cell| *cell.borrow_mut() = Some(scroll));
@ -420,7 +421,7 @@ impl Component for SettingsShell {
.into_element(),
]
} else {
search_results(query, search)
search_results(&query, search)
};
rect()
@ -504,19 +505,35 @@ fn search_box(mut search: State<String>) -> impl IntoElement {
)
}
fn search_results(query: String, mut search: State<String>) -> Vec<Element> {
let q = query.to_lowercase();
let mut matches: Vec<SearchItem> = SEARCH_INDEX
/// How well a settings entry answers the query. The title is what the user is
/// looking for, so a hit there outranks one in the blurb or the hidden keywords
/// however well those scored — a description match is a hint, not the thing.
fn settings_score(item: &SearchItem, query: &SearchQuery) -> Option<(u8, MatchScore)> {
if let Some(score) = query.score(item.title) {
return Some((2, score));
}
if let Some(score) = query.best_score(item.keywords.iter().copied()) {
return Some((1, score));
}
query.score(item.description).map(|score| (0, score))
}
fn search_results(query: &SearchQuery, mut search: State<String>) -> Vec<Element> {
let mut scored: Vec<(u8, MatchScore, SearchItem)> = SEARCH_INDEX
.iter()
.filter(|&item| {
item.title.to_lowercase().contains(&q)
|| item.description.to_lowercase().contains(&q)
|| item.keywords.iter().any(|k| k.to_lowercase().contains(&q))
})
.cloned()
.filter_map(|item| settings_score(item, query).map(|(f, s)| (f, s, item.clone())))
.collect();
matches.sort_by_key(|m| m.title.len());
// Best field, then best score, then the shortest title — the entry whose
// name is closest to being just the query.
scored.sort_by_key(|(field, score, item)| {
(
std::cmp::Reverse(*field),
std::cmp::Reverse(*score),
item.title.len(),
)
});
let mut matches: Vec<SearchItem> = scored.into_iter().map(|(_, _, item)| item).collect();
matches.truncate(SEARCH_RESULTS_MAX);
if matches.is_empty() {

View file

@ -6,6 +6,7 @@ use freya::animation::{
use freya::prelude::*;
use freya::router::RouterContext;
use oneclient_common::parse_mc_version;
use oneclient_common::search::normalize_query;
use oneclient_content::packages::types::ProjectSummary;
use oneclient_content::packages::types::SearchSort;
use oneclient_content::packages::{ContentType, ProviderId};
@ -127,9 +128,11 @@ impl Component for Browser {
};
let mut page_state = page;
// Normalised the same way the search key is, so a query that only picked
// up a space is not treated as a new search that resets the page.
let signature = format!(
"{provider_id:?}|{}|{compat}|{}",
debounced_query.read().trim().to_lowercase(),
normalize_query(&debounced_query.read()).to_lowercase(),
cats.join(",")
);
let mut last_signature = use_state(|| signature.clone());

View file

@ -4,6 +4,7 @@ use std::sync::Arc;
use freya::prelude::*;
use freya::query::MutationStateData;
use freya::text_edit::Clipboard;
use oneclient_common::search::SearchQuery;
use oneclient_core::{LogFileInfo, LogKind, LogLevel};
use crate::components::{
@ -366,14 +367,10 @@ impl Component for LogPicker {
let on_select = self.on_select.clone();
let is_open = open() && enabled;
let query = filter.read().to_lowercase();
let query = SearchQuery::new(&filter.read());
let rows: Vec<Element> = files
.into_iter()
.filter(|f| {
query.is_empty()
|| f.name.to_lowercase().contains(&query)
|| kind_badge(f.kind).to_lowercase().contains(&query)
})
.filter(|f| query.matches(&f.name) || query.matches(kind_badge(f.kind)))
.map(|file| {
let on_select = on_select.clone();
let path = file.path.clone();

View file

@ -1,6 +1,7 @@
use std::collections::{HashMap, HashSet};
use freya::prelude::*;
use oneclient_common::search::{MatchScore, SearchQuery};
use oneclient_content::packages::{CachedPackageMeta, ContentType, ProviderId};
use oneclient_core::{BundleFileKind, BundleWithUpdateStatus, LinkedArtifactInfo};
use oneclient_db::models::OverrideType;
@ -287,11 +288,10 @@ impl Tab {
}
}
/// `query` is expected to be lowercased already.
fn matches_query(p: &PackageEntry, query: &str) -> bool {
query.is_empty()
|| p.name.to_lowercase().contains(query)
|| p.file_name.to_lowercase().contains(query)
/// A package is searched by what the user sees (its display name) and by what
/// is on disk (its file name), whichever matches better.
fn query_score(p: &PackageEntry, query: &SearchQuery) -> Option<MatchScore> {
query.best_score([p.name.as_str(), p.file_name.as_str()])
}
/// Rows to show for the current toolbar state. The search is scoped to the
@ -302,18 +302,25 @@ fn matches_query(p: &PackageEntry, query: &str) -> bool {
fn visible_packages(
items: &[PackageEntry],
tab: Option<&Tab>,
query: &str,
query: &SearchQuery,
show: EnabledFilter,
hidden: HiddenFilter,
) -> Vec<PackageEntry> {
items
.iter()
.filter(|p| tab.is_none_or(|t| t.matches(p)) && matches_query(p, query))
.filter(|p| tab.is_none_or(|t| t.matches(p)) && query_score(p, query).is_some())
.filter(|p| show.keep(p) && hidden.keep(p))
.cloned()
.collect()
}
/// Reorders search results so the closest matches come first. Stable, so the
/// sort the user picked in the toolbar survives as the tie-break between rows
/// that matched equally well.
fn rank_by_query(rows: &mut [PackageEntry], query: &SearchQuery) {
rows.sort_by_key(|p| std::cmp::Reverse(query_score(p, query)));
}
pub fn bundle_categories(bundles: &[BundleWithUpdateStatus]) -> Vec<String> {
let mut cats: Vec<String> = Vec::new();
for bundle in bundles {
@ -403,7 +410,7 @@ impl Component for PackageManager {
let view = use_view_state("cluster.packages");
let sort = view.sort;
let layout = view.layout;
let query = search.read().to_lowercase();
let query = SearchQuery::new(&search.read());
let sort_mode = sort
.read()
.as_deref()
@ -420,6 +427,12 @@ impl Component for PackageManager {
let mut filtered = visible_packages(&items, tab_filter, &query, show, hidden);
sort_mode.sort(&mut filtered);
// Relevance has to win while searching: with fuzzy matching, a name that
// only matched after forgiving a typo would otherwise sort above the one
// the user actually typed.
if !query.is_empty() {
rank_by_query(&mut filtered, &query);
}
// Coming up empty during a search is about the query, not about the tab
// having nothing in it, so it gets its own empty state naming the tab
@ -468,212 +481,3 @@ impl Component for PackageManager {
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::view::onboarding::test_support::{archive, file};
use oneclient_core::{BundleFile, FileUpdateStatus};
fn bundle(category: &str, files: Vec<BundleFile>) -> BundleWithUpdateStatus {
BundleWithUpdateStatus {
files: files
.iter()
.cloned()
.map(|f| (f, FileUpdateStatus::UpToDate))
.collect(),
archive: archive(category, true, files),
has_updates: false,
}
}
fn installed(package_id: &str) -> LinkedArtifactInfo {
LinkedArtifactInfo {
hash: package_id.to_string(),
cluster_file_name: format!("{package_id}.jar"),
enabled: true,
content_type: ContentType::Mod,
file_name: format!("{package_id}.jar"),
project_id: None,
version_id: None,
display_name: None,
display_version: None,
provider: None,
published_at: None,
}
}
fn rows_for(
content: Vec<LinkedArtifactInfo>,
bundles: &[BundleWithUpdateStatus],
) -> Vec<PackageEntry> {
bundle_packages(
content,
bundles,
&HashMap::new(),
&PackageMetaMap::new(),
&HashSet::new(),
ContentType::Mod,
)
}
fn ids(rows: &[PackageEntry]) -> Vec<String> {
rows.iter().map(|r| r.package_id.clone()).collect()
}
fn shown(rows: &[PackageEntry], hidden: HiddenFilter) -> Vec<String> {
ids(&visible_packages(
rows,
None,
"",
EnabledFilter::All,
hidden,
))
}
#[test]
fn hidden_bundle_files_are_not_listed_by_default() {
let bundles = vec![bundle(
"Core",
vec![file("visible", true, false), file("dep", true, true)],
)];
let rows = rows_for(Vec::new(), &bundles);
assert_eq!(shown(&rows, HiddenFilter::Hide), ["visible"]);
}
#[test]
fn turning_the_filter_off_reveals_hidden_bundle_files() {
let bundles = vec![bundle(
"Core",
vec![file("visible", true, false), file("dep", true, true)],
)];
let rows = rows_for(Vec::new(), &bundles);
assert_eq!(shown(&rows, HiddenFilter::Show), ["visible", "dep"]);
}
#[test]
fn an_installed_hidden_file_does_not_come_back_as_loose_content() {
// The dependency really is in the cluster, so the pass over installed
// content would happily list it as a second, unmanaged local row.
let bundles = vec![bundle("Core", vec![file("dep", true, true)])];
let rows = rows_for(vec![installed("dep")], &bundles);
assert_eq!(ids(&rows), ["dep"]);
assert!(rows[0].hidden && rows[0].in_bundle());
assert!(shown(&rows, HiddenFilter::Hide).is_empty());
}
#[test]
fn a_category_of_only_hidden_files_gets_no_tab() {
let bundles = vec![bundle("Core", vec![file("dep", true, true)])];
let rows = rows_for(Vec::new(), &bundles);
let labels = |hidden| -> Vec<String> {
build_tabs(&bundle_categories(&bundles), &rows, hidden)
.iter()
.map(Tab::label)
.collect()
};
assert!(!labels(HiddenFilter::Hide).contains(&"Core".to_string()));
assert!(labels(HiddenFilter::Show).contains(&"Core".to_string()));
}
#[test]
fn a_file_another_bundle_shows_openly_stays_visible() {
let bundles = vec![
bundle("Core", vec![file("shared", true, true)]),
bundle("Extras", vec![file("shared", true, false)]),
];
let rows = rows_for(Vec::new(), &bundles);
assert_eq!(ids(&rows), ["shared"]);
assert_eq!(
rows[0].bundle_name.as_deref(),
Some("OneClient 1.21.11 Fabric [Extras]")
);
}
fn entry(name: &str, provider: ProviderId, categories: &[&str]) -> PackageEntry {
PackageEntry {
package_id: name.to_string(),
bundle_name: (!categories.is_empty()).then(|| "bundle".to_string()),
provider,
name: name.to_string(),
file_name: format!("{}.jar", name.to_lowercase()),
author: String::new(),
description: String::new(),
icon_url: None,
size: 0,
categories: categories.iter().map(|c| (*c).to_string()).collect(),
enabled: true,
manifest_default: true,
installed: true,
hash: None,
update_available: false,
hidden: false,
}
}
fn items() -> Vec<PackageEntry> {
vec![
entry("Sodium", ProviderId::Modrinth, &["Performance"]),
entry("Sodium Extra", ProviderId::Modrinth, &["Visuals"]),
entry("Sodium Local", ProviderId::Local, &[]),
]
}
fn names(rows: &[PackageEntry]) -> Vec<&str> {
rows.iter().map(|p| p.name.as_str()).collect()
}
#[test]
fn search_stays_inside_the_active_category() {
let items = items();
let tab = Tab::Category("Performance".to_string());
let rows = visible_packages(
&items,
Some(&tab),
"sodium",
EnabledFilter::All,
HiddenFilter::Hide,
);
assert_eq!(names(&rows), ["Sodium"]);
}
#[test]
fn search_stays_inside_the_local_tab() {
let items = items();
let rows = visible_packages(
&items,
Some(&Tab::Local),
"sodium",
EnabledFilter::All,
HiddenFilter::Hide,
);
assert_eq!(names(&rows), ["Sodium Local"]);
}
#[test]
fn all_tab_searches_every_package() {
let items = items();
let rows = visible_packages(
&items,
Some(&Tab::All),
"sodium",
EnabledFilter::All,
HiddenFilter::Hide,
);
assert_eq!(names(&rows), ["Sodium", "Sodium Extra", "Sodium Local"]);
}
#[test]
fn empty_query_keeps_the_whole_tab() {
let items = items();
let tab = Tab::Category("Visuals".to_string());
let rows = visible_packages(
&items,
Some(&tab),
"",
EnabledFilter::All,
HiddenFilter::Hide,
);
assert_eq!(names(&rows), ["Sodium Extra"]);
}
}

View file

@ -11,6 +11,7 @@ pub mod domain;
pub mod os_ext;
pub mod paths;
pub mod patch;
pub mod search;
pub mod version;
mod error;
@ -21,4 +22,5 @@ pub use domain::{
pub use error::{PathsError, PathsResult};
pub use os_ext::OsExt;
pub use patch::Patch;
pub use search::{MatchScore, SearchQuery, normalize_query};
pub use version::{ParsedMcVersion, VersionKey, format_mc_version, parse_mc_version};

View file

@ -0,0 +1,394 @@
//! Query normalisation and typo-tolerant matching for the launcher's search
//! boxes.
//!
//! Every search surface — the package browser, a cluster's package list, the
//! settings index — funnels its raw text box through [`SearchQuery`] so they all
//! agree on what a query *is*. Surfaces that match locally then use the scoring
//! here; surfaces that hand the query to a remote provider only take the
//! normalised text, because ranking a provider's results is the provider's job.
/// Collapses a text box's raw contents into the canonical form every search
/// agrees on: no leading or trailing whitespace, internal runs of whitespace
/// reduced to a single space.
///
/// Without this, `"sodium"`, `" sodium"` and `"sodium extra"` are three
/// different searches — different provider requests, different cache keys, and
/// for a plain `contains` filter, different results.
#[must_use]
pub fn normalize_query(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for word in raw.split_whitespace() {
if !out.is_empty() {
out.push(' ');
}
out.push_str(word);
}
out
}
/// How well a candidate matched. Ordered worst to best, so a list of candidates
/// sorts into relevance order with `sort_by_key(|c| Reverse(score))`.
///
/// The tier dominates the comparison: a prefix match always beats a substring
/// match, which always beats a subsequence match, which always beats a
/// typo-tolerant one. The remainder only orders candidates *within* a tier, so
/// the ordering never surprises — a worse kind of match cannot climb above a
/// better one by scoring well.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct MatchScore(u32);
impl MatchScore {
/// What an empty query awards every candidate: nothing is more relevant than
/// anything else, so whatever order the caller already had survives.
pub const NEUTRAL: Self = Self(0);
}
/// Tier floors. The gap between them is wider than any bonus below can reach.
const TIER_EXACT: u32 = 5_000;
const TIER_PREFIX: u32 = 4_000;
const TIER_WORD_PREFIX: u32 = 3_000;
const TIER_SUBSTRING: u32 = 2_000;
const TIER_SUBSEQUENCE: u32 = 1_000;
const TIER_TYPO: u32 = 100;
/// Ceiling for the within-tier bonus, comfortably below the 1000-wide tier gap.
const BONUS_MAX: u32 = 900;
/// Below this many characters a typo budget would match almost anything, so
/// short terms are held to the exact tiers.
const MIN_TYPO_LEN: usize = 4;
/// A search box's contents, normalised once so matching a list of candidates
/// against it doesn't redo the work per row.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct SearchQuery {
/// Normalised and lowercased; this is what gets matched and what a remote
/// provider should be sent.
text: String,
/// `text` split on spaces. Multi-word queries fall back to matching each
/// word independently, so "sodum extra" can still reach "Sodium Extra".
terms: Vec<String>,
}
impl SearchQuery {
#[must_use]
pub fn new(raw: &str) -> Self {
let text = normalize_query(raw).to_lowercase();
let terms = text
.split(' ')
.filter(|t| !t.is_empty())
.map(str::to_string)
.collect();
Self { text, terms }
}
/// The normalised query text. Safe to use as a cache key or to send to a
/// provider — two raw inputs differing only in spacing produce the same one.
#[must_use]
pub fn as_str(&self) -> &str {
&self.text
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
/// Scores `haystack`, or `None` if it doesn't match at all.
///
/// An empty query matches everything at [`MatchScore::NEUTRAL`].
#[must_use]
pub fn score(&self, haystack: &str) -> Option<MatchScore> {
if self.is_empty() {
return Some(MatchScore::NEUTRAL);
}
let hay = normalize_query(haystack).to_lowercase();
if hay.is_empty() {
return None;
}
if let Some(score) = score_whole(&hay, &self.text) {
return Some(score);
}
// The query as one string didn't land, so try it as independent words
// against the candidate's: every term has to hit one, and the weakest
// hit sets the tier. This is what lets word order differ ("extra
// sodium"), what keeps a typo in one word from sinking the whole query,
// and what gives the typo budget a word to work against instead of a
// whole name it could never be close enough to.
let mut worst = u32::MAX;
for term in &self.terms {
let best = hay
.split(' ')
.filter_map(|word| score_whole(word, term))
.max()
.or_else(|| score_whole(&hay, term))?;
worst = worst.min(best.0);
}
// A per-word match is a weaker claim than a whole-query one, so it can
// never outrank the tier it matched at.
Some(MatchScore(worst.saturating_sub(1)))
}
/// The best score across several fields of the same candidate — a package's
/// display name and its file name, say. `None` if no field matched.
#[must_use]
pub fn best_score<'a>(&self, fields: impl IntoIterator<Item = &'a str>) -> Option<MatchScore> {
fields.into_iter().filter_map(|f| self.score(f)).max()
}
#[must_use]
pub fn matches(&self, haystack: &str) -> bool {
self.score(haystack).is_some()
}
}
/// Scores one already-normalised, already-lowercased pair.
fn score_whole(hay: &str, needle: &str) -> Option<MatchScore> {
if hay == needle {
return Some(MatchScore(TIER_EXACT));
}
if hay.starts_with(needle) {
return Some(MatchScore(TIER_PREFIX + coverage(hay, needle)));
}
if let Some(at) = hay.find(needle) {
// Landing on a word boundary ("extra" in "Sodium Extra") reads as a real
// hit; landing mid-word ("ode" in "Sodium") is closer to a coincidence.
let word_start = hay[..at].ends_with(' ');
let tier = if word_start {
TIER_WORD_PREFIX
} else {
TIER_SUBSTRING
};
return Some(MatchScore(tier + coverage(hay, needle)));
}
if let Some(span) = subsequence_span(hay, needle) {
// A subsequence spread over the whole name is a weaker match than one
// packed together, so tightness — not just length — sets the bonus.
let tightness = scale(needle.chars().count(), span);
return Some(MatchScore(TIER_SUBSEQUENCE + tightness));
}
let needle_len = needle.chars().count();
if needle_len < MIN_TYPO_LEN {
return None;
}
let budget = typo_budget(needle_len);
let distance = osa_distance(hay, needle, budget)?;
// Fewer typos to forgive is a better match; an exact-length hit with one
// wrong letter should beat one with two.
let bonus = BONUS_MAX.saturating_sub((distance as u32) * (BONUS_MAX / (budget as u32 + 1)));
Some(MatchScore(TIER_TYPO + bonus))
}
/// How much of the candidate the query accounts for, as a within-tier bonus.
/// "Sodium" hit by "sodiu" outranks "Sodium Extra Extras" hit by the same.
fn coverage(hay: &str, needle: &str) -> u32 {
scale(needle.chars().count(), hay.chars().count())
}
fn scale(part: usize, whole: usize) -> u32 {
if whole == 0 {
return 0;
}
let ratio = (part.min(whole) as u64 * u64::from(BONUS_MAX)) / whole as u64;
ratio as u32
}
/// Letters a term of this length may get wrong. Deliberately stingy: a generous
/// budget turns every search into a match for everything.
fn typo_budget(len: usize) -> usize {
if len >= 8 { 2 } else { 1 }
}
/// The width of the tightest window of `hay` containing `needle`'s characters in
/// order, or `None` if it isn't a subsequence at all. Covers the typo class a
/// dropped or doubled letter produces ("sodum", "sodiium").
fn subsequence_span(hay: &str, needle: &str) -> Option<usize> {
let hay: Vec<char> = hay.chars().collect();
let needle: Vec<char> = needle.chars().collect();
if needle.is_empty() || needle.len() > hay.len() {
return None;
}
// Walk forward for the end of the earliest match, then walk back from there
// for the latest start that still matches — that pair is the tightest window
// around this match.
let mut n = 0;
let mut end = None;
for (i, c) in hay.iter().enumerate() {
if *c == needle[n] {
n += 1;
if n == needle.len() {
end = Some(i);
break;
}
}
}
let end = end?;
let mut n = needle.len();
let mut start = 0;
for i in (0..=end).rev() {
if hay[i] == needle[n - 1] {
n -= 1;
if n == 0 {
start = i;
break;
}
}
}
Some(end - start + 1)
}
/// Optimal string alignment distance, giving up as soon as it exceeds `max`.
/// Unlike a plain Levenshtein it counts a transposition ("soduim") as one edit,
/// which is the typo people actually make when typing quickly.
fn osa_distance(a: &str, b: &str, max: usize) -> Option<usize> {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.len().abs_diff(b.len()) > max {
return None;
}
// Three rows rotate through these buffers; `two_ago` is only ever read from
// row two onwards, by which point it holds real values.
let mut two_ago: Vec<usize> = vec![0; b.len() + 1];
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur: Vec<usize> = vec![0; b.len() + 1];
for i in 1..=a.len() {
cur[0] = i;
let mut row_min = cur[0];
for j in 1..=b.len() {
let cost = usize::from(a[i - 1] != b[j - 1]);
let mut best = (prev[j] + 1).min(cur[j - 1] + 1).min(prev[j - 1] + cost);
if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
best = best.min(two_ago[j - 2] + 1);
}
cur[j] = best;
row_min = row_min.min(best);
}
// Every later row is at least this large, so there is no way back under
// the budget from here.
if row_min > max {
return None;
}
std::mem::swap(&mut two_ago, &mut prev);
std::mem::swap(&mut prev, &mut cur);
}
(prev[b.len()] <= max).then_some(prev[b.len()])
}
#[cfg(test)]
mod tests {
use super::*;
fn best(query: &str, candidates: &[&str]) -> Vec<String> {
let q = SearchQuery::new(query);
let mut scored: Vec<(MatchScore, &str)> = candidates
.iter()
.filter_map(|c| q.score(c).map(|s| (s, *c)))
.collect();
scored.sort_by_key(|(s, c)| (std::cmp::Reverse(*s), *c));
scored.into_iter().map(|(_, c)| c.to_string()).collect()
}
#[test]
fn whitespace_never_changes_a_query() {
let canonical = SearchQuery::new("sodium extra");
for raw in [
" sodium extra",
"sodium extra ",
"sodium extra",
"\tsodium\nextra ",
] {
assert_eq!(SearchQuery::new(raw), canonical, "{raw:?}");
}
assert_eq!(canonical.as_str(), "sodium extra");
}
#[test]
fn a_blank_query_is_empty() {
assert!(SearchQuery::new(" ").is_empty());
assert_eq!(
SearchQuery::new("").score("anything"),
Some(MatchScore::NEUTRAL)
);
}
#[test]
fn common_typos_still_find_the_package() {
// Dropped letter, transposition, wrong letter, doubled letter.
for typo in ["sodum", "soduim", "sodiun", "sodiium"] {
assert!(SearchQuery::new(typo).matches("Sodium"), "{typo}");
}
}
#[test]
fn short_terms_are_not_fuzzed() {
// Two edits away from half the alphabet; a budget here is noise.
assert!(!SearchQuery::new("abc").matches("Sodium"));
}
#[test]
fn unrelated_names_do_not_match() {
assert!(!SearchQuery::new("sodium").matches("Journeymap"));
}
#[test]
fn better_kinds_of_match_rank_first() {
assert_eq!(
best(
"sodium",
&[
"Sodium",
"Sodium Extra",
"Indium (Sodium addon)",
"Sodum Fork",
"Sodiun"
]
),
[
// exact, then prefix (shorter first), then word-boundary
// substring, then the typo-tolerant tail.
"Sodium",
"Sodium Extra",
"Indium (Sodium addon)",
"Sodiun",
"Sodum Fork",
]
);
}
#[test]
fn a_tight_subsequence_beats_a_scattered_one() {
assert_eq!(
best("srr", &["Sodium Rendering Regression", "Sorry"]),
["Sorry", "Sodium Rendering Regression"]
);
}
#[test]
fn multi_word_queries_match_out_of_order() {
let q = SearchQuery::new("extra sodium");
assert!(q.matches("Sodium Extra"));
assert!(!q.matches("Sodium"));
}
#[test]
fn a_typo_in_one_word_does_not_sink_the_query() {
assert!(SearchQuery::new("sodum extra").matches("Sodium Extra"));
}
#[test]
fn best_score_picks_the_strongest_field() {
let q = SearchQuery::new("sodium");
let whole = q.score("Sodium").unwrap();
assert_eq!(q.best_score(["Fabric API", "Sodium"]), Some(whole));
assert_eq!(q.best_score(["Fabric API", "fabric-api.jar"]), None);
}
}

View file

@ -58,8 +58,9 @@ impl PackageProvider for CurseForgeProvider {
params.append_pair("index", &filters.offset.unwrap_or(0).to_string());
params.append_pair("sortField", "6");
params.append_pair("sortOrder", "desc");
if let Some(q) = &filters.query {
params.append_pair("searchFilter", q);
let query = filters.normalized_query();
if !query.is_empty() {
params.append_pair("searchFilter", &query);
}
if let Some(v) = filters.game_versions.as_ref().and_then(|v| v.first()) {
params.append_pair("gameVersion", v);

View file

@ -60,7 +60,7 @@ impl PackageProvider for ModrinthProvider {
let mut params = url.query_pairs_mut();
params.append_pair("limit", &limit.to_string());
params.append_pair("offset", &offset.to_string());
params.append_pair("query", filters.query.as_deref().unwrap_or_default());
params.append_pair("query", &filters.normalized_query());
let mut groups: Vec<Vec<String>> = Vec::new();
if let Some(content_type) = filters.content_type {

View file

@ -4,6 +4,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use oneclient_common::domain::{ContentType, GameLoader, ProviderId};
use oneclient_common::search::normalize_query;
pub const DEFAULT_PAGE_SIZE: usize = 24;
@ -19,6 +20,18 @@ pub struct SearchFilters {
pub sort: Option<SearchSort>,
}
impl SearchFilters {
/// The query as it should go on the wire: no surrounding whitespace, and
/// internal runs collapsed to a single space. Providers score the raw string
/// they are given, so " sodium " and "sodium" come back ranked differently —
/// normalising at the boundary is what stops a stray keystroke from changing
/// the results. Empty when there is nothing to search for.
#[must_use]
pub fn normalized_query(&self) -> String {
self.query.as_deref().map(normalize_query).unwrap_or_default()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SearchSort {
#[default]