Menu
Public repository / master
AkurAI-Notes / crates/notes-core/src/search.rs
Latest change b3a7784af2094889f6d5d4630b0cbd6d8ff94452 - release: v0.0.2 by Claude Code
use akurai_json::Value;
pub fn owner_matches(record: &Value, owners: &[&str]) -> bool {
let Some(record_owner) = record.get("ownerSub").and_then(|v| v.as_str()) else {
return false;
};
owners
.iter()
.any(|owner| !owner.is_empty() && record_owner == *owner)
}
pub fn lexical_score(query: &str, title: &str, body: &str) -> Option<f32> {
let tokens = query_tokens(query);
if tokens.is_empty() {
return None;
}
let normalized_query = normalize_text(query);
let title_norm = normalize_text(title);
let body_norm = normalize_text(body);
let combined = format!("{title_norm} {body_norm}");
if tokens.iter().any(|token| !combined.contains(token)) {
return None;
}
let mut score = 0.0;
if !normalized_query.is_empty() {
if title_norm.contains(&normalized_query) {
score += 100.0;
}
if body_norm.contains(&normalized_query) {
score += 30.0;
}
}
for token in &tokens {
if title_norm.contains(token) {
score += 12.0;
score += occurrence_count(&title_norm, token).min(4) as f32;
}
if body_norm.contains(token) {
score += 3.0;
score += occurrence_count(&body_norm, token).min(6) as f32 * 0.5;
}
}
Some(score)
}
pub fn note_id(note: &Value) -> i64 {
note.get("id").and_then(|v| v.as_i64()).unwrap_or(0)
}
pub fn note_updated_at(note: &Value) -> i64 {
note.get("updatedAt").and_then(|v| v.as_i64()).unwrap_or(0)
}
pub fn note_title(note: &Value) -> &str {
note.get("title").and_then(|v| v.as_str()).unwrap_or("")
}
pub fn note_body(note: &Value) -> &str {
note.get("body").and_then(|v| v.as_str()).unwrap_or("")
}
pub fn snippet(body: &str, max_chars: usize) -> String {
let mut out = String::new();
let mut truncated = false;
for (idx, ch) in body.chars().enumerate() {
if idx >= max_chars {
truncated = true;
break;
}
out.push(ch);
}
if truncated {
out.push_str("...");
}
out
}
fn query_tokens(query: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
for ch in query.chars() {
if ch.is_alphanumeric() {
for lower in ch.to_lowercase() {
current.push(lower);
}
} else if !current.is_empty() {
push_unique(&mut tokens, ¤t);
current.clear();
}
}
if !current.is_empty() {
push_unique(&mut tokens, ¤t);
}
tokens
}
fn push_unique(tokens: &mut Vec<String>, token: &str) {
if !tokens.iter().any(|existing| existing == token) {
tokens.push(token.to_string());
}
}
fn normalize_text(text: &str) -> String {
let mut out = String::new();
let mut last_was_space = true;
for ch in text.chars() {
if ch.is_alphanumeric() {
for lower in ch.to_lowercase() {
out.push(lower);
}
last_was_space = false;
} else if !last_was_space {
out.push(' ');
last_was_space = true;
}
}
if out.ends_with(' ') {
out.pop();
}
out
}
fn occurrence_count(haystack: &str, needle: &str) -> usize {
if needle.is_empty() {
return 0;
}
haystack.matches(needle).count()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lexical_score_ignores_punctuation_between_query_terms() {
let score = lexical_score(
"AkurAI Platform Skills CLI Tools",
"AkurAI Platform - Skills & CLI Tools",
"",
);
assert!(score.is_some());
}
#[test]
fn lexical_score_requires_all_query_terms() {
let score = lexical_score("platform passvault", "AkurAI Platform", "CLI tools");
assert!(score.is_none());
}
#[test]
fn title_matches_rank_above_body_only_matches() {
let title_score = lexical_score("skills cli tools", "Skills & CLI Tools", "").unwrap();
let body_score = lexical_score("skills cli tools", "Index", "Skills CLI Tools").unwrap();
assert!(title_score > body_score);
}
}