use crate::index::model::IndexEntry;
fn word_starts_with(haystack: &str, needle: &str) -> bool {
let bytes = haystack.as_bytes();
haystack
.match_indices(needle)
.any(|(i, _)| i == 0 || !bytes[i - 1].is_ascii_alphanumeric())
}
fn term_score(name: &str, desc: &str, cat: &str, term: &str) -> u32 {
let mut score = 0;
if name == term {
score += 6;
} else if word_starts_with(name, term) {
score += 3;
}
if word_starts_with(desc, term) {
score += 2;
}
if word_starts_with(cat, term) {
score += 1;
}
score
}
fn score(entry: &IndexEntry, terms: &[String]) -> u32 {
let name = entry.plugin.to_lowercase();
let desc = entry.description.to_lowercase();
let cat = entry.category.as_deref().unwrap_or("").to_lowercase();
let mut total = 0;
for t in terms {
match term_score(&name, &desc, &cat, t) {
0 => return 0,
s => total += s,
}
}
total
}
pub fn rank<'a>(
entries: &'a [IndexEntry],
query: &str,
marketplace: Option<&str>,
limit: usize,
) -> Vec<&'a IndexEntry> {
let terms: Vec<String> = query.split_whitespace().map(|t| t.to_lowercase()).collect();
let mkt = marketplace.map(|m| m.to_lowercase());
let mut scored: Vec<(u32, &IndexEntry)> = entries
.iter()
.filter(|e| match &mkt {
Some(m) => e.marketplace.to_lowercase() == *m,
None => true,
})
.map(|e| (score(e, &terms), e))
.filter(|(s, _)| *s > 0)
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.plugin.cmp(&b.1.plugin)));
scored.into_iter().take(limit).map(|(_, e)| e).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn e(plugin: &str, mkt: &str, desc: &str, cat: Option<&str>) -> IndexEntry {
IndexEntry {
plugin: plugin.into(),
marketplace: mkt.into(),
repo: format!("owner/{mkt}"),
description: desc.into(),
category: cat.map(|c| c.into()),
}
}
#[test]
fn ranks_name_hits_above_description_hits() {
let entries = vec![
e("django-helper", "a", "web framework", None),
e("logger", "a", "python logging utility", None),
e("python", "a", "the python toolkit", None),
];
let got = rank(&entries, "python", None, 10);
assert_eq!(got[0].plugin, "python"); assert_eq!(got[1].plugin, "logger"); assert_eq!(got.len(), 2); }
#[test]
fn matches_only_at_word_starts() {
let entries = vec![
e("guardrails-x", "a", "audit with guardrails", None),
e("ultra-rails-skills", "a", "hyphenated name", None),
e("layered-rails", "a", "review Rails apps", None),
];
let got = rank(&entries, "rails", None, 10);
let names: Vec<&str> = got.iter().map(|e| e.plugin.as_str()).collect();
assert_eq!(names, vec!["layered-rails", "ultra-rails-skills"]);
}
#[test]
fn drops_entries_missing_any_term() {
let entries = vec![
e("both", "a", "backend architecture guide", None),
e("one-only", "a", "backend only", None),
];
let got = rank(&entries, "backend architecture", None, 10);
assert_eq!(got.len(), 1);
assert_eq!(got[0].plugin, "both");
}
#[test]
fn multi_term_and_marketplace_filter_and_limit() {
let entries = vec![
e("a", "mkt1", "backend architecture guide", None),
e("b", "mkt2", "backend only", None),
e("c", "mkt1", "architecture only", None),
];
let got = rank(&entries, "backend architecture", Some("mkt1"), 1);
assert_eq!(got.len(), 1);
assert_eq!(got[0].plugin, "a"); }
}