Skip to main content

frankensearch_core/
error.rs

1use std::path::PathBuf;
2
3/// Unified error type covering all failure modes across the frankensearch search pipeline.
4///
5/// Every variant includes an actionable error message guiding the consumer toward resolution.
6/// The `TwoTierSearcher` catches transient errors and degrades gracefully: fast embedding
7/// failures can still yield lexical-only initial results when lexical retrieval is available,
8/// `RerankFailed` skips reranking, and `SearchTimeout` yields initial results.
9/// Only `IndexNotFound` and `InvalidConfig` prevent search from starting.
10#[derive(Debug, thiserror::Error)]
11pub enum SearchError {
12    // === Embedding errors ===
13    /// An embedding model is not available (not compiled in, or model files missing).
14    #[error(
15        "Embedder unavailable: {model} — {reason}. Set FRANKENSEARCH_MODEL_DIR or enable the corresponding feature flag."
16    )]
17    EmbedderUnavailable {
18        /// Identifier of the unavailable model.
19        model: String,
20        /// Why it is unavailable.
21        reason: String,
22    },
23
24    /// Embedding inference failed for a given model.
25    #[error(
26        "Embedding failed for {model}: {source}. Transient error; retry or use lexical fallback when configured."
27    )]
28    EmbeddingFailed {
29        /// Which model failed.
30        model: String,
31        /// The underlying error.
32        #[source]
33        source: Box<dyn std::error::Error + Send + Sync>,
34    },
35
36    /// Model files were not found at any searched path.
37    #[error("Model {name} not found. Run download or set FRANKENSEARCH_MODEL_DIR.")]
38    ModelNotFound {
39        /// Model identifier.
40        name: String,
41    },
42
43    /// Model files exist but failed to load (corrupted, incompatible version, etc.).
44    #[error("Failed to load model from {path}: {source}")]
45    ModelLoadFailed {
46        /// Path that was attempted.
47        path: PathBuf,
48        /// The underlying error.
49        #[source]
50        source: Box<dyn std::error::Error + Send + Sync>,
51    },
52
53    // === Index errors ===
54    /// The vector index file is corrupted (bad magic, CRC mismatch, truncated).
55    #[error(
56        "Vector index corrupted at {path}: {detail}. Delete and rebuild with index_documents()."
57    )]
58    IndexCorrupted {
59        /// Path to the corrupted file.
60        path: PathBuf,
61        /// Nature of the corruption.
62        detail: String,
63    },
64
65    /// The FSVI file version does not match what this build expects.
66    #[error(
67        "Index version mismatch at index: expected v{expected}, found v{found}. Rebuild the index."
68    )]
69    IndexVersionMismatch {
70        /// The version this library expects.
71        expected: u16,
72        /// The version found in the file.
73        found: u16,
74    },
75
76    /// Query vector dimension does not match the index dimension.
77    #[error(
78        "Dimension mismatch: index has {expected}-dim vectors, query has {found}-dim. Use matching embedder."
79    )]
80    DimensionMismatch {
81        /// Dimension the index was built with.
82        expected: usize,
83        /// Dimension of the query vector.
84        found: usize,
85    },
86
87    /// No vector index file exists at the expected path.
88    #[error(
89        "Vector index not found at {path}. Run index_documents() first, or check FRANKENSEARCH_DATA_DIR."
90    )]
91    IndexNotFound {
92        /// Expected path.
93        path: PathBuf,
94    },
95
96    // === Search errors ===
97    /// The query string could not be parsed.
98    #[error("Query parse error for \"{query}\": {detail}")]
99    QueryParseError {
100        /// The problematic query.
101        query: String,
102        /// What went wrong.
103        detail: String,
104    },
105
106    /// A search phase exceeded its time budget.
107    #[error(
108        "Search timed out after {elapsed_ms}ms (budget: {budget_ms}ms). Increase timeout in TwoTierConfig."
109    )]
110    SearchTimeout {
111        /// How long the operation ran.
112        elapsed_ms: u64,
113        /// The configured budget.
114        budget_ms: u64,
115    },
116
117    /// Federated search did not receive enough successful shard responses.
118    #[error(
119        "Federated search required at least {required} index responses, but only {received} succeeded."
120    )]
121    FederatedInsufficientResponses {
122        /// Minimum responses required by config.
123        required: usize,
124        /// Number of successful shard responses observed.
125        received: usize,
126    },
127
128    // === Reranker errors ===
129    /// The reranking model is not available.
130    #[error(
131        "Reranker unavailable: {model}. Results are valid without reranking; enable 'rerank' feature."
132    )]
133    RerankerUnavailable {
134        /// Model identifier.
135        model: String,
136    },
137
138    /// Reranking inference failed.
139    #[error(
140        "Reranking failed for {model}: {source}. Results still valid with original RRF scores."
141    )]
142    RerankFailed {
143        /// Which reranker model failed.
144        model: String,
145        /// The underlying error.
146        #[source]
147        source: Box<dyn std::error::Error + Send + Sync>,
148    },
149
150    // === I/O errors ===
151    /// Wraps `std::io::Error` for file operations.
152    #[error("I/O error: {0}. Check file permissions and disk space.")]
153    Io(#[from] std::io::Error),
154
155    // === Configuration errors ===
156    /// A configuration value is invalid.
157    #[error("Invalid config: {field} = \"{value}\" — {reason}")]
158    InvalidConfig {
159        /// Which config field.
160        field: String,
161        /// The invalid value.
162        value: String,
163        /// Why it is invalid.
164        reason: String,
165    },
166
167    // === Hash verification ===
168    /// Downloaded or loaded file does not match expected hash.
169    #[error("Hash mismatch for {path}: expected {expected}, got {actual}. File may be corrupted.")]
170    HashMismatch {
171        /// Path to the file.
172        path: PathBuf,
173        /// Expected hash (hex string).
174        expected: String,
175        /// Actual computed hash.
176        actual: String,
177    },
178
179    // === Cancellation ===
180    /// Operation was cancelled via the asupersync structured concurrency protocol.
181    #[error("Operation cancelled during {phase}: {reason}")]
182    Cancelled {
183        /// Which phase was active when cancelled.
184        phase: String,
185        /// Cancellation reason.
186        reason: String,
187    },
188
189    // === Queue errors ===
190    /// The embedding job queue is full.
191    #[error(
192        "Embedding queue full ({pending}/{capacity} pending). Apply backpressure or increase capacity."
193    )]
194    QueueFull {
195        /// Number of pending items.
196        pending: usize,
197        /// Queue capacity.
198        capacity: usize,
199    },
200
201    // === Subsystem errors ===
202    /// Wraps errors from optional subsystems (storage, durability, FTS5, etc.).
203    ///
204    /// Always present in the enum regardless of feature flags, avoiding
205    /// match-arm breakage across feature combinations.
206    #[error("{subsystem} error: {source}")]
207    SubsystemError {
208        /// Which subsystem produced the error (e.g., "storage", "durability", "fts5").
209        subsystem: &'static str,
210        /// The underlying error.
211        #[source]
212        source: Box<dyn std::error::Error + Send + Sync>,
213    },
214
215    /// A durability/repair feature was requested but is not compiled in.
216    #[error(
217        "Durability feature is not enabled. Enable the 'durability' Cargo feature for self-healing indices."
218    )]
219    DurabilityDisabled,
220}
221
222/// Convenience alias used throughout the frankensearch crate hierarchy.
223pub type SearchResult<T> = Result<T, SearchError>;
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use std::error::Error as _;
229
230    #[test]
231    fn error_is_send_sync() {
232        fn assert_send_sync<T: Send + Sync>() {}
233        assert_send_sync::<SearchError>();
234    }
235
236    #[test]
237    fn io_error_conversion() {
238        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
239        let search_err: SearchError = io_err.into();
240        assert!(matches!(search_err, SearchError::Io(_)));
241        assert!(search_err.to_string().contains("gone"));
242    }
243
244    #[test]
245    fn display_messages_are_actionable() {
246        let err = SearchError::IndexNotFound {
247            path: PathBuf::from("/tmp/missing.fsvi"),
248        };
249        let msg = err.to_string();
250        assert!(msg.contains("index_documents()"), "should suggest recovery");
251
252        let err = SearchError::DimensionMismatch {
253            expected: 256,
254            found: 384,
255        };
256        let msg = err.to_string();
257        assert!(msg.contains("256"));
258        assert!(msg.contains("384"));
259    }
260
261    #[test]
262    fn federated_insufficient_responses_message_has_counts() {
263        let err = SearchError::FederatedInsufficientResponses {
264            required: 2,
265            received: 1,
266        };
267        let msg = err.to_string();
268        assert!(msg.contains('2'));
269        assert!(msg.contains('1'));
270    }
271
272    #[test]
273    fn subsystem_error_wraps_arbitrary_errors() {
274        let inner = std::io::Error::other("db locked");
275        let err = SearchError::SubsystemError {
276            subsystem: "storage",
277            source: Box::new(inner),
278        };
279        assert!(err.to_string().contains("storage"));
280        assert!(err.to_string().contains("db locked"));
281    }
282
283    #[test]
284    fn search_result_alias_works() {
285        // Verify the type alias compiles and works with both Ok and Err variants.
286        let ok: SearchResult<u32> = Ok(42);
287        assert!(ok.is_ok());
288
289        let err: SearchResult<u32> = Err(SearchError::DurabilityDisabled);
290        assert!(err.is_err());
291    }
292
293    #[test]
294    fn embedding_failed_preserves_source() {
295        let inner = std::io::Error::other("onnx crash");
296        let err = SearchError::EmbeddingFailed {
297            model: "MiniLM".into(),
298            source: Box::new(inner),
299        };
300        assert!(err.to_string().contains("MiniLM"));
301        assert!(err.to_string().contains("onnx crash"));
302    }
303
304    #[test]
305    fn cancelled_variant() {
306        let err = SearchError::Cancelled {
307            phase: "quality_embed".into(),
308            reason: "parent scope dropped".into(),
309        };
310        assert!(err.to_string().contains("quality_embed"));
311        assert!(err.to_string().contains("parent scope dropped"));
312    }
313
314    #[test]
315    fn embedder_unavailable_display() {
316        let err = SearchError::EmbedderUnavailable {
317            model: "MiniLM".into(),
318            reason: "feature not enabled".into(),
319        };
320        let msg = err.to_string();
321        assert!(msg.contains("MiniLM"));
322        assert!(msg.contains("feature not enabled"));
323    }
324
325    #[test]
326    fn model_not_found_display() {
327        let err = SearchError::ModelNotFound {
328            name: "all-MiniLM-L6-v2".into(),
329        };
330        let msg = err.to_string();
331        assert!(msg.contains("all-MiniLM-L6-v2"));
332        assert!(msg.contains("FRANKENSEARCH_MODEL_DIR"));
333    }
334
335    #[test]
336    fn model_load_failed_preserves_source() {
337        let inner = std::io::Error::other("mmap failed");
338        let err = SearchError::ModelLoadFailed {
339            path: PathBuf::from("/models/broken.onnx"),
340            source: Box::new(inner),
341        };
342        let msg = err.to_string();
343        assert!(msg.contains("/models/broken.onnx"));
344        assert!(msg.contains("mmap failed"));
345        // Verify source chain via std::error::Error trait
346        assert!(err.source().is_some());
347    }
348
349    #[test]
350    fn index_corrupted_display() {
351        let err = SearchError::IndexCorrupted {
352            path: PathBuf::from("/data/index.fsvi"),
353            detail: "CRC mismatch in header".into(),
354        };
355        let msg = err.to_string();
356        assert!(msg.contains("/data/index.fsvi"));
357        assert!(msg.contains("CRC mismatch"));
358        assert!(msg.contains("rebuild"));
359    }
360
361    #[test]
362    fn index_version_mismatch_display() {
363        let err = SearchError::IndexVersionMismatch {
364            expected: 3,
365            found: 1,
366        };
367        let msg = err.to_string();
368        assert!(msg.contains("v3"));
369        assert!(msg.contains("v1"));
370        assert!(msg.contains("Rebuild"));
371    }
372
373    #[test]
374    fn query_parse_error_display() {
375        let err = SearchError::QueryParseError {
376            query: "foo AND OR bar".into(),
377            detail: "unexpected OR after AND".into(),
378        };
379        let msg = err.to_string();
380        assert!(msg.contains("foo AND OR bar"));
381        assert!(msg.contains("unexpected OR after AND"));
382    }
383
384    #[test]
385    fn search_timeout_display() {
386        let err = SearchError::SearchTimeout {
387            elapsed_ms: 750,
388            budget_ms: 500,
389        };
390        let msg = err.to_string();
391        assert!(msg.contains("750"));
392        assert!(msg.contains("500"));
393    }
394
395    #[test]
396    fn reranker_unavailable_display() {
397        let err = SearchError::RerankerUnavailable {
398            model: "cross-encoder".into(),
399        };
400        let msg = err.to_string();
401        assert!(msg.contains("cross-encoder"));
402        assert!(msg.contains("rerank"));
403    }
404
405    #[test]
406    fn rerank_failed_preserves_source() {
407        let inner = std::io::Error::other("inference oom");
408        let err = SearchError::RerankFailed {
409            model: "cross-encoder".into(),
410            source: Box::new(inner),
411        };
412        let msg = err.to_string();
413        assert!(msg.contains("cross-encoder"));
414        assert!(msg.contains("inference oom"));
415        assert!(err.source().is_some());
416    }
417
418    #[test]
419    fn invalid_config_display() {
420        let err = SearchError::InvalidConfig {
421            field: "quality_weight".into(),
422            value: "-1.0".into(),
423            reason: "must be between 0.0 and 1.0".into(),
424        };
425        let msg = err.to_string();
426        assert!(msg.contains("quality_weight"));
427        assert!(msg.contains("-1.0"));
428        assert!(msg.contains("must be between"));
429    }
430
431    #[test]
432    fn hash_mismatch_display() {
433        let err = SearchError::HashMismatch {
434            path: PathBuf::from("/tmp/model.bin"),
435            expected: "abc123".into(),
436            actual: "def456".into(),
437        };
438        let msg = err.to_string();
439        assert!(msg.contains("/tmp/model.bin"));
440        assert!(msg.contains("abc123"));
441        assert!(msg.contains("def456"));
442    }
443
444    #[test]
445    fn queue_full_display() {
446        let err = SearchError::QueueFull {
447            pending: 100,
448            capacity: 100,
449        };
450        let msg = err.to_string();
451        assert!(msg.contains("100"));
452        assert!(msg.contains("backpressure"));
453    }
454
455    #[test]
456    fn durability_disabled_display() {
457        let err = SearchError::DurabilityDisabled;
458        let msg = err.to_string();
459        assert!(msg.contains("durability"));
460    }
461
462    #[test]
463    fn error_debug_format() {
464        let err = SearchError::DimensionMismatch {
465            expected: 128,
466            found: 256,
467        };
468        let debug = format!("{err:?}");
469        assert!(debug.contains("DimensionMismatch"));
470        assert!(debug.contains("128"));
471        assert!(debug.contains("256"));
472    }
473}