use serde_json::Value;
use super::types::Node;
const NAME_KEYS: [&str; 6] = ["name", "title", "path", "label", "subject", "file"];
const BODY_WEIGHT: f64 = 0.6;
pub fn terms(text: &str) -> Vec<String> {
let mut out: Vec<String> = split_identifier(text);
out.sort();
out.dedup();
out
}
pub fn score(node: &Node, terms: &[String]) -> f64 {
if terms.is_empty() {
return 0.0;
}
let mut name_tokens: Vec<String> = split_identifier(&node.node_type);
let mut body_tokens: Vec<String> = Vec::new();
collect(&node.properties, None, &mut name_tokens, &mut body_tokens);
for label in &node.labels {
body_tokens.extend(split_identifier(label));
}
let total: f64 = terms
.iter()
.map(|t| {
let name_hit = best_hit(t, &name_tokens);
let body_hit = best_hit(t, &body_tokens) * BODY_WEIGHT;
name_hit.max(body_hit)
})
.sum();
total / terms.len() as f64
}
fn best_hit(term: &str, tokens: &[String]) -> f64 {
let mut best = 0.0f64;
for tok in tokens {
let hit = if tok == term {
1.0
} else if tok.starts_with(term) || term.starts_with(tok.as_str()) {
0.75
} else if tok.contains(term) || term.contains(tok.as_str()) {
0.5
} else {
0.0
};
if hit > best {
best = hit;
}
if best >= 1.0 {
break;
}
}
best
}
fn collect(value: &Value, key: Option<&str>, name: &mut Vec<String>, body: &mut Vec<String>) {
match value {
Value::String(s) => {
let bucket = if key.is_some_and(|k| NAME_KEYS.contains(&k)) {
name
} else {
body
};
bucket.extend(split_identifier(s));
}
Value::Object(map) => {
for (k, v) in map {
collect(v, Some(k.as_str()), name, body);
}
}
Value::Array(items) => {
for v in items {
collect(v, key, name, body);
}
}
_ => {}
}
}
fn split_identifier(s: &str) -> Vec<String> {
let lower = s.to_lowercase();
let mut out: Vec<String> = Vec::new();
for chunk in lower.split(|c: char| !c.is_alphanumeric()) {
if chunk.len() >= 2 {
out.push(chunk.to_string());
}
}
let mut hump = String::new();
for ch in s.chars() {
if ch.is_uppercase() && !hump.is_empty() {
if hump.len() >= 2 {
out.push(std::mem::take(&mut hump).to_lowercase());
} else {
hump.clear();
}
}
if ch.is_alphanumeric() {
hump.push(ch);
} else if hump.len() >= 2 {
out.push(std::mem::take(&mut hump).to_lowercase());
} else {
hump.clear();
}
}
if hump.len() >= 2 {
out.push(hump.to_lowercase());
}
if lower.len() >= 2 {
out.push(lower);
}
out.sort();
out.dedup();
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::prime::types::NodeId;
use chrono::{TimeZone, Utc};
use serde_json::json;
fn node(node_type: &str, props: Value) -> Node {
let at = Utc.with_ymd_and_hms(2026, 3, 4, 5, 6, 7).unwrap();
Node {
id: NodeId::from("11111111-2222-3333-4444-555555555555".to_string()),
node_type: node_type.to_string(),
properties: props,
domain: None,
labels: vec![],
deleted: false,
created_at: at,
updated_at: at,
}
}
#[test]
fn snake_case_identifiers_split_into_their_parts() {
let n = node("function", json!({ "name": "call_search" }));
assert!(score(&n, &terms("search")) > 0.0);
assert!(score(&n, &terms("call")) > 0.0);
}
#[test]
fn camel_case_identifiers_split_into_their_parts() {
let n = node("struct", json!({ "name": "HostedPrime" }));
assert!(score(&n, &terms("hosted")) > 0.0);
assert!(score(&n, &terms("prime")) > 0.0);
}
#[test]
fn a_prefix_term_scores_below_an_exact_one_but_above_nothing() {
let n = node("function", json!({ "name": "parse_invoice" }));
let partial = score(&n, &terms("invoic"));
assert!(partial > 0.0);
assert!(partial < score(&n, &terms("invoice")));
}
#[test]
fn an_identifier_query_and_its_parts_both_match_fully() {
let n = node("function", json!({ "name": "call_search" }));
assert_eq!(score(&n, &terms("call_search")), 1.0);
assert_eq!(score(&n, &terms("call")), 1.0);
}
#[test]
fn a_name_match_outranks_the_same_term_in_a_description() {
let named = node("function", json!({ "name": "checkout" }));
let described = node(
"function",
json!({ "name": "f", "description": "handles checkout" }),
);
let t = terms("checkout");
assert!(score(&named, &t) > score(&described, &t));
assert!(score(&described, &t) > 0.0);
}
#[test]
fn matching_every_term_outranks_matching_one() {
let both = node("function", json!({ "name": "parse_invoice" }));
let one = node("function", json!({ "name": "parse_header" }));
let t = terms("parse invoice");
assert!(score(&both, &t) > score(&one, &t));
}
#[test]
fn a_node_with_nothing_in_common_scores_zero() {
let n = node(
"person",
json!({ "name": "Alice", "email": "a@example.com" }),
);
assert_eq!(score(&n, &terms("kubernetes scheduler")), 0.0);
}
#[test]
fn no_terms_scores_zero_rather_than_matching_everything() {
let n = node("person", json!({ "name": "Alice" }));
assert_eq!(score(&n, &terms("")), 0.0);
assert_eq!(score(&n, &[]), 0.0);
}
#[test]
fn a_file_path_matches_by_any_of_its_segments() {
let n = node("file", json!({ "path": "apps/core/src/prime/facade.rs" }));
assert!(score(&n, &terms("facade")) > 0.0);
assert!(score(&n, &terms("prime")) > 0.0);
}
#[test]
fn nested_and_array_properties_are_searched() {
let n = node(
"interaction",
json!({ "from": { "name": "Dana" }, "tags": ["invoice", "urgent"] }),
);
assert!(score(&n, &terms("dana")) > 0.0);
assert!(score(&n, &terms("invoice")) > 0.0);
}
#[test]
fn numbers_are_not_matchable_text() {
let n = node("function", json!({ "name": "f", "line": 42 }));
assert_eq!(score(&n, &terms("42")), 0.0);
}
#[test]
fn the_node_type_itself_is_matchable() {
let n = node("project", json!({ "name": "atlas" }));
assert!(score(&n, &terms("project")) > 0.0);
}
}