Skip to main content

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}