use matchsorter::{AsMatchStr, Key, MatchSorterOptions, RankedItem, Ranking, match_sorter};
#[derive(Debug, PartialEq)]
struct Item {
name: String,
}
impl Item {
fn new(name: &str) -> Self {
Self {
name: name.to_owned(),
}
}
}
impl AsMatchStr for Item {
fn as_match_str(&self) -> &str {
&self.name
}
}
#[derive(Debug, PartialEq)]
struct TaggedItem {
name: String,
tags: Vec<String>,
}
impl TaggedItem {
fn new(name: &str, tags: &[&str]) -> Self {
Self {
name: name.to_owned(),
tags: tags.iter().map(|s| (*s).to_owned()).collect(),
}
}
}
impl AsMatchStr for TaggedItem {
fn as_match_str(&self) -> &str {
&self.name
}
}
#[test]
fn basic_string_array_apple_first() {
let items = ["apple", "banana", "grape"];
let results = match_sorter(&items, "ap", MatchSorterOptions::default());
assert!(!results.is_empty(), "should have at least one match");
assert_eq!(results[0], &"apple", "apple should be first (StartsWith)");
assert!(
results.contains(&&"grape"),
"grape should match via Contains"
);
}
#[test]
fn basic_string_array_rank_ordering() {
let items = ["pineapple", "apple", "applesauce"];
let results = match_sorter(&items, "apple", MatchSorterOptions::default());
assert_eq!(results[0], &"apple", "exact match first");
assert_eq!(results[1], &"applesauce", "StartsWith second");
assert_eq!(results[2], &"pineapple", "Contains third");
}
#[test]
fn case_insensitive_matching() {
let items = ["Green", "Red", "Blue"];
let results = match_sorter(&items, "green", MatchSorterOptions::default());
assert_eq!(results.len(), 1);
assert_eq!(results[0], &"Green");
}
#[test]
fn case_sensitive_beats_insensitive() {
let items = ["green", "Green"];
let results = match_sorter(&items, "green", MatchSorterOptions::default());
assert_eq!(results[0], &"green", "exact case match should be first");
assert_eq!(results[1], &"Green", "case-insensitive match second");
}
#[test]
fn diacritics_cafe_matches_accented() {
let items = ["cafe", "caf\u{00e9}", "restaurant"];
let results = match_sorter(&items, "cafe", MatchSorterOptions::default());
assert_eq!(results.len(), 2, "both cafe and cafe should match");
assert!(results.contains(&&"cafe"));
assert!(results.contains(&&"caf\u{00e9}"));
}
#[test]
fn diacritics_kept_no_cross_match() {
let items = ["cafe", "caf\u{00e9}"];
let opts = MatchSorterOptions {
keep_diacritics: true,
..Default::default()
};
let results = match_sorter(&items, "cafe", opts);
assert_eq!(results, vec![&"cafe"], "only exact cafe matches");
}
#[test]
fn threshold_contains_excludes_fuzzy() {
let items = ["apple", "banana", "grape"];
let opts = MatchSorterOptions {
threshold: Ranking::Contains,
..Default::default()
};
let results = match_sorter(&items, "ap", opts);
assert_eq!(results, vec![&"apple", &"grape"]);
}
#[test]
fn threshold_case_sensitive_equal_strict() {
let items = ["apple", "Apple", "APPLE"];
let opts = MatchSorterOptions {
threshold: Ranking::CaseSensitiveEqual,
..Default::default()
};
let results = match_sorter(&items, "apple", opts);
assert_eq!(results, vec![&"apple"]);
}
#[test]
fn key_based_struct_matching() {
let items = vec![Item::new("Alice"), Item::new("Bob"), Item::new("Charlie")];
let opts = MatchSorterOptions {
keys: vec![Key::new(|i: &Item| vec![i.name.clone()])],
..Default::default()
};
let results = match_sorter(&items, "ali", opts);
assert!(!results.is_empty());
assert_eq!(results[0].name, "Alice", "Alice matches via StartsWith");
}
#[test]
fn key_based_from_fn() {
let items = vec![Item::new("Delta"), Item::new("Echo"), Item::new("Foxtrot")];
let opts = MatchSorterOptions {
keys: vec![Key::<Item>::from_fn(|i| i.name.as_str())],
..Default::default()
};
let results = match_sorter(&items, "echo", opts);
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "Echo");
}
#[test]
fn multi_value_key_best_tag_wins() {
let items = vec![
TaggedItem::new("Alice", &["admin", "staff"]),
TaggedItem::new("Bob", &["user"]),
TaggedItem::new("Charlie", &["moderator", "admin"]),
];
let opts = MatchSorterOptions {
keys: vec![Key::new(|i: &TaggedItem| i.tags.clone())],
..Default::default()
};
let results = match_sorter(&items, "admin", opts);
assert_eq!(results.len(), 2);
let names: Vec<&str> = results.iter().map(|i| i.name.as_str()).collect();
assert!(names.contains(&"Alice"));
assert!(names.contains(&"Charlie"));
}
#[test]
fn multi_value_key_from_fn_multi() {
let items = vec![
TaggedItem::new("Server", &["production", "linux"]),
TaggedItem::new("Laptop", &["development", "macos"]),
];
let opts = MatchSorterOptions {
keys: vec![Key::<TaggedItem>::from_fn_multi(|i| {
i.tags.iter().map(|t| t.as_str()).collect()
})],
..Default::default()
};
let results = match_sorter(&items, "linux", opts);
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "Server");
}
#[test]
fn per_key_max_ranking_clamps_down() {
let items = vec![Item::new("Alice"), Item::new("Bob")];
let opts = MatchSorterOptions {
keys: vec![Key::new(|i: &Item| vec![i.name.clone()]).max_ranking(Ranking::Contains)],
..Default::default()
};
let results = match_sorter(&items, "Alice", opts);
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "Alice");
}
#[test]
fn per_key_min_ranking_promotes() {
let items = vec![Item::new("playground"), Item::new("apple")];
let opts = MatchSorterOptions {
keys: vec![Key::new(|i: &Item| vec![i.name.clone()]).min_ranking(Ranking::Contains)],
..Default::default()
};
let results = match_sorter(&items, "plgnd", opts);
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "playground");
}
#[test]
fn per_key_min_ranking_does_not_promote_no_match() {
let items = vec![Item::new("abc")];
let opts = MatchSorterOptions {
keys: vec![Key::new(|i: &Item| vec![i.name.clone()]).min_ranking(Ranking::Contains)],
..Default::default()
};
let results = match_sorter(&items, "xyz", opts);
assert!(results.is_empty());
}
#[test]
fn custom_base_sort_preserve_original_order() {
let items = ["cherry", "banana", "apple"];
let opts = MatchSorterOptions {
base_sort: Some(Box::new(|a: &RankedItem<&str>, b: &RankedItem<&str>| {
a.index.cmp(&b.index)
})),
..Default::default()
};
let results = match_sorter(&items, "", opts);
assert_eq!(results, vec![&"cherry", &"banana", &"apple"]);
}
#[test]
fn default_base_sort_alphabetical() {
let items = ["cherry", "banana", "apple"];
let results = match_sorter(&items, "", MatchSorterOptions::default());
assert_eq!(results, vec![&"apple", &"banana", &"cherry"]);
}
#[test]
fn sorter_override_reverse() {
let items = ["apple", "banana", "grape"];
let default_results = match_sorter(&items, "a", MatchSorterOptions::default());
let opts = MatchSorterOptions {
sorter: Some(Box::new(|mut items: Vec<RankedItem<&str>>| {
items.reverse();
items
})),
..Default::default()
};
let reversed_results = match_sorter(&items, "a", opts);
assert_eq!(reversed_results.len(), default_results.len());
let mut reversed_default = default_results.clone();
reversed_default.reverse();
assert_eq!(reversed_results, reversed_default);
}
#[test]
fn sorter_override_preserve_input_order() {
let items = ["grape", "apple", "banana"];
let opts = MatchSorterOptions {
sorter: Some(Box::new(|mut items: Vec<RankedItem<&str>>| {
items.sort_by_key(|ri| ri.index);
items
})),
..Default::default()
};
let results = match_sorter(&items, "", opts);
assert_eq!(results, vec![&"grape", &"apple", &"banana"]);
}
#[test]
fn empty_query_returns_all_sorted() {
let items = ["banana", "apple", "cherry"];
let results = match_sorter(&items, "", MatchSorterOptions::default());
assert_eq!(results.len(), 3, "all items should be returned");
assert_eq!(results[0], &"apple");
assert_eq!(results[1], &"banana");
assert_eq!(results[2], &"cherry");
}
#[test]
fn empty_query_string_items() {
let items = vec!["zebra".to_owned(), "mango".to_owned()];
let results = match_sorter(&items, "", MatchSorterOptions::default());
assert_eq!(results.len(), 2);
assert_eq!(results[0].as_str(), "mango");
assert_eq!(results[1].as_str(), "zebra");
}
#[test]
fn single_char_query_matches_substring() {
let items = ["apple", "banana", "plum", "grape"];
let results = match_sorter(&items, "a", MatchSorterOptions::default());
assert!(!results.contains(&&"plum"), "plum has no 'a'");
assert_eq!(results[0], &"apple", "apple starts with 'a'");
assert!(results.contains(&&"banana"));
assert!(results.contains(&&"grape"));
}
#[test]
fn single_char_query_no_match() {
let items = ["hello", "world"];
let results = match_sorter(&items, "z", MatchSorterOptions::default());
assert!(results.is_empty());
}
#[test]
fn acronym_matching_nwa() {
let items = [
"North-West Airlines",
"National Weather Association",
"Something Else",
];
let results = match_sorter(&items, "nwa", MatchSorterOptions::default());
assert!(
results.contains(&&"North-West Airlines"),
"North-West Airlines should match via Acronym"
);
assert!(
results.contains(&&"National Weather Association"),
"National Weather Association also has acronym nwa"
);
assert!(
!results.contains(&&"Something Else"),
"Something Else should not match"
);
}
#[test]
fn acronym_matching_asap() {
let items = ["as soon as possible", "something random"];
let results = match_sorter(&items, "asap", MatchSorterOptions::default());
assert_eq!(results.len(), 1);
assert_eq!(results[0], &"as soon as possible");
}
#[test]
fn word_boundary_fran_matches_san_francisco() {
let items = ["San Francisco", "New York", "Frankfurt"];
let results = match_sorter(&items, "fran", MatchSorterOptions::default());
assert!(results.contains(&&"San Francisco"));
assert!(results.contains(&&"Frankfurt"));
assert_eq!(results[0], &"Frankfurt");
assert_eq!(results[1], &"San Francisco");
}
#[test]
fn word_boundary_hyphen_not_boundary() {
let items = ["North-West", "South West"];
let results = match_sorter(&items, "west", MatchSorterOptions::default());
assert_eq!(
results[0], &"South West",
"South West should rank higher (WordStartsWith)"
);
assert_eq!(
results[1], &"North-West",
"North-West should rank lower (Contains)"
);
}
#[test]
fn edge_empty_items() {
let items: [&str; 0] = [];
let results = match_sorter(&items, "test", MatchSorterOptions::default());
assert!(results.is_empty());
}
#[test]
fn edge_very_long_strings() {
let long_string = "a".repeat(10_000);
let items = [long_string.as_str()];
let results = match_sorter(&items, "a", MatchSorterOptions::default());
assert_eq!(results.len(), 1);
}
#[test]
fn edge_long_query_short_items() {
let items = ["hi", "ok"];
let long_query = "a".repeat(1_000);
let results = match_sorter(&items, &long_query, MatchSorterOptions::default());
assert!(results.is_empty());
}
#[test]
fn edge_empty_string_item() {
let items = ["", "nonempty"];
let results = match_sorter(&items, "", MatchSorterOptions::default());
assert_eq!(results.len(), 2);
assert_eq!(results[0], &"", "empty string is CaseSensitiveEqual");
assert_eq!(results[1], &"nonempty", "nonempty is StartsWith");
}
#[test]
fn edge_unicode_items() {
let items = ["\u{4e16}\u{754c}", "hello"];
let results = match_sorter(&items, "\u{4e16}", MatchSorterOptions::default());
assert_eq!(results.len(), 1);
assert_eq!(results[0], &"\u{4e16}\u{754c}");
}
#[test]
fn per_key_threshold_override() {
let items = vec![Item::new("apple"), Item::new("apricot")];
let opts = MatchSorterOptions {
keys: vec![
Key::new(|i: &Item| vec![i.name.clone()]).threshold(Ranking::CaseSensitiveEqual),
],
threshold: Ranking::Matches(1.0), ..Default::default()
};
let results = match_sorter(&items, "apple", opts);
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "apple");
}