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
//! # searchez — a searchable-model layer for Rust
//!
//! The Rust ecosystem has excellent search *engines* (tantivy, meilisearch,
//! opensearch) and nothing above them: no way to say "this model is
//! searchable", keep its index in sync as records change, and turn results back
//! into records. Every project rewrites the same `to_document()`, the same
//! post-save index call, and the same bespoke backfill binary. searchez is that
//! missing layer.
//!
//! Three pieces:
//! 1. [`Searchable`] — a type declares its index, its id, and its document.
//! 2. [`SearchEngine`] — index / remove / reindex / search, over a pluggable
//!    [`Backend`]. The lifecycle spine.
//! 3. [`MemoryBackend`] — a real BM25-ranked in-memory engine, so it all runs
//!    and tests with no external service. Swap in a server backend later;
//!    nothing above the [`Backend`] trait changes.
//!
//! ```
//! use searchez::{Searchable, SearchEngine, MemoryBackend, Document, Query};
//!
//! struct Product { id: u64, name: String, in_stock: bool }
//!
//! impl Searchable for Product {
//!     fn index_name() -> &'static str { "products" }
//!     fn search_id(&self) -> String { self.id.to_string() }
//!     fn to_document(&self) -> Document {
//!         Document::new().field("name", self.name.clone()).field("in_stock", self.in_stock)
//!     }
//! }
//!
//! # async fn demo() -> searchez::Result<()> {
//! let engine = SearchEngine::new(MemoryBackend::new());
//! engine.index(&Product { id: 1, name: "Dark Roast Coffee".into(), in_stock: true }).await?;
//!
//! let hits = engine.search::<Product>(Query::text("coffee").filter("in_stock", true)).await?;
//! assert_eq!(hits[0].id, "1");
//! # Ok(()) }
//! ```
//!
//! ## Keeping the index in sync
//!
//! Rust has no universal ORM callback, so sync points are explicit — call
//! [`SearchEngine::index`] after a create/update and [`SearchEngine::remove`]
//! after a delete. In exchange the boilerplate (document mapping, backend call,
//! bulk backfill) is done for you. [`SearchEngine::reindex`] rebuilds an index
//! from a full set of records (the backfill job).
//!
//! ## Hydration
//!
//! A [`Hit`] carries the stored [`Document`], which covers displaying results
//! directly. When you need the full database record (columns or associations
//! you didn't index), take `hit.id`, load the rows from your database, and
//! preserve the hit order — see the README for the pattern.

mod backend;
mod document;
mod error;
mod memory;
#[cfg(feature = "meilisearch")]
mod meilisearch;
mod query;
mod token;

pub use backend::{Backend, IndexDoc};
pub use document::Document;
pub use error::{Result, SearchError};
pub use memory::MemoryBackend;
#[cfg(feature = "meilisearch")]
pub use meilisearch::MeilisearchBackend;
pub use query::{Filter, Hit, Query, DEFAULT_LIMIT};

/// A type that can be indexed and searched.
///
/// Implement three things: which index it lives in, how to derive its stable id,
/// and how to project it to a [`Document`]. The id must round-trip — it's what
/// `remove` deletes by and what a [`Hit`] hands back for hydration.
pub trait Searchable {
    /// The index (Searchkick's per-model index). Usually the table/collection
    /// name. `Self: Sized` so it's callable as `T::index_name()`.
    fn index_name() -> &'static str
    where
        Self: Sized;

    /// A stable, unique id for this record, as a string.
    fn search_id(&self) -> String;

    /// Project this record to its searchable document.
    fn to_document(&self) -> Document;
}

/// The handle you index and search through. Holds a [`Backend`]; clone-free,
/// pass it by reference (wrap in `Arc` to share across tasks).
pub struct SearchEngine {
    backend: Box<dyn Backend>,
}

impl SearchEngine {
    /// Build an engine over any backend. Use [`MemoryBackend`] to start.
    pub fn new(backend: impl Backend + 'static) -> Self {
        Self {
            backend: Box::new(backend),
        }
    }

    /// Index (or re-index) one record.
    pub async fn index<T: Searchable>(&self, item: &T) -> Result<()> {
        self.backend
            .upsert(
                T::index_name(),
                vec![IndexDoc {
                    id: item.search_id(),
                    document: item.to_document(),
                }],
            )
            .await
    }

    /// Index a batch in one call — the efficient path for many records.
    pub async fn index_many<T: Searchable>(&self, items: &[T]) -> Result<()> {
        if items.is_empty() {
            return Ok(());
        }
        let docs = items
            .iter()
            .map(|i| IndexDoc {
                id: i.search_id(),
                document: i.to_document(),
            })
            .collect();
        self.backend.upsert(T::index_name(), docs).await
    }

    /// Remove one record from its index.
    pub async fn remove<T: Searchable>(&self, item: &T) -> Result<()> {
        self.remove_id::<T>(&item.search_id()).await
    }

    /// Remove by id — for when the record is already gone and you only have its
    /// id (e.g. in a delete path).
    pub async fn remove_id<T: Searchable>(&self, id: &str) -> Result<()> {
        self.backend
            .delete(T::index_name(), &[id.to_string()])
            .await
    }

    /// Rebuild an index from scratch: clear it, then index every record. This is
    /// the backfill job — run it to seed a new index or repair drift.
    ///
    /// Returns the number of records indexed. Note this is a clear-then-fill, so
    /// there is a window where the index is partial; a zero-downtime alias swap
    /// is a backend-specific enhancement for later.
    pub async fn reindex<T: Searchable>(&self, items: &[T]) -> Result<usize> {
        self.backend.clear(T::index_name()).await?;
        self.index_many(items).await?;
        Ok(items.len())
    }

    /// Search an index. Accepts a `&str`/`String` (full-text with defaults) or a
    /// built [`Query`]. Hits come back ranked and paginated.
    pub async fn search<T: Searchable>(&self, query: impl Into<Query>) -> Result<Vec<Hit>> {
        self.backend.search(T::index_name(), &query.into()).await
    }

    /// Just the ids of the matches, in rank order — the input to hydrating full
    /// records from your database.
    pub async fn search_ids<T: Searchable>(&self, query: impl Into<Query>) -> Result<Vec<String>> {
        Ok(self
            .search::<T>(query)
            .await?
            .into_iter()
            .map(|h| h.id)
            .collect())
    }

    /// How many documents an index holds.
    pub async fn count<T: Searchable>(&self) -> Result<usize> {
        self.backend.count(T::index_name()).await
    }
}