use std::path::PathBuf;
#[derive(Debug, thiserror::Error)]
pub enum SearchError {
#[error(
"Embedder unavailable: {model} — {reason}. Set FRANKENSEARCH_MODEL_DIR or enable the corresponding feature flag."
)]
EmbedderUnavailable {
model: String,
reason: String,
},
#[error(
"Embedding failed for {model}: {source}. Transient error; retry or use lexical fallback when configured."
)]
EmbeddingFailed {
model: String,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("Model {name} not found. Run download or set FRANKENSEARCH_MODEL_DIR.")]
ModelNotFound {
name: String,
},
#[error("Failed to load model from {path}: {source}")]
ModelLoadFailed {
path: PathBuf,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error(
"Vector index corrupted at {path}: {detail}. Delete and rebuild with index_documents()."
)]
IndexCorrupted {
path: PathBuf,
detail: String,
},
#[error(
"Index version mismatch at index: expected v{expected}, found v{found}. Rebuild the index."
)]
IndexVersionMismatch {
expected: u16,
found: u16,
},
#[error(
"Dimension mismatch: index has {expected}-dim vectors, query has {found}-dim. Use matching embedder."
)]
DimensionMismatch {
expected: usize,
found: usize,
},
#[error(
"Vector index not found at {path}. Run index_documents() first, or check FRANKENSEARCH_DATA_DIR."
)]
IndexNotFound {
path: PathBuf,
},
#[error("Query parse error for \"{query}\": {detail}")]
QueryParseError {
query: String,
detail: String,
},
#[error(
"Search timed out after {elapsed_ms}ms (budget: {budget_ms}ms). Increase timeout in TwoTierConfig."
)]
SearchTimeout {
elapsed_ms: u64,
budget_ms: u64,
},
#[error(
"Federated search required at least {required} index responses, but only {received} succeeded."
)]
FederatedInsufficientResponses {
required: usize,
received: usize,
},
#[error(
"Reranker unavailable: {model}. Results are valid without reranking; enable 'rerank' feature."
)]
RerankerUnavailable {
model: String,
},
#[error(
"Reranking failed for {model}: {source}. Results still valid with original RRF scores."
)]
RerankFailed {
model: String,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("I/O error: {0}. Check file permissions and disk space.")]
Io(#[from] std::io::Error),
#[error("Invalid config: {field} = \"{value}\" — {reason}")]
InvalidConfig {
field: String,
value: String,
reason: String,
},
#[error("Hash mismatch for {path}: expected {expected}, got {actual}. File may be corrupted.")]
HashMismatch {
path: PathBuf,
expected: String,
actual: String,
},
#[error("Operation cancelled during {phase}: {reason}")]
Cancelled {
phase: String,
reason: String,
},
#[error(
"Embedding queue full ({pending}/{capacity} pending). Apply backpressure or increase capacity."
)]
QueueFull {
pending: usize,
capacity: usize,
},
#[error("{subsystem} error: {source}")]
SubsystemError {
subsystem: &'static str,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error(
"Durability feature is not enabled. Enable the 'durability' Cargo feature for self-healing indices."
)]
DurabilityDisabled,
}
pub type SearchResult<T> = Result<T, SearchError>;
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error as _;
#[test]
fn error_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<SearchError>();
}
#[test]
fn io_error_conversion() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
let search_err: SearchError = io_err.into();
assert!(matches!(search_err, SearchError::Io(_)));
assert!(search_err.to_string().contains("gone"));
}
#[test]
fn display_messages_are_actionable() {
let err = SearchError::IndexNotFound {
path: PathBuf::from("/tmp/missing.fsvi"),
};
let msg = err.to_string();
assert!(msg.contains("index_documents()"), "should suggest recovery");
let err = SearchError::DimensionMismatch {
expected: 256,
found: 384,
};
let msg = err.to_string();
assert!(msg.contains("256"));
assert!(msg.contains("384"));
}
#[test]
fn federated_insufficient_responses_message_has_counts() {
let err = SearchError::FederatedInsufficientResponses {
required: 2,
received: 1,
};
let msg = err.to_string();
assert!(msg.contains('2'));
assert!(msg.contains('1'));
}
#[test]
fn subsystem_error_wraps_arbitrary_errors() {
let inner = std::io::Error::other("db locked");
let err = SearchError::SubsystemError {
subsystem: "storage",
source: Box::new(inner),
};
assert!(err.to_string().contains("storage"));
assert!(err.to_string().contains("db locked"));
}
#[test]
fn search_result_alias_works() {
let ok: SearchResult<u32> = Ok(42);
assert!(ok.is_ok());
let err: SearchResult<u32> = Err(SearchError::DurabilityDisabled);
assert!(err.is_err());
}
#[test]
fn embedding_failed_preserves_source() {
let inner = std::io::Error::other("onnx crash");
let err = SearchError::EmbeddingFailed {
model: "MiniLM".into(),
source: Box::new(inner),
};
assert!(err.to_string().contains("MiniLM"));
assert!(err.to_string().contains("onnx crash"));
}
#[test]
fn cancelled_variant() {
let err = SearchError::Cancelled {
phase: "quality_embed".into(),
reason: "parent scope dropped".into(),
};
assert!(err.to_string().contains("quality_embed"));
assert!(err.to_string().contains("parent scope dropped"));
}
#[test]
fn embedder_unavailable_display() {
let err = SearchError::EmbedderUnavailable {
model: "MiniLM".into(),
reason: "feature not enabled".into(),
};
let msg = err.to_string();
assert!(msg.contains("MiniLM"));
assert!(msg.contains("feature not enabled"));
}
#[test]
fn model_not_found_display() {
let err = SearchError::ModelNotFound {
name: "all-MiniLM-L6-v2".into(),
};
let msg = err.to_string();
assert!(msg.contains("all-MiniLM-L6-v2"));
assert!(msg.contains("FRANKENSEARCH_MODEL_DIR"));
}
#[test]
fn model_load_failed_preserves_source() {
let inner = std::io::Error::other("mmap failed");
let err = SearchError::ModelLoadFailed {
path: PathBuf::from("/models/broken.onnx"),
source: Box::new(inner),
};
let msg = err.to_string();
assert!(msg.contains("/models/broken.onnx"));
assert!(msg.contains("mmap failed"));
assert!(err.source().is_some());
}
#[test]
fn index_corrupted_display() {
let err = SearchError::IndexCorrupted {
path: PathBuf::from("/data/index.fsvi"),
detail: "CRC mismatch in header".into(),
};
let msg = err.to_string();
assert!(msg.contains("/data/index.fsvi"));
assert!(msg.contains("CRC mismatch"));
assert!(msg.contains("rebuild"));
}
#[test]
fn index_version_mismatch_display() {
let err = SearchError::IndexVersionMismatch {
expected: 3,
found: 1,
};
let msg = err.to_string();
assert!(msg.contains("v3"));
assert!(msg.contains("v1"));
assert!(msg.contains("Rebuild"));
}
#[test]
fn query_parse_error_display() {
let err = SearchError::QueryParseError {
query: "foo AND OR bar".into(),
detail: "unexpected OR after AND".into(),
};
let msg = err.to_string();
assert!(msg.contains("foo AND OR bar"));
assert!(msg.contains("unexpected OR after AND"));
}
#[test]
fn search_timeout_display() {
let err = SearchError::SearchTimeout {
elapsed_ms: 750,
budget_ms: 500,
};
let msg = err.to_string();
assert!(msg.contains("750"));
assert!(msg.contains("500"));
}
#[test]
fn reranker_unavailable_display() {
let err = SearchError::RerankerUnavailable {
model: "cross-encoder".into(),
};
let msg = err.to_string();
assert!(msg.contains("cross-encoder"));
assert!(msg.contains("rerank"));
}
#[test]
fn rerank_failed_preserves_source() {
let inner = std::io::Error::other("inference oom");
let err = SearchError::RerankFailed {
model: "cross-encoder".into(),
source: Box::new(inner),
};
let msg = err.to_string();
assert!(msg.contains("cross-encoder"));
assert!(msg.contains("inference oom"));
assert!(err.source().is_some());
}
#[test]
fn invalid_config_display() {
let err = SearchError::InvalidConfig {
field: "quality_weight".into(),
value: "-1.0".into(),
reason: "must be between 0.0 and 1.0".into(),
};
let msg = err.to_string();
assert!(msg.contains("quality_weight"));
assert!(msg.contains("-1.0"));
assert!(msg.contains("must be between"));
}
#[test]
fn hash_mismatch_display() {
let err = SearchError::HashMismatch {
path: PathBuf::from("/tmp/model.bin"),
expected: "abc123".into(),
actual: "def456".into(),
};
let msg = err.to_string();
assert!(msg.contains("/tmp/model.bin"));
assert!(msg.contains("abc123"));
assert!(msg.contains("def456"));
}
#[test]
fn queue_full_display() {
let err = SearchError::QueueFull {
pending: 100,
capacity: 100,
};
let msg = err.to_string();
assert!(msg.contains("100"));
assert!(msg.contains("backpressure"));
}
#[test]
fn durability_disabled_display() {
let err = SearchError::DurabilityDisabled;
let msg = err.to_string();
assert!(msg.contains("durability"));
}
#[test]
fn error_debug_format() {
let err = SearchError::DimensionMismatch {
expected: 128,
found: 256,
};
let debug = format!("{err:?}");
assert!(debug.contains("DimensionMismatch"));
assert!(debug.contains("128"));
assert!(debug.contains("256"));
}
}