AkurAI Build
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, &current);
            current.clear();
        }
    }

    if !current.is_empty() {
        push_unique(&mut tokens, &current);
    }

    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);
    }
}