lucisearch 0.8.0

Embeddable, in-process search engine — the SQLite/DuckDB of Elasticsearch
Documentation
use std::{fmt, io};

/// Alias for `Result`s in Luci.
pub type Result<T> = std::result::Result<T, LuciError>;

/// Top-level error type for all Luci operations.
///
/// Designed to map cleanly across the FFI boundary to Python exceptions.
/// See [[architecture-api-surface#Error Handling]].
#[derive(Debug)]
pub enum LuciError {
    /// Underlying I/O error.
    Io(io::Error),

    /// Index file does not exist at the given path.
    IndexNotFound(String),

    /// Index file failed checksum validation — data is corrupt.
    /// See [[architecture-storage-format#Crash Recovery]].
    IndexCorrupted(String),

    /// Another `IndexWriter` holds the file lock.
    /// See [[architecture-concurrency-model#Single-Writer Model]].
    WriterLocked,

    /// Document field type conflicts with the schema mapping.
    SchemaConflict {
        field: String,
        expected: String,
        actual: String,
    },

    /// Query type is recognized but not yet implemented.
    UnsupportedQuery(String),

    /// Query JSON is malformed or fails validation.
    InvalidQuery(String),

    /// A field value cannot be stored as specified — e.g. a keyword or
    /// `_id` whose UTF-8 length exceeds the 65535-byte columnar dictionary
    /// limit. Rejected at indexing time rather than silently truncated
    /// ([[code-must-not-lie]]). See [[optimization-keyword-dict-offset-index]].
    InvalidValue(String),

    /// Write attempted on Index while a transaction is active on the
    /// same thread. Use txn.add() instead of index.add().
    TransactionActive,

    /// ES query feature that Luci implements with different behavior.
    /// The query will NOT execute — users must acknowledge the difference.
    QueryBehaviorDifference { feature: String, difference: String },

    /// HNSW segment is in a recognized older format but requires
    /// user-driven migration (re-index). Used for the v0.7.1 → v0.7.2
    /// cosine format break.
    SegmentFormatMigrationRequired(String),

    /// HNSW segment carries an unknown format version byte. Most likely
    /// a future-format file opened by an older Luci binary.
    SegmentFormatUnknown(String),
}

impl fmt::Display for LuciError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Io(e) => write!(f, "I/O error: {e}"),
            Self::IndexNotFound(path) => write!(f, "index not found: {path}"),
            Self::IndexCorrupted(msg) => write!(f, "index corrupted: {msg}"),
            Self::WriterLocked => write!(f, "another writer holds the lock"),
            Self::SchemaConflict {
                field,
                expected,
                actual,
            } => write!(
                f,
                "schema conflict on field '{field}': expected {expected}, got {actual}"
            ),
            Self::UnsupportedQuery(q) => write!(f, "unsupported query type: {q}"),
            Self::InvalidQuery(msg) => write!(f, "invalid query: {msg}"),
            Self::InvalidValue(msg) => write!(f, "invalid value: {msg}"),
            Self::TransactionActive => write!(
                f,
                "cannot use index.add() while a transaction is active on this thread — use txn.add() instead"
            ),
            Self::QueryBehaviorDifference {
                feature,
                difference,
            } => write!(f, "{feature}: {difference}"),
            Self::SegmentFormatMigrationRequired(msg) => {
                write!(f, "segment format requires migration: {msg}")
            }
            Self::SegmentFormatUnknown(msg) => {
                write!(f, "unknown segment format: {msg}")
            }
        }
    }
}

impl std::error::Error for LuciError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Io(e) => Some(e),
            _ => None,
        }
    }
}

impl From<io::Error> for LuciError {
    fn from(e: io::Error) -> Self {
        Self::Io(e)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::error::Error;

    #[test]
    fn io_error_conversion() {
        let io_err = io::Error::new(io::ErrorKind::NotFound, "file gone");
        let luci_err = LuciError::from(io_err);
        assert!(matches!(luci_err, LuciError::Io(_)));
        assert!(luci_err.source().is_some());
    }

    #[test]
    fn display_index_not_found() {
        let err = LuciError::IndexNotFound("/tmp/test.luci".into());
        assert!(format!("{err}").contains("/tmp/test.luci"));
    }

    #[test]
    fn display_index_corrupted() {
        let err = LuciError::IndexCorrupted("checksum mismatch".into());
        assert!(format!("{err}").contains("checksum mismatch"));
    }

    #[test]
    fn display_writer_locked() {
        let err = LuciError::WriterLocked;
        assert!(format!("{err}").contains("lock"));
    }

    #[test]
    fn display_schema_conflict() {
        let err = LuciError::SchemaConflict {
            field: "price".into(),
            expected: "float".into(),
            actual: "text".into(),
        };
        let msg = format!("{err}");
        assert!(msg.contains("price"));
        assert!(msg.contains("float"));
        assert!(msg.contains("text"));
    }

    #[test]
    fn display_unsupported_query() {
        let err = LuciError::UnsupportedQuery("span_near".into());
        assert!(format!("{err}").contains("span_near"));
    }

    #[test]
    fn display_invalid_query() {
        let err = LuciError::InvalidQuery("unexpected token".into());
        assert!(format!("{err}").contains("unexpected token"));
    }

    #[test]
    fn display_invalid_value() {
        let err = LuciError::InvalidValue("keyword value exceeds 65535 bytes".into());
        let msg = format!("{err}");
        assert!(msg.contains("invalid value"));
        assert!(msg.contains("65535"));
    }

    #[test]
    fn non_io_errors_have_no_source() {
        let err = LuciError::WriterLocked;
        assert!(err.source().is_none());
    }
}