1use std::path::PathBuf;
2
3#[derive(Debug, thiserror::Error)]
11pub enum SearchError {
12 #[error(
15 "Embedder unavailable: {model} — {reason}. Set FRANKENSEARCH_MODEL_DIR or enable the corresponding feature flag."
16 )]
17 EmbedderUnavailable {
18 model: String,
20 reason: String,
22 },
23
24 #[error(
26 "Embedding failed for {model}: {source}. Transient error; retry or use lexical fallback when configured."
27 )]
28 EmbeddingFailed {
29 model: String,
31 #[source]
33 source: Box<dyn std::error::Error + Send + Sync>,
34 },
35
36 #[error("Model {name} not found. Run download or set FRANKENSEARCH_MODEL_DIR.")]
38 ModelNotFound {
39 name: String,
41 },
42
43 #[error("Failed to load model from {path}: {source}")]
45 ModelLoadFailed {
46 path: PathBuf,
48 #[source]
50 source: Box<dyn std::error::Error + Send + Sync>,
51 },
52
53 #[error(
56 "Vector index corrupted at {path}: {detail}. Delete and rebuild with index_documents()."
57 )]
58 IndexCorrupted {
59 path: PathBuf,
61 detail: String,
63 },
64
65 #[error(
67 "Index version mismatch at index: expected v{expected}, found v{found}. Rebuild the index."
68 )]
69 IndexVersionMismatch {
70 expected: u16,
72 found: u16,
74 },
75
76 #[error(
78 "Dimension mismatch: index has {expected}-dim vectors, query has {found}-dim. Use matching embedder."
79 )]
80 DimensionMismatch {
81 expected: usize,
83 found: usize,
85 },
86
87 #[error(
89 "Vector index not found at {path}. Run index_documents() first, or check FRANKENSEARCH_DATA_DIR."
90 )]
91 IndexNotFound {
92 path: PathBuf,
94 },
95
96 #[error("Query parse error for \"{query}\": {detail}")]
99 QueryParseError {
100 query: String,
102 detail: String,
104 },
105
106 #[error(
108 "Search timed out after {elapsed_ms}ms (budget: {budget_ms}ms). Increase timeout in TwoTierConfig."
109 )]
110 SearchTimeout {
111 elapsed_ms: u64,
113 budget_ms: u64,
115 },
116
117 #[error(
119 "Federated search required at least {required} index responses, but only {received} succeeded."
120 )]
121 FederatedInsufficientResponses {
122 required: usize,
124 received: usize,
126 },
127
128 #[error(
131 "Reranker unavailable: {model}. Results are valid without reranking; enable 'rerank' feature."
132 )]
133 RerankerUnavailable {
134 model: String,
136 },
137
138 #[error(
140 "Reranking failed for {model}: {source}. Results still valid with original RRF scores."
141 )]
142 RerankFailed {
143 model: String,
145 #[source]
147 source: Box<dyn std::error::Error + Send + Sync>,
148 },
149
150 #[error("I/O error: {0}. Check file permissions and disk space.")]
153 Io(#[from] std::io::Error),
154
155 #[error("Invalid config: {field} = \"{value}\" — {reason}")]
158 InvalidConfig {
159 field: String,
161 value: String,
163 reason: String,
165 },
166
167 #[error("Hash mismatch for {path}: expected {expected}, got {actual}. File may be corrupted.")]
170 HashMismatch {
171 path: PathBuf,
173 expected: String,
175 actual: String,
177 },
178
179 #[error("Operation cancelled during {phase}: {reason}")]
182 Cancelled {
183 phase: String,
185 reason: String,
187 },
188
189 #[error(
192 "Embedding queue full ({pending}/{capacity} pending). Apply backpressure or increase capacity."
193 )]
194 QueueFull {
195 pending: usize,
197 capacity: usize,
199 },
200
201 #[error("{subsystem} error: {source}")]
207 SubsystemError {
208 subsystem: &'static str,
210 #[source]
212 source: Box<dyn std::error::Error + Send + Sync>,
213 },
214
215 #[error(
217 "Durability feature is not enabled. Enable the 'durability' Cargo feature for self-healing indices."
218 )]
219 DurabilityDisabled,
220}
221
222pub 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 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 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}