sqry_nl/error.rs
1//! Error types for the sqry-nl crate.
2//!
3//! Uses `thiserror` for ergonomic error handling with automatic
4//! `std::error::Error` implementation.
5
6use thiserror::Error;
7
8/// Result type alias for sqry-nl operations.
9pub type NlResult<T> = Result<T, NlError>;
10
11/// Top-level error type for sqry-nl operations.
12#[derive(Error, Debug)]
13pub enum NlError {
14 /// Preprocessing failed (Unicode normalization, input validation)
15 #[error("Preprocessing failed: {0}")]
16 Preprocess(#[from] PreprocessError),
17
18 /// Entity extraction failed
19 #[error("Entity extraction failed: {0}")]
20 Extractor(#[from] ExtractorError),
21
22 /// Intent classification failed
23 #[error("Classification failed: {0}")]
24 Classifier(ClassifierError),
25
26 /// Command assembly failed
27 #[error("Assembly failed: {0}")]
28 Assembler(#[from] AssemblerError),
29
30 /// Validation failed (safety checks)
31 #[error("Validation failed: {0}")]
32 Validator(#[from] ValidatorError),
33
34 /// Cache operation failed
35 #[error("Cache error: {0}")]
36 Cache(#[from] CacheError),
37
38 /// Configuration error
39 #[error("Configuration error: {0}")]
40 Config(String),
41
42 /// I/O error
43 #[error("I/O error: {0}")]
44 Io(#[from] std::io::Error),
45
46 /// Resolved model directory does not exist on disk.
47 #[error("Model directory not found: {0}")]
48 ModelDirNotFound(String),
49
50 /// Checksum of an on-disk model file does not match the manifest.
51 #[error("Model file checksum mismatch for {file}: expected {expected}, got {actual}")]
52 ChecksumMismatch {
53 file: String,
54 expected: String,
55 actual: String,
56 },
57
58 /// `checksums.json` (or equivalent integrity manifest) is absent from the model directory.
59 #[error("Model checksums file is missing from model directory")]
60 ChecksumsMissing,
61
62 /// A file referenced by the integrity manifest is missing from the model directory.
63 #[error("File listed in checksums is missing from model directory: {0}")]
64 ChecksummedFileMissing(String),
65
66 /// ONNX Runtime shared library could not be loaded at runtime.
67 #[error("ONNX Runtime is not available: {hint}")]
68 OnnxRuntimeMissing {
69 /// Operator-facing remediation hint (populated in NL08).
70 hint: String,
71 },
72
73 /// Top-level manifest SHA-256 does not match the expected pinned value.
74 ///
75 /// Raised by NL03's downloader when the streaming SHA-256 computed over
76 /// the freshly downloaded archive bytes does not match the
77 /// `manifest.json.sha256` value baked into the binary. Always fatal —
78 /// there is no `--allow-unverified-model` opt-out for tampering on a
79 /// trusted-mode payload.
80 #[error("Model manifest SHA-256 mismatch for {file}: expected {expected}, got {actual}")]
81 ManifestSha256Mismatch {
82 /// The archive file name from the manifest (e.g.
83 /// `sqry-models-v1.0.0.tar.gz`).
84 file: String,
85 /// SHA-256 hex from the trusted baked-in manifest.
86 expected: String,
87 /// SHA-256 hex computed over the on-the-wire bytes.
88 actual: String,
89 },
90
91 /// Model manifest could not be parsed as JSON.
92 #[error("Model manifest parse failed: {0}")]
93 ManifestParseFailed(#[from] serde_json::Error),
94
95 /// Network download was attempted but `allow_model_download` is `false`.
96 #[error("Model download is disabled by configuration")]
97 DownloadDisabled,
98
99 /// Network download failed (transport, HTTP status, or post-fetch I/O).
100 #[error("Model download failed: {0}")]
101 DownloadFailed(String),
102}
103
104// Manual `From<ClassifierError>` impl: replaces the previous `#[from]`
105// derive on `NlError::Classifier`. The special case here promotes
106// `ClassifierError::OnnxRuntimeMissing { hint }` to the top-level
107// `NlError::OnnxRuntimeMissing { hint }` variant so every consumer
108// (CLI, MCP, LSP, daemon) can pattern-match on a single, stable
109// wire-facing variant instead of having to dig into nested
110// classifier-specific error structures.
111//
112// All other `ClassifierError` variants pass through unchanged into
113// `NlError::Classifier(_)`. This preserves the `?` ergonomics that
114// the previous `#[from]` derive provided.
115impl From<ClassifierError> for NlError {
116 fn from(err: ClassifierError) -> Self {
117 match err {
118 ClassifierError::OnnxRuntimeMissing { hint } => NlError::OnnxRuntimeMissing { hint },
119 other => NlError::Classifier(other),
120 }
121 }
122}
123
124/// Errors from the preprocessing stage.
125#[derive(Error, Debug, Clone, PartialEq, Eq)]
126pub enum PreprocessError {
127 /// Input exceeds maximum length
128 #[error("Input too long: {len} bytes (max: {max})")]
129 InputTooLong { len: usize, max: usize },
130
131 /// Input contains only whitespace or is empty
132 #[error("Input is empty or contains only whitespace")]
133 EmptyInput,
134
135 /// Homoglyph attack detected
136 #[error("Suspicious character detected: possible homoglyph attack")]
137 HomoglyphDetected,
138
139 /// Invalid UTF-8 encoding
140 #[error("Invalid UTF-8 encoding")]
141 InvalidUtf8,
142}
143
144/// Errors from the entity extraction stage.
145#[derive(Error, Debug, Clone, PartialEq, Eq)]
146pub enum ExtractorError {
147 /// No symbols found in input
148 #[error("No symbol or pattern found in query")]
149 NoSymbolFound,
150
151 /// Ambiguous symbol reference
152 #[error("Ambiguous symbol reference: multiple interpretations possible")]
153 AmbiguousSymbol,
154
155 /// Invalid language specified
156 #[error("Unknown language: {0}")]
157 UnknownLanguage(String),
158
159 /// Invalid symbol kind specified
160 #[error("Unknown symbol kind: {0}")]
161 UnknownKind(String),
162
163 /// Regex compilation error
164 #[error("Pattern compilation failed: {0}")]
165 RegexError(String),
166}
167
168/// Errors from the intent classification stage.
169#[derive(Error, Debug)]
170pub enum ClassifierError {
171 /// Model file not found
172 #[error("Model not found at: {0}")]
173 ModelNotFound(String),
174
175 /// Model checksum mismatch on a present file (tampering).
176 ///
177 /// NL04: ALWAYS fatal regardless of `allow_unverified`. Mirrors the
178 /// shape of [`NlError::ChecksumMismatch`] so the boundary between
179 /// the classifier-internal error and the top-level NL error stays
180 /// clean.
181 #[error("Model checksum mismatch for {file}: expected {expected}, got {actual}")]
182 ChecksumMismatch {
183 file: String,
184 expected: String,
185 actual: String,
186 },
187
188 /// `checksums.json` is absent from the model directory (strict mode).
189 #[error("Model checksums file is missing from model directory")]
190 ChecksumsMissing,
191
192 /// A file referenced by the integrity manifest is missing from disk
193 /// (strict mode).
194 #[error("File listed in checksums is missing from model directory: {0}")]
195 ChecksummedFileMissing(String),
196
197 /// Tokenization failed
198 #[error("Tokenization failed: {0}")]
199 TokenizationFailed(String),
200
201 /// ONNX Runtime error
202 #[error("ONNX Runtime error: {0}")]
203 OnnxError(String),
204
205 /// Custom-mode local manifest cannot anchor `checksums.json`.
206 ///
207 /// Raised when a custom model directory's `manifest.json` is
208 /// missing, malformed, or lacks `files["checksums.json"]`. This is
209 /// distinct from missing `checksums.json` itself: the operator
210 /// escape hatch can downgrade missing checksums, but it must not
211 /// silently remove the custom-mode manifest trust anchor.
212 #[error("Model manifest integrity anchor invalid: {0}")]
213 ManifestAnchorInvalid(String),
214
215 /// ONNX Runtime shared library could not be loaded.
216 ///
217 /// Raised by [`crate::classifier::IntentClassifier::load`] when the
218 /// `ort` crate's `Session::builder()` chain fails (or panics) due to
219 /// `libonnxruntime` being absent on the host. The `hint` field
220 /// carries a platform-aware remediation string (apt / brew / .dll
221 /// download URL) baked at compile time. NL08 surfaces this variant
222 /// across CLI / MCP / LSP / daemon as an actionable diagnostic
223 /// rather than the opaque `OnnxError(...)` string the lower-level
224 /// crate would otherwise produce.
225 ///
226 /// Always converted to [`NlError::OnnxRuntimeMissing`] at the
227 /// `From<ClassifierError>` boundary — the top-level error variant
228 /// is the wire-facing name.
229 #[error("ONNX Runtime is not available: {hint}")]
230 OnnxRuntimeMissing {
231 /// Operator-facing remediation hint (platform-specific install
232 /// instructions). Populated by
233 /// [`crate::classifier::model::onnx_runtime_install_hint`].
234 hint: String,
235 },
236
237 /// Model version incompatible
238 #[error("Model version {model_version} incompatible with sqry-nl {crate_version}")]
239 VersionMismatch {
240 model_version: String,
241 crate_version: String,
242 },
243
244 /// Inference timeout
245 #[error("Classification timed out after {timeout_ms}ms")]
246 Timeout { timeout_ms: u64 },
247}
248
249/// Errors from the command assembly stage.
250#[derive(Error, Debug, Clone, PartialEq, Eq)]
251pub enum AssemblerError {
252 /// Required symbol not provided
253 #[error("Missing required symbol for this command type")]
254 MissingSymbol,
255
256 /// Missing from/to symbols for trace-path
257 #[error("Trace-path requires both 'from' and 'to' symbols")]
258 MissingTracePath,
259
260 /// Intent is ambiguous and cannot be assembled
261 #[error("Cannot assemble command: intent is ambiguous")]
262 AmbiguousIntent,
263
264 /// Generated command exceeds length limit
265 #[error("Generated command too long: {len} chars (max: {max})")]
266 CommandTooLong { len: usize, max: usize },
267
268 /// Template not found for intent
269 #[error("No template found for intent: {0}")]
270 NoTemplate(String),
271}
272
273/// Errors from the validation stage.
274#[derive(Error, Debug, Clone, PartialEq, Eq)]
275pub enum ValidatorError {
276 /// Command doesn't match any allowed template
277 #[error("Command rejected: doesn't match any allowed template")]
278 TemplateMismatch,
279
280 /// Dangerous shell metacharacters detected
281 #[error("Command rejected: contains shell metacharacters")]
282 MetacharDetected,
283
284 /// Environment variable expansion detected
285 #[error("Command rejected: contains environment variable")]
286 EnvVarDetected,
287
288 /// Path traversal attempt detected
289 #[error("Command rejected: path traversal detected")]
290 PathTraversal,
291
292 /// Absolute path detected
293 #[error("Command rejected: absolute paths not allowed")]
294 AbsolutePath,
295
296 /// Write-mode operation detected
297 #[error("Command rejected: write operations not allowed via NL")]
298 WriteOperation,
299
300 /// Command too long
301 #[error("Command rejected: exceeds maximum length")]
302 CommandTooLong,
303}
304
305/// Errors from the cache operations.
306#[derive(Error, Debug, Clone, PartialEq, Eq)]
307pub enum CacheError {
308 /// Cache is disabled
309 #[error("Cache is disabled")]
310 Disabled,
311
312 /// Cache entry expired
313 #[error("Cache entry has expired")]
314 Expired,
315
316 /// Cache key generation failed
317 #[error("Failed to generate cache key: {0}")]
318 KeyGenerationFailed(String),
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324
325 #[test]
326 fn test_error_display() {
327 let err = PreprocessError::InputTooLong {
328 len: 5000,
329 max: 4096,
330 };
331 assert!(err.to_string().contains("5000"));
332 assert!(err.to_string().contains("4096"));
333 }
334
335 #[test]
336 fn test_error_conversion() {
337 let preprocess_err = PreprocessError::EmptyInput;
338 let nl_err: NlError = preprocess_err.into();
339 assert!(matches!(nl_err, NlError::Preprocess(_)));
340 }
341
342 #[test]
343 fn test_errors_implement_std_error() {
344 fn assert_error<T: std::error::Error>() {}
345
346 assert_error::<NlError>();
347 assert_error::<PreprocessError>();
348 assert_error::<ExtractorError>();
349 assert_error::<ClassifierError>();
350 assert_error::<AssemblerError>();
351 assert_error::<ValidatorError>();
352 assert_error::<CacheError>();
353 }
354}