Skip to main content

blz_cli/
error.rs

1//! CLI error handling with semantic exit codes.
2//!
3//! This module provides categorized errors that map to specific exit codes,
4//! enabling reliable error handling in shell scripts and CI pipelines.
5//!
6//! # Exit Code Categories
7//!
8//! Exit codes follow a semantic scheme where the code indicates the type of failure:
9//!
10//! | Code | Category | Description |
11//! |------|----------|-------------|
12//! | 0 | Success | Command completed successfully |
13//! | 1 | `Internal` | Unexpected/internal error |
14//! | 2 | `Usage` | Invalid arguments or configuration |
15//! | 3 | `NotFound` | Requested resource not found |
16//! | 4 | `InvalidQuery` | Query syntax or semantic error |
17//! | 5 | `Network` | Network or fetch failure |
18//! | 6 | `Timeout` | Operation timed out |
19//! | 7 | `Integrity` | Index or data corruption |
20//!
21//! # Usage
22//!
23//! ```bash
24//! # Check for specific error types
25//! blz search "query" --source missing
26//! case $? in
27//!     0) echo "Success" ;;
28//!     3) echo "Source not found" ;;
29//!     *) echo "Other error" ;;
30//! esac
31//! ```
32//!
33//! # Design
34//!
35//! The error system is designed to:
36//! - Provide meaningful exit codes for automation
37//! - Preserve error context and chains (via `anyhow`)
38//! - Support both machine-readable codes and human-readable messages
39//! - Be backward compatible (errors still work as regular `anyhow::Error`)
40
41use std::fmt;
42use std::process::ExitCode;
43
44/// Semantic error category determining the exit code.
45///
46/// Each category maps to a specific exit code for reliable error handling
47/// in shell scripts and CI pipelines.
48#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
49#[repr(u8)]
50pub enum ErrorCategory {
51    /// Unexpected or internal error (exit code 1).
52    ///
53    /// Use for programming errors, assertion failures, or errors that
54    /// shouldn't happen in normal operation.
55    Internal = 1,
56
57    /// Invalid arguments or configuration (exit code 2).
58    ///
59    /// Use for CLI argument validation failures, invalid flag combinations,
60    /// or configuration file errors.
61    Usage = 2,
62
63    /// Requested resource not found (exit code 3).
64    ///
65    /// Use when a source alias, file, or other named resource doesn't exist.
66    NotFound = 3,
67
68    /// Query syntax or semantic error (exit code 4).
69    ///
70    /// Use for malformed search queries or invalid query parameters.
71    InvalidQuery = 4,
72
73    /// Network or fetch failure (exit code 5).
74    ///
75    /// Use for HTTP errors, DNS failures, or connection timeouts
76    /// when fetching remote content.
77    Network = 5,
78
79    /// Operation timed out (exit code 6).
80    ///
81    /// Use when an operation exceeds its time limit.
82    Timeout = 6,
83
84    /// Index or data corruption (exit code 7).
85    ///
86    /// Use when local data is corrupted, inconsistent, or unreadable.
87    Integrity = 7,
88}
89
90impl ErrorCategory {
91    /// Get the exit code for this category.
92    #[must_use]
93    pub const fn exit_code(self) -> u8 {
94        self as u8
95    }
96
97    /// Create an `ExitCode` from this category.
98    #[must_use]
99    pub fn as_exit_code(self) -> ExitCode {
100        ExitCode::from(self.exit_code())
101    }
102
103    /// Get a short description of this error category.
104    #[must_use]
105    pub const fn description(self) -> &'static str {
106        match self {
107            Self::Internal => "internal error",
108            Self::Usage => "usage error",
109            Self::NotFound => "not found",
110            Self::InvalidQuery => "invalid query",
111            Self::Network => "network error",
112            Self::Timeout => "timeout",
113            Self::Integrity => "integrity error",
114        }
115    }
116
117    /// Infer the error category from an error message.
118    ///
119    /// This provides a heuristic-based fallback when errors aren't explicitly
120    /// categorized. It examines the error message for common patterns.
121    #[must_use]
122    pub fn infer_from_message(msg: &str) -> Self {
123        let msg_lower = msg.to_lowercase();
124
125        // Timeout errors (check before Network so "connection timeout" is categorized correctly)
126        if msg_lower.contains("timeout") || msg_lower.contains("timed out") {
127            return Self::Timeout;
128        }
129
130        // Network errors
131        if msg_lower.contains("network")
132            || msg_lower.contains("connection")
133            || msg_lower.contains("dns")
134            || msg_lower.contains("http")
135            || msg_lower.contains("fetch")
136            || msg_lower.contains("unreachable")
137        {
138            return Self::Network;
139        }
140
141        // Not found errors
142        if msg_lower.contains("not found")
143            || msg_lower.contains("no such")
144            || msg_lower.contains("does not exist")
145            || msg_lower.contains("unknown source")
146            || msg_lower.contains("source not found")
147        {
148            return Self::NotFound;
149        }
150
151        // Query errors
152        if msg_lower.contains("query")
153            || msg_lower.contains("invalid search")
154            || msg_lower.contains("parse error")
155        {
156            return Self::InvalidQuery;
157        }
158
159        // Integrity errors
160        if msg_lower.contains("corrupt")
161            || msg_lower.contains("integrity")
162            || msg_lower.contains("invalid index")
163            || msg_lower.contains("checksum")
164        {
165            return Self::Integrity;
166        }
167
168        // Usage errors
169        if msg_lower.contains("invalid argument")
170            || msg_lower.contains("missing required")
171            || msg_lower.contains("invalid value")
172            || msg_lower.contains("cannot use")
173        {
174            return Self::Usage;
175        }
176
177        // Default to internal for unknown errors
178        Self::Internal
179    }
180}
181
182impl fmt::Display for ErrorCategory {
183    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184        write!(f, "{}", self.description())
185    }
186}
187
188/// A CLI error with a semantic category for exit code mapping.
189///
190/// Wraps an `anyhow::Error` with an `ErrorCategory` to enable proper
191/// exit codes while preserving full error context and chains.
192///
193/// # Creating Categorized Errors
194///
195/// ```rust,ignore
196/// use blz_cli::error::{CliError, ErrorCategory};
197/// use anyhow::anyhow;
198///
199/// // Explicit category
200/// let err = CliError::new(
201///     ErrorCategory::NotFound,
202///     anyhow!("Source 'react' not found"),
203/// );
204///
205/// // Using convenience constructors
206/// let err = CliError::not_found("Source 'react' not found");
207/// let err = CliError::usage("Invalid flag combination");
208/// ```
209#[derive(Debug)]
210pub struct CliError {
211    /// The semantic category of this error.
212    pub category: ErrorCategory,
213    /// The underlying error with full context.
214    pub source: anyhow::Error,
215}
216
217impl CliError {
218    /// Create a new CLI error with explicit category.
219    pub fn new(category: ErrorCategory, source: impl Into<anyhow::Error>) -> Self {
220        Self {
221            category,
222            source: source.into(),
223        }
224    }
225
226    /// Create a CLI error, inferring the category from the error message.
227    pub fn inferred(source: impl Into<anyhow::Error>) -> Self {
228        let source = source.into();
229        let category = ErrorCategory::infer_from_message(&source.to_string());
230        Self { category, source }
231    }
232
233    /// Create an internal error.
234    pub fn internal(source: impl Into<anyhow::Error>) -> Self {
235        Self::new(ErrorCategory::Internal, source)
236    }
237
238    /// Create a usage error.
239    pub fn usage(source: impl Into<anyhow::Error>) -> Self {
240        Self::new(ErrorCategory::Usage, source)
241    }
242
243    /// Create a not-found error.
244    pub fn not_found(source: impl Into<anyhow::Error>) -> Self {
245        Self::new(ErrorCategory::NotFound, source)
246    }
247
248    /// Create an invalid-query error.
249    pub fn invalid_query(source: impl Into<anyhow::Error>) -> Self {
250        Self::new(ErrorCategory::InvalidQuery, source)
251    }
252
253    /// Create a network error.
254    pub fn network(source: impl Into<anyhow::Error>) -> Self {
255        Self::new(ErrorCategory::Network, source)
256    }
257
258    /// Create a timeout error.
259    pub fn timeout(source: impl Into<anyhow::Error>) -> Self {
260        Self::new(ErrorCategory::Timeout, source)
261    }
262
263    /// Create an integrity error.
264    pub fn integrity(source: impl Into<anyhow::Error>) -> Self {
265        Self::new(ErrorCategory::Integrity, source)
266    }
267
268    /// Get the exit code for this error.
269    #[must_use]
270    pub const fn exit_code(&self) -> u8 {
271        self.category.exit_code()
272    }
273
274    /// Create an `ExitCode` from this error.
275    #[must_use]
276    pub fn as_exit_code(&self) -> ExitCode {
277        self.category.as_exit_code()
278    }
279}
280
281impl fmt::Display for CliError {
282    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283        write!(f, "{}", self.source)
284    }
285}
286
287impl std::error::Error for CliError {
288    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
289        Some(self.source.as_ref())
290    }
291}
292
293/// Extension trait for converting errors to `CliError` with category inference.
294pub trait IntoCliError {
295    /// Convert to a `CliError`, inferring the category from the error message.
296    fn into_cli_error(self) -> CliError;
297
298    /// Convert to a `CliError` with an explicit category.
299    fn with_category(self, category: ErrorCategory) -> CliError;
300}
301
302impl<E: Into<anyhow::Error>> IntoCliError for E {
303    fn into_cli_error(self) -> CliError {
304        CliError::inferred(self)
305    }
306
307    fn with_category(self, category: ErrorCategory) -> CliError {
308        CliError::new(category, self)
309    }
310}
311
312/// Determine the exit code from an `anyhow::Error`.
313///
314/// If the error is a `CliError`, returns its category's exit code.
315/// Otherwise, infers the category from the error message.
316#[must_use]
317pub fn exit_code_from_error(err: &anyhow::Error) -> u8 {
318    // Check if this is already a CliError
319    if let Some(cli_err) = err.downcast_ref::<CliError>() {
320        return cli_err.exit_code();
321    }
322
323    // Infer from the error message
324    ErrorCategory::infer_from_message(&err.to_string()).exit_code()
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use anyhow::anyhow;
331
332    mod error_category {
333        use super::*;
334
335        #[test]
336        fn test_exit_codes() {
337            assert_eq!(ErrorCategory::Internal.exit_code(), 1);
338            assert_eq!(ErrorCategory::Usage.exit_code(), 2);
339            assert_eq!(ErrorCategory::NotFound.exit_code(), 3);
340            assert_eq!(ErrorCategory::InvalidQuery.exit_code(), 4);
341            assert_eq!(ErrorCategory::Network.exit_code(), 5);
342            assert_eq!(ErrorCategory::Timeout.exit_code(), 6);
343            assert_eq!(ErrorCategory::Integrity.exit_code(), 7);
344        }
345
346        #[test]
347        fn test_infer_network() {
348            assert_eq!(
349                ErrorCategory::infer_from_message("Connection refused"),
350                ErrorCategory::Network
351            );
352            assert_eq!(
353                ErrorCategory::infer_from_message("HTTP 500 error"),
354                ErrorCategory::Network
355            );
356            assert_eq!(
357                ErrorCategory::infer_from_message("Failed to fetch URL"),
358                ErrorCategory::Network
359            );
360        }
361
362        #[test]
363        fn test_infer_timeout() {
364            assert_eq!(
365                ErrorCategory::infer_from_message("Operation timed out"),
366                ErrorCategory::Timeout
367            );
368            assert_eq!(
369                ErrorCategory::infer_from_message("Request timeout after 30s"),
370                ErrorCategory::Timeout
371            );
372        }
373
374        #[test]
375        fn test_infer_not_found() {
376            assert_eq!(
377                ErrorCategory::infer_from_message("Source not found: react"),
378                ErrorCategory::NotFound
379            );
380            assert_eq!(
381                ErrorCategory::infer_from_message("No such file or directory"),
382                ErrorCategory::NotFound
383            );
384            assert_eq!(
385                ErrorCategory::infer_from_message("Unknown source 'test'"),
386                ErrorCategory::NotFound
387            );
388        }
389
390        #[test]
391        fn test_infer_query() {
392            assert_eq!(
393                ErrorCategory::infer_from_message("Invalid query syntax"),
394                ErrorCategory::InvalidQuery
395            );
396            assert_eq!(
397                ErrorCategory::infer_from_message("Query parse error at position 5"),
398                ErrorCategory::InvalidQuery
399            );
400        }
401
402        #[test]
403        fn test_infer_integrity() {
404            assert_eq!(
405                ErrorCategory::infer_from_message("Index corrupted"),
406                ErrorCategory::Integrity
407            );
408            assert_eq!(
409                ErrorCategory::infer_from_message("Checksum mismatch"),
410                ErrorCategory::Integrity
411            );
412        }
413
414        #[test]
415        fn test_infer_usage() {
416            assert_eq!(
417                ErrorCategory::infer_from_message("Invalid argument: --foo"),
418                ErrorCategory::Usage
419            );
420            assert_eq!(
421                ErrorCategory::infer_from_message("Missing required field"),
422                ErrorCategory::Usage
423            );
424        }
425
426        #[test]
427        fn test_infer_default() {
428            assert_eq!(
429                ErrorCategory::infer_from_message("Something went wrong"),
430                ErrorCategory::Internal
431            );
432        }
433    }
434
435    mod cli_error {
436        use super::*;
437
438        #[test]
439        fn test_new() {
440            let err = CliError::new(ErrorCategory::NotFound, anyhow!("Source not found"));
441            assert_eq!(err.category, ErrorCategory::NotFound);
442            assert_eq!(err.exit_code(), 3);
443        }
444
445        #[test]
446        fn test_inferred() {
447            let err = CliError::inferred(anyhow!("Connection refused"));
448            assert_eq!(err.category, ErrorCategory::Network);
449        }
450
451        #[test]
452        fn test_convenience_constructors() {
453            assert_eq!(
454                CliError::internal(anyhow!("err")).category,
455                ErrorCategory::Internal
456            );
457            assert_eq!(
458                CliError::usage(anyhow!("err")).category,
459                ErrorCategory::Usage
460            );
461            assert_eq!(
462                CliError::not_found(anyhow!("err")).category,
463                ErrorCategory::NotFound
464            );
465            assert_eq!(
466                CliError::invalid_query(anyhow!("err")).category,
467                ErrorCategory::InvalidQuery
468            );
469            assert_eq!(
470                CliError::network(anyhow!("err")).category,
471                ErrorCategory::Network
472            );
473            assert_eq!(
474                CliError::timeout(anyhow!("err")).category,
475                ErrorCategory::Timeout
476            );
477            assert_eq!(
478                CliError::integrity(anyhow!("err")).category,
479                ErrorCategory::Integrity
480            );
481        }
482
483        #[test]
484        fn test_display() {
485            let err = CliError::not_found(anyhow!("Source 'react' not found"));
486            assert_eq!(err.to_string(), "Source 'react' not found");
487        }
488    }
489
490    mod exit_code_from_error {
491        use super::*;
492
493        #[test]
494        fn test_cli_error() {
495            let cli_err = CliError::not_found(anyhow!("Not found"));
496            let err: anyhow::Error = cli_err.into();
497            assert_eq!(exit_code_from_error(&err), 3);
498        }
499
500        #[test]
501        fn test_regular_error() {
502            // Use "Operation timed out" instead of "Connection timeout"
503            // because the latter contains "connection" which matches Network first
504            let err = anyhow!("Operation timed out");
505            assert_eq!(exit_code_from_error(&err), 6);
506        }
507    }
508
509    mod into_cli_error {
510        use super::*;
511
512        #[test]
513        fn test_into_cli_error() {
514            let err = anyhow!("Source not found").into_cli_error();
515            assert_eq!(err.category, ErrorCategory::NotFound);
516        }
517
518        #[test]
519        fn test_with_category() {
520            let err = anyhow!("Something failed").with_category(ErrorCategory::Timeout);
521            assert_eq!(err.category, ErrorCategory::Timeout);
522        }
523    }
524}