searchez 1.0.0

A searchable-model layer for Rust: make a type searchable, keep the index in sync, and search with real relevance ranking — over a pluggable backend, with a batteries-included in-memory engine that needs no external service.
Documentation
// End-to-end behaviour of searchez over the in-memory backend: the sync
// lifecycle (index / update / remove / reindex), real BM25 relevance ordering,
// filtering, paging, and the hydration id path.

use searchez::{Document, MemoryBackend, Query, Searchable, SearchEngine};

#[derive(Clone)]
struct Article {
    id: u64,
    title: String,
    body: String,
    published: bool,
}

impl Searchable for Article {
    fn index_name() -> &'static str {
        "articles"
    }
    fn search_id(&self) -> String {
        self.id.to_string()
    }
    fn to_document(&self) -> Document {
        Document::new()
            .field("title", self.title.clone())
            .field("body", self.body.clone())
            .field("published", self.published)
    }
}

fn engine() -> SearchEngine {
    SearchEngine::new(MemoryBackend::new())
}

fn article(id: u64, title: &str, body: &str, published: bool) -> Article {
    Article { id, title: title.into(), body: body.into(), published }
}

#[tokio::test]
async fn index_search_and_remove() {
    let e = engine();
    e.index(&article(1, "Rust ownership", "borrow checker basics", true))
        .await
        .unwrap();
    assert_eq!(e.count::<Article>().await.unwrap(), 1);

    let hits = e.search::<Article>("ownership").await.unwrap();
    assert_eq!(hits.len(), 1);
    assert_eq!(hits[0].id, "1");
    // The stored document comes back on the hit (display without a DB round-trip).
    assert_eq!(hits[0].document.get("title").unwrap(), "Rust ownership");

    // Removing it empties the results and the index.
    e.remove_id::<Article>("1").await.unwrap();
    assert!(e.search::<Article>("ownership").await.unwrap().is_empty());
    assert_eq!(e.count::<Article>().await.unwrap(), 0);
}

#[tokio::test]
async fn reindexing_a_record_supersedes_the_old_text() {
    let e = engine();
    e.index(&article(1, "coffee", "dark roast", true)).await.unwrap();
    // Re-index the same id with different text.
    e.index(&article(1, "tea", "green leaves", true)).await.unwrap();

    // The old term no longer matches; the new one does. No stale postings.
    assert!(e.search::<Article>("coffee").await.unwrap().is_empty());
    assert_eq!(e.search::<Article>("tea").await.unwrap().len(), 1);
    assert_eq!(e.count::<Article>().await.unwrap(), 1, "still one document, not two");
}

#[tokio::test]
async fn bm25_ranks_the_stronger_match_first() {
    let e = engine();
    // Doc 1 mentions "rust" once in a long body; doc 2 is titled "rust" and short.
    // BM25 (term frequency + length normalization) should rank doc 2 higher.
    e.index(&article(
        1,
        "Programming languages",
        "there are many languages and rust is one of the systems languages people use",
        true,
    ))
    .await
    .unwrap();
    e.index(&article(2, "Rust", "rust rust systems language", true))
        .await
        .unwrap();

    let hits = e.search::<Article>("rust").await.unwrap();
    assert_eq!(hits.len(), 2);
    assert_eq!(hits[0].id, "2", "the denser, shorter match ranks first");
    assert!(hits[0].score > hits[1].score, "scores must be ordered");
    assert!(hits[1].score > 0.0, "the weaker match still scores > 0");
}

#[tokio::test]
async fn filters_narrow_and_combine_with_text() {
    let e = engine();
    e.index(&article(1, "rust guide", "draft", false)).await.unwrap();
    e.index(&article(2, "rust guide", "live", true)).await.unwrap();

    // Text + filter: only the published one.
    let hits = e
        .search::<Article>(Query::text("rust").filter("published", true))
        .await
        .unwrap();
    assert_eq!(hits.len(), 1);
    assert_eq!(hits[0].id, "2");

    // Pure filter query (no text): everything matching, score 0.
    let all_drafts = e
        .search::<Article>(Query::all().filter("published", false))
        .await
        .unwrap();
    assert_eq!(all_drafts.len(), 1);
    assert_eq!(all_drafts[0].id, "1");
    assert_eq!(all_drafts[0].score, 0.0);
}

#[tokio::test]
async fn paging_is_stable_across_offset() {
    let e = engine();
    for i in 1..=5 {
        e.index(&article(i, "same title", "shared body text", true)).await.unwrap();
    }
    // All five score identically on "title"; ties break by id, so paging is
    // deterministic and non-overlapping.
    let page1 = e.search::<Article>(Query::text("title").limit(2).offset(0)).await.unwrap();
    let page2 = e.search::<Article>(Query::text("title").limit(2).offset(2)).await.unwrap();
    let ids1: Vec<_> = page1.iter().map(|h| &h.id).collect();
    let ids2: Vec<_> = page2.iter().map(|h| &h.id).collect();
    assert_eq!(ids1, vec!["1", "2"]);
    assert_eq!(ids2, vec!["3", "4"]);
}

#[tokio::test]
async fn reindex_rebuilds_from_a_full_set() {
    let e = engine();
    e.index(&article(99, "stale", "orphan document", true)).await.unwrap();

    let fresh = vec![
        article(1, "alpha", "first", true),
        article(2, "beta", "second", true),
    ];
    let n = e.reindex(&fresh).await.unwrap();
    assert_eq!(n, 2);

    // The pre-existing (stale) document is gone; only the fresh set remains.
    assert_eq!(e.count::<Article>().await.unwrap(), 2);
    assert!(e.search::<Article>("orphan").await.unwrap().is_empty());
    assert_eq!(e.search::<Article>("alpha").await.unwrap()[0].id, "1");
}

#[tokio::test]
async fn search_ids_drives_hydration_in_rank_order() {
    let e = engine();
    e.index(&article(1, "weak rust mention buried in words words words", "x", true)).await.unwrap();
    e.index(&article(2, "rust", "rust", true)).await.unwrap();

    // The hydration input: ids in rank order, to load full DB rows and re-sort.
    let ids = e.search_ids::<Article>("rust").await.unwrap();
    assert_eq!(ids, vec!["2".to_string(), "1".to_string()]);
}