Skip to main content

cqlite_cli/
error.rs

1//! Error handling and exit code management for CQLite CLI
2//!
3//! This module defines standardized exit codes as specified in M2_CLI_SPEC.md
4//! and provides utilities for classifying errors and printing helpful messages.
5
6/// CLI exit codes as specified in M2_CLI_SPEC.md line 340
7///
8/// Exit codes:
9/// - 0: success
10/// - 2: invalid CLI args
11/// - 3: schema errors
12/// - 4: data-dir/discovery errors
13/// - 5: query execution errors
14/// - 6: write operation errors (Issue #392)
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16#[repr(i32)]
17#[allow(dead_code)] // Success variant used for completeness per spec
18pub enum CliExitCode {
19    /// Successful execution
20    Success = 0,
21    /// Invalid CLI arguments or flags
22    InvalidCliArgs = 2,
23    /// Schema parsing or loading errors
24    SchemaError = 3,
25    /// Data directory or discovery errors
26    DataDirError = 4,
27    /// Query execution errors
28    QueryExecutionError = 5,
29    /// Write operation errors (Issue #392)
30    WriteError = 6,
31}
32
33impl CliExitCode {
34    /// Convert exit code to i32 for process::exit
35    pub fn as_i32(self) -> i32 {
36        self as i32
37    }
38
39    /// Get helpful hint for this error category per M2_CLI_SPEC.md
40    ///
41    /// These hints follow the spec's guidance to provide concise,
42    /// actionable next-step commands.
43    pub fn hint(&self) -> &'static str {
44        match self {
45            Self::Success => "",
46            Self::InvalidCliArgs => "Run 'cqlite --help' for usage information",
47            Self::SchemaError => "Use ':schema load <file>' or '--schema <path>' to provide schema",
48            Self::DataDirError => {
49                "Use ':config data-dir <path>' or '--data-dir <path>' to set data directory"
50            }
51            Self::QueryExecutionError => "Check query syntax and ensure required data is available",
52            Self::WriteError => {
53                "Use '--writable --write-dir <path>' to enable write operations. Check mutation JSON format."
54            }
55        }
56    }
57}
58
59/// Classify an error into appropriate exit code category
60///
61/// Examines the error message and context chain to determine the most
62/// appropriate exit code per M2_CLI_SPEC.md exit code policy.
63///
64/// Classification logic (order matters - most specific first):
65/// 1. CLI argument parsing errors -> InvalidCliArgs (2)
66/// 2. Schema-related errors -> SchemaError (3)
67/// 3. Data directory/SSTable errors -> DataDirError (4)
68/// 4. Default: Query execution errors -> QueryExecutionError (5)
69///
70/// # Examples
71///
72/// ```
73/// use anyhow::anyhow;
74/// use cqlite_cli::error::{classify_error, CliExitCode};
75///
76/// let err = anyhow!("Failed to parse schema file");
77/// assert_eq!(classify_error(&err), CliExitCode::SchemaError);
78///
79/// let err = anyhow!("Data directory not found");
80/// assert_eq!(classify_error(&err), CliExitCode::DataDirError);
81/// ```
82pub fn classify_error(err: &anyhow::Error) -> CliExitCode {
83    // First, try to downcast to cqlite_core::Error for precise classification
84    if let Some(core_err) = err.downcast_ref::<cqlite_core::Error>() {
85        return match core_err {
86            // Exit code 3: Schema loading errors
87            cqlite_core::Error::Schema(_) | cqlite_core::Error::Table(_) => {
88                CliExitCode::SchemaError
89            }
90
91            // Exit code 4: Data directory / SSTable discovery errors
92            cqlite_core::Error::Io(_)
93            | cqlite_core::Error::Storage(_)
94            | cqlite_core::Error::InvalidPath(_)
95            | cqlite_core::Error::NotFound(_)
96            | cqlite_core::Error::Index(_) => CliExitCode::DataDirError,
97
98            // Exit code 5: Query execution / unsupported query failures
99            cqlite_core::Error::QueryExecution(_)
100            | cqlite_core::Error::CqlParse(_)
101            | cqlite_core::Error::UnsupportedQuery(_)
102            | cqlite_core::Error::Parse(_)
103            | cqlite_core::Error::InvalidInput(_) => CliExitCode::QueryExecutionError,
104
105            // Other errors default to query execution error
106            _ => CliExitCode::QueryExecutionError,
107        };
108    }
109
110    // Fall back to string-based classification for non-core errors
111    let err_chain = format!("{:#}", err);
112    let err_lower = err_chain.to_lowercase();
113
114    // Check for specific error patterns (order matters - most specific first)
115
116    // Issue #231: Missing --data-dir flag check (MUST be before schema check)
117    // This specific error message contains "--schema" but is really about missing --data-dir
118    if err_lower.contains("missing required flag: --data-dir") {
119        return CliExitCode::DataDirError;
120    }
121
122    // CLI argument parsing errors (from clap or manual validation)
123    if err_lower.contains("invalid argument")
124        || err_lower.contains("unexpected argument")
125        || err_lower.contains("required argument")
126        || err_chain.contains("clap::Error")
127        || err_lower.contains("usage:")
128    {
129        return CliExitCode::InvalidCliArgs;
130    }
131
132    // Schema-related errors
133    if err_lower.contains("schema")
134        || err_lower.contains("failed to parse cql")
135        || err_lower.contains("failed to parse json")
136        || err_lower.contains("missing keyspace")
137        || err_lower.contains("missing table")
138        || err_lower.contains("invalid column")
139    {
140        return CliExitCode::SchemaError;
141    }
142
143    // Data directory and SSTable discovery errors
144    if err_lower.contains("data-dir")
145        || err_lower.contains("data directory")
146        || err_lower.contains("sstable")
147        || err_lower.contains("discovery")
148        || err_lower.contains("failed to open")
149        || err_lower.contains("no such file or directory")
150        || err_lower.contains("not found")
151        || err_lower.contains("cannot read file")
152    {
153        return CliExitCode::DataDirError;
154    }
155
156    // Issue #392: Write operation errors
157    if err_lower.contains("write")
158        || err_lower.contains("mutation")
159        || err_lower.contains("memtable")
160        || err_lower.contains("wal")
161        || err_lower.contains("flush")
162        || err_lower.contains("compaction")
163        || err_lower.contains("export")
164        || err_lower.contains("writeengine")
165    {
166        return CliExitCode::WriteError;
167    }
168
169    // Default to query execution error for database operations
170    CliExitCode::QueryExecutionError
171}
172
173/// Print error with appropriate formatting and helpful hint
174///
175/// Formats error output consistently across the CLI with:
176/// - Error message with full context chain
177/// - Context-aware hint for resolution
178///
179/// # Examples
180///
181/// ```no_run
182/// use anyhow::anyhow;
183/// use cqlite_cli::error::{print_error, classify_error};
184///
185/// let err = anyhow!("Unsupported query: JOIN not supported");
186/// let exit_code = classify_error(&err);
187/// print_error(&err, exit_code);
188/// // Output:
189/// // Error: Unsupported query: JOIN not supported
190/// //
191/// // Hint: Supported SELECT features in M2:
192/// //   • SELECT with WHERE on partition/primary key
193/// //   ...
194/// ```
195pub fn print_error(err: &anyhow::Error, exit_code: CliExitCode) {
196    eprintln!("Error: {:#}", err);
197
198    let hint = get_error_hint(err, exit_code);
199    if !hint.is_empty() {
200        eprintln!("\nHint: {}", hint);
201    }
202}
203
204/// Get context-aware hint based on error details and exit code
205///
206/// Provides enhanced hints for specific error categories, particularly
207/// for unsupported query features per M2_CLI_SPEC.md
208///
209/// # Examples
210///
211/// ```no_run
212/// use anyhow::anyhow;
213/// use cqlite_cli::error::{get_error_hint, classify_error};
214///
215/// let err = anyhow!("Unsupported query: JOIN not supported");
216/// let exit_code = classify_error(&err);
217/// let hint = get_error_hint(&err, exit_code);
218/// assert!(hint.contains("Supported SELECT features"));
219/// ```
220pub fn get_error_hint(err: &anyhow::Error, exit_code: CliExitCode) -> String {
221    // For query execution errors, check if it's an unsupported query
222    if matches!(exit_code, CliExitCode::QueryExecutionError) {
223        let err_text = format!("{:#}", err).to_lowercase();
224
225        // Detect unsupported query pattern from core Error::UnsupportedQuery
226        if err_text.contains("unsupported query") || err_text.contains("not supported") {
227            return build_unsupported_query_hint();
228        }
229    }
230
231    // Fall back to standard hint from CliExitCode
232    exit_code.hint().to_string()
233}
234
235/// Build detailed hint for unsupported query features
236///
237/// Provides specific guidance on what SELECT features are supported in M2
238/// as defined in M2_CLI_SPEC.md lines 303-306, 328-330
239fn build_unsupported_query_hint() -> String {
240    let mut hint = String::new();
241    hint.push_str("Supported SELECT features in M2:\n");
242    hint.push_str("  • SELECT with WHERE on partition/primary key\n");
243    hint.push_str("  • LIMIT clause for result pagination\n");
244    hint.push_str("  • DESCRIBE/DESC for schema information\n");
245    hint.push_str("  • USE for keyspace switching\n\n");
246    hint.push_str("Examples:\n");
247    hint.push_str("  SELECT * FROM users WHERE id = ? LIMIT 10\n");
248    hint.push_str("  DESCRIBE TABLE keyspace.users\n");
249    hint.push_str("  USE my_keyspace\n\n");
250    hint.push_str("Not supported: JOIN, subqueries, advanced aggregations\n");
251    hint.push_str("See: cqlite-cli/CLI_USAGE_EXAMPLES.md");
252    hint
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use anyhow::anyhow;
259
260    #[test]
261    fn test_classify_invalid_args() {
262        let err = anyhow!("Invalid argument: --foo");
263        assert_eq!(classify_error(&err), CliExitCode::InvalidCliArgs);
264
265        let err = anyhow!("Required argument missing");
266        assert_eq!(classify_error(&err), CliExitCode::InvalidCliArgs);
267    }
268
269    #[test]
270    fn test_classify_schema_error() {
271        let err = anyhow!("Failed to parse schema file");
272        assert_eq!(classify_error(&err), CliExitCode::SchemaError);
273
274        let err = anyhow!("Missing keyspace in schema");
275        assert_eq!(classify_error(&err), CliExitCode::SchemaError);
276    }
277
278    #[test]
279    fn test_classify_data_dir_error() {
280        let err = anyhow!("Data directory not found");
281        assert_eq!(classify_error(&err), CliExitCode::DataDirError);
282
283        let err = anyhow!("Failed to open SSTable");
284        assert_eq!(classify_error(&err), CliExitCode::DataDirError);
285    }
286
287    #[test]
288    fn test_classify_missing_data_dir_flag() {
289        // Issue #231: Missing --data-dir flag should return exit code 4
290        let err = anyhow!(
291            "Missing required flag: --data-dir\n\n\
292             One-shot query execution requires both --schema and --data-dir."
293        );
294        assert_eq!(classify_error(&err), CliExitCode::DataDirError);
295        assert_eq!(classify_error(&err).as_i32(), 4);
296    }
297
298    #[test]
299    fn test_classify_query_error() {
300        let err = anyhow!("Query execution failed");
301        assert_eq!(classify_error(&err), CliExitCode::QueryExecutionError);
302
303        let err = anyhow!("Syntax error in SELECT statement");
304        assert_eq!(classify_error(&err), CliExitCode::QueryExecutionError);
305    }
306
307    #[test]
308    fn test_exit_code_hints() {
309        assert_eq!(
310            CliExitCode::InvalidCliArgs.hint(),
311            "Run 'cqlite --help' for usage information"
312        );
313        assert_eq!(
314            CliExitCode::SchemaError.hint(),
315            "Use ':schema load <file>' or '--schema <path>' to provide schema"
316        );
317        assert_eq!(
318            CliExitCode::DataDirError.hint(),
319            "Use ':config data-dir <path>' or '--data-dir <path>' to set data directory"
320        );
321        assert_eq!(
322            CliExitCode::QueryExecutionError.hint(),
323            "Check query syntax and ensure required data is available"
324        );
325        assert_eq!(CliExitCode::Success.hint(), "");
326    }
327
328    #[test]
329    fn test_exit_code_as_i32() {
330        assert_eq!(CliExitCode::Success.as_i32(), 0);
331        assert_eq!(CliExitCode::InvalidCliArgs.as_i32(), 2);
332        assert_eq!(CliExitCode::SchemaError.as_i32(), 3);
333        assert_eq!(CliExitCode::DataDirError.as_i32(), 4);
334        assert_eq!(CliExitCode::QueryExecutionError.as_i32(), 5);
335    }
336
337    #[test]
338    fn test_unsupported_query_detection() {
339        let err = anyhow!("Unsupported query: JOIN operations not supported");
340        let exit_code = classify_error(&err);
341        assert_eq!(exit_code, CliExitCode::QueryExecutionError);
342
343        let hint = get_error_hint(&err, exit_code);
344        assert!(hint.contains("Supported SELECT features"));
345        assert!(hint.contains("WHERE on partition/primary key"));
346        assert!(hint.contains("LIMIT"));
347        assert!(hint.contains("DESCRIBE"));
348    }
349
350    #[test]
351    fn test_unsupported_query_hint_format() {
352        let err = anyhow!("Query feature not supported");
353        let exit_code = CliExitCode::QueryExecutionError;
354        let hint = get_error_hint(&err, exit_code);
355
356        // Verify hint contains required sections
357        assert!(hint.contains("Supported SELECT features"));
358        assert!(hint.contains("Examples:"));
359        assert!(hint.contains("Not supported"));
360        assert!(hint.contains("CLI_USAGE_EXAMPLES.md"));
361    }
362
363    #[test]
364    fn test_regular_query_error_hint() {
365        let err = anyhow!("Query execution failed: timeout");
366        let exit_code = classify_error(&err);
367        let hint = get_error_hint(&err, exit_code);
368
369        // Should get standard hint, not unsupported query hint
370        assert_eq!(
371            hint,
372            "Check query syntax and ensure required data is available"
373        );
374    }
375
376    #[test]
377    fn test_exit_code_5_for_unsupported_queries() {
378        let err = anyhow!("Unsupported query: subquery not allowed");
379        let exit_code = classify_error(&err);
380        assert_eq!(exit_code.as_i32(), 5);
381    }
382
383    #[test]
384    fn test_classify_core_schema_errors() {
385        // Test direct cqlite_core::Error::Schema mapping
386        let core_err = cqlite_core::Error::schema("Invalid schema definition");
387        let anyhow_err = anyhow::Error::new(core_err);
388        assert_eq!(classify_error(&anyhow_err), CliExitCode::SchemaError);
389        assert_eq!(classify_error(&anyhow_err).as_i32(), 3);
390
391        // Test cqlite_core::Error::Table mapping
392        let table_err = cqlite_core::Error::Table("Table not found".to_string());
393        let anyhow_err = anyhow::Error::new(table_err);
394        assert_eq!(classify_error(&anyhow_err), CliExitCode::SchemaError);
395        assert_eq!(classify_error(&anyhow_err).as_i32(), 3);
396    }
397
398    #[test]
399    fn test_classify_core_discovery_errors() {
400        // Test cqlite_core::Error::Io mapping
401        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
402        let core_err = cqlite_core::Error::from(io_err);
403        let anyhow_err = anyhow::Error::new(core_err);
404        assert_eq!(classify_error(&anyhow_err), CliExitCode::DataDirError);
405        assert_eq!(classify_error(&anyhow_err).as_i32(), 4);
406
407        // Test cqlite_core::Error::Storage mapping
408        let storage_err = cqlite_core::Error::storage("SSTable not accessible");
409        let anyhow_err = anyhow::Error::new(storage_err);
410        assert_eq!(classify_error(&anyhow_err), CliExitCode::DataDirError);
411        assert_eq!(classify_error(&anyhow_err).as_i32(), 4);
412
413        // Test cqlite_core::Error::InvalidPath mapping
414        let path_err = cqlite_core::Error::invalid_path("/nonexistent/path");
415        let anyhow_err = anyhow::Error::new(path_err);
416        assert_eq!(classify_error(&anyhow_err), CliExitCode::DataDirError);
417        assert_eq!(classify_error(&anyhow_err).as_i32(), 4);
418
419        // Test cqlite_core::Error::NotFound mapping
420        let not_found_err = cqlite_core::Error::not_found("Resource not found");
421        let anyhow_err = anyhow::Error::new(not_found_err);
422        assert_eq!(classify_error(&anyhow_err), CliExitCode::DataDirError);
423        assert_eq!(classify_error(&anyhow_err).as_i32(), 4);
424
425        // Test cqlite_core::Error::Index mapping
426        let index_err = cqlite_core::Error::index("Index read failure");
427        let anyhow_err = anyhow::Error::new(index_err);
428        assert_eq!(classify_error(&anyhow_err), CliExitCode::DataDirError);
429        assert_eq!(classify_error(&anyhow_err).as_i32(), 4);
430    }
431
432    #[test]
433    fn test_classify_core_query_errors() {
434        // Test cqlite_core::Error::QueryExecution mapping
435        let query_err = cqlite_core::Error::query_execution("Query failed");
436        let anyhow_err = anyhow::Error::new(query_err);
437        assert_eq!(
438            classify_error(&anyhow_err),
439            CliExitCode::QueryExecutionError
440        );
441        assert_eq!(classify_error(&anyhow_err).as_i32(), 5);
442
443        // Test cqlite_core::Error::CqlParse mapping
444        let parse_err = cqlite_core::Error::cql_parse("Invalid SELECT syntax");
445        let anyhow_err = anyhow::Error::new(parse_err);
446        assert_eq!(
447            classify_error(&anyhow_err),
448            CliExitCode::QueryExecutionError
449        );
450        assert_eq!(classify_error(&anyhow_err).as_i32(), 5);
451
452        // Test cqlite_core::Error::UnsupportedQuery mapping
453        let unsupported_err = cqlite_core::Error::unsupported_query("JOIN not supported");
454        let anyhow_err = anyhow::Error::new(unsupported_err);
455        assert_eq!(
456            classify_error(&anyhow_err),
457            CliExitCode::QueryExecutionError
458        );
459        assert_eq!(classify_error(&anyhow_err).as_i32(), 5);
460
461        // Test cqlite_core::Error::Parse mapping
462        let parse_err = cqlite_core::Error::parse("Parse failure");
463        let anyhow_err = anyhow::Error::new(parse_err);
464        assert_eq!(
465            classify_error(&anyhow_err),
466            CliExitCode::QueryExecutionError
467        );
468        assert_eq!(classify_error(&anyhow_err).as_i32(), 5);
469
470        // Test cqlite_core::Error::InvalidInput mapping
471        let invalid_input_err = cqlite_core::Error::invalid_input("Invalid query input");
472        let anyhow_err = anyhow::Error::new(invalid_input_err);
473        assert_eq!(
474            classify_error(&anyhow_err),
475            CliExitCode::QueryExecutionError
476        );
477        assert_eq!(classify_error(&anyhow_err).as_i32(), 5);
478    }
479
480    #[test]
481    fn test_classify_core_other_errors_default() {
482        // Test that other core errors default to QueryExecutionError
483        let corruption_err = cqlite_core::Error::corruption("Data corrupted");
484        let anyhow_err = anyhow::Error::new(corruption_err);
485        assert_eq!(
486            classify_error(&anyhow_err),
487            CliExitCode::QueryExecutionError
488        );
489        assert_eq!(classify_error(&anyhow_err).as_i32(), 5);
490    }
491}