Skip to main content

luci/core/
error.rs

1use std::{fmt, io};
2
3/// Alias for `Result`s in Luci.
4pub type Result<T> = std::result::Result<T, LuciError>;
5
6/// Top-level error type for all Luci operations.
7///
8/// Designed to map cleanly across the FFI boundary to Python exceptions.
9/// See [[architecture-api-surface#Error Handling]].
10#[derive(Debug)]
11pub enum LuciError {
12    /// Underlying I/O error.
13    Io(io::Error),
14
15    /// Index file does not exist at the given path.
16    IndexNotFound(String),
17
18    /// Index file failed checksum validation — data is corrupt.
19    /// See [[architecture-storage-format#Crash Recovery]].
20    IndexCorrupted(String),
21
22    /// Another `IndexWriter` holds the file lock.
23    /// See [[architecture-concurrency-model#Single-Writer Model]].
24    WriterLocked,
25
26    /// Document field type conflicts with the schema mapping.
27    SchemaConflict {
28        field: String,
29        expected: String,
30        actual: String,
31    },
32
33    /// Query type is recognized but not yet implemented.
34    UnsupportedQuery(String),
35
36    /// Query JSON is malformed or fails validation.
37    InvalidQuery(String),
38
39    /// A field value cannot be stored as specified — e.g. a keyword or
40    /// `_id` whose UTF-8 length exceeds the 65535-byte columnar dictionary
41    /// limit. Rejected at indexing time rather than silently truncated
42    /// ([[code-must-not-lie]]). See [[optimization-keyword-dict-offset-index]].
43    InvalidValue(String),
44
45    /// Write attempted on Index while a transaction is active on the
46    /// same thread. Use txn.add() instead of index.add().
47    TransactionActive,
48
49    /// ES query feature that Luci implements with different behavior.
50    /// The query will NOT execute — users must acknowledge the difference.
51    QueryBehaviorDifference { feature: String, difference: String },
52
53    /// HNSW segment is in a recognized older format but requires
54    /// user-driven migration (re-index). Used for the v0.7.1 → v0.7.2
55    /// cosine format break.
56    SegmentFormatMigrationRequired(String),
57
58    /// HNSW segment carries an unknown format version byte. Most likely
59    /// a future-format file opened by an older Luci binary.
60    SegmentFormatUnknown(String),
61}
62
63impl fmt::Display for LuciError {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match self {
66            Self::Io(e) => write!(f, "I/O error: {e}"),
67            Self::IndexNotFound(path) => write!(f, "index not found: {path}"),
68            Self::IndexCorrupted(msg) => write!(f, "index corrupted: {msg}"),
69            Self::WriterLocked => write!(f, "another writer holds the lock"),
70            Self::SchemaConflict {
71                field,
72                expected,
73                actual,
74            } => write!(
75                f,
76                "schema conflict on field '{field}': expected {expected}, got {actual}"
77            ),
78            Self::UnsupportedQuery(q) => write!(f, "unsupported query type: {q}"),
79            Self::InvalidQuery(msg) => write!(f, "invalid query: {msg}"),
80            Self::InvalidValue(msg) => write!(f, "invalid value: {msg}"),
81            Self::TransactionActive => write!(
82                f,
83                "cannot use index.add() while a transaction is active on this thread — use txn.add() instead"
84            ),
85            Self::QueryBehaviorDifference {
86                feature,
87                difference,
88            } => write!(f, "{feature}: {difference}"),
89            Self::SegmentFormatMigrationRequired(msg) => {
90                write!(f, "segment format requires migration: {msg}")
91            }
92            Self::SegmentFormatUnknown(msg) => {
93                write!(f, "unknown segment format: {msg}")
94            }
95        }
96    }
97}
98
99impl std::error::Error for LuciError {
100    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
101        match self {
102            Self::Io(e) => Some(e),
103            _ => None,
104        }
105    }
106}
107
108impl From<io::Error> for LuciError {
109    fn from(e: io::Error) -> Self {
110        Self::Io(e)
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use std::error::Error;
118
119    #[test]
120    fn io_error_conversion() {
121        let io_err = io::Error::new(io::ErrorKind::NotFound, "file gone");
122        let luci_err = LuciError::from(io_err);
123        assert!(matches!(luci_err, LuciError::Io(_)));
124        assert!(luci_err.source().is_some());
125    }
126
127    #[test]
128    fn display_index_not_found() {
129        let err = LuciError::IndexNotFound("/tmp/test.luci".into());
130        assert!(format!("{err}").contains("/tmp/test.luci"));
131    }
132
133    #[test]
134    fn display_index_corrupted() {
135        let err = LuciError::IndexCorrupted("checksum mismatch".into());
136        assert!(format!("{err}").contains("checksum mismatch"));
137    }
138
139    #[test]
140    fn display_writer_locked() {
141        let err = LuciError::WriterLocked;
142        assert!(format!("{err}").contains("lock"));
143    }
144
145    #[test]
146    fn display_schema_conflict() {
147        let err = LuciError::SchemaConflict {
148            field: "price".into(),
149            expected: "float".into(),
150            actual: "text".into(),
151        };
152        let msg = format!("{err}");
153        assert!(msg.contains("price"));
154        assert!(msg.contains("float"));
155        assert!(msg.contains("text"));
156    }
157
158    #[test]
159    fn display_unsupported_query() {
160        let err = LuciError::UnsupportedQuery("span_near".into());
161        assert!(format!("{err}").contains("span_near"));
162    }
163
164    #[test]
165    fn display_invalid_query() {
166        let err = LuciError::InvalidQuery("unexpected token".into());
167        assert!(format!("{err}").contains("unexpected token"));
168    }
169
170    #[test]
171    fn display_invalid_value() {
172        let err = LuciError::InvalidValue("keyword value exceeds 65535 bytes".into());
173        let msg = format!("{err}");
174        assert!(msg.contains("invalid value"));
175        assert!(msg.contains("65535"));
176    }
177
178    #[test]
179    fn non_io_errors_have_no_source() {
180        let err = LuciError::WriterLocked;
181        assert!(err.source().is_none());
182    }
183}