use crate::daemon::CapabilityMeta;
use std::collections::HashSet;
#[derive(Debug, Clone)]
pub struct LexicalMatchResult {
pub id: String,
pub score: f32,
pub match_types: Vec<String>,
}
pub fn score_lexical(
query: impl AsRef<str>,
capabilities: &[(String, CapabilityMeta)],
) -> Vec<LexicalMatchResult> {
let query_clean = query.as_ref().trim().to_lowercase();
if query_clean.is_empty() {
return capabilities
.iter()
.map(|(id, _)| LexicalMatchResult {
id: id.clone(),
score: 1.0,
match_types: vec!["all".to_string()],
})
.collect();
}
let query_tokens: Vec<&str> = query_clean
.split(|c: char| !c.is_alphanumeric())
.filter(|s| !s.is_empty())
.collect();
let query_token_set: HashSet<&str> = query_tokens.iter().copied().collect();
let mut results = Vec::new();
for (id, meta) in capabilities {
let mut match_types = HashSet::new();
let mut score: f32 = 0.0;
if id.eq_ignore_ascii_case(&query_clean) {
score += 1.0;
match_types.insert("exact_id".to_string());
} else if id.to_ascii_lowercase().contains(&query_clean) {
score += 0.7;
match_types.insert("id".to_string());
}
if meta.server.eq_ignore_ascii_case(&query_clean)
|| meta.tool.eq_ignore_ascii_case(&query_clean)
{
score += 0.6;
match_types.insert("server_tool".to_string());
}
let mut tag_hit = false;
for tag in &meta.tags {
if tag.eq_ignore_ascii_case(&query_clean)
|| query_token_set
.iter()
.any(|qt| tag.eq_ignore_ascii_case(qt))
{
tag_hit = true;
break;
}
}
if tag_hit {
score += 0.5;
match_types.insert("tag".to_string());
}
let mut token_matches = 0;
let mut check_field_tokens = |text: &str| {
for token in text.split(|c: char| !c.is_alphanumeric()) {
if !token.is_empty() {
for qt in &query_token_set {
if token.eq_ignore_ascii_case(qt) {
token_matches += 1;
}
}
}
}
};
check_field_tokens(id);
check_field_tokens(&meta.summary);
check_field_tokens(&meta.description);
if !query_tokens.is_empty() {
let overlap_ratio = (token_matches as f32 / query_tokens.len() as f32).min(1.0);
if overlap_ratio > 0.0 {
score += overlap_ratio * 0.5;
match_types.insert("lexical".to_string());
}
}
if score > 0.0 {
let mut types_vec: Vec<String> = match_types.into_iter().collect();
types_vec.sort();
results.push(LexicalMatchResult {
id: id.clone(),
score: score.min(1.0),
match_types: types_vec,
});
}
}
results.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
});
results
}
#[cfg(test)]
mod tests {
use super::*;
fn dummy_capability(
server: &str,
tool: &str,
summary: &str,
tags: Vec<&str>,
) -> CapabilityMeta {
CapabilityMeta {
server: server.to_string(),
tool: tool.to_string(),
summary: summary.to_string(),
description: summary.to_string(),
input_schema: serde_json::json!({}),
tags: tags.into_iter().map(|s| s.to_string()).collect(),
examples: vec![],
}
}
#[test]
fn exact_id_returns_top_score() {
let caps = vec![(
"github.issues.search".to_string(),
dummy_capability(
"github",
"issues.search",
"Search GitHub issues",
vec!["git", "issues"],
),
)];
let matches = score_lexical("github.issues.search", &caps);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].score, 1.0);
assert!(matches[0].match_types.contains(&"exact_id".to_string()));
}
#[test]
fn tag_match_finds_item() {
let caps = vec![(
"obs.logs.search".to_string(),
dummy_capability(
"obs",
"logs.search",
"Search structured app logs",
vec!["logs", "read"],
),
)];
let matches = score_lexical("logs", &caps);
assert_eq!(matches.len(), 1);
assert!(matches[0].match_types.contains(&"tag".to_string()));
}
}