Skip to main content

cqlsh_rs/
error.rs

1//! Error classification and formatting for user-facing error display.
2//!
3//! Maps scylla driver error types to Python cqlsh-compatible error names
4//! and strips verbose driver boilerplate to produce clean messages.
5
6use scylla::errors::{DbError, ExecutionError, RequestAttemptError, RequestError};
7
8/// Error categories matching Python cqlsh error display names.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum ErrorCategory {
11    SyntaxException,
12    InvalidRequest,
13    Unauthorized,
14    Unavailable,
15    ReadTimeout,
16    WriteTimeout,
17    ConfigurationException,
18    AlreadyExists,
19    Overloaded,
20    IsBootstrapping,
21    TruncateError,
22    ReadFailure,
23    WriteFailure,
24    FunctionFailure,
25    AuthenticationError,
26    ServerError,
27    ProtocolError,
28    ConnectionError,
29}
30
31impl ErrorCategory {
32    /// CQL protocol error code for this category.
33    pub fn error_code(&self) -> Option<u32> {
34        match self {
35            Self::ServerError => Some(0x0000),
36            Self::ProtocolError => Some(0x000A),
37            Self::AuthenticationError => Some(0x0100),
38            Self::Unavailable => Some(0x1000),
39            Self::Overloaded => Some(0x1001),
40            Self::IsBootstrapping => Some(0x1002),
41            Self::TruncateError => Some(0x1003),
42            Self::WriteTimeout => Some(0x1100),
43            Self::ReadTimeout => Some(0x1200),
44            Self::ReadFailure => Some(0x1300),
45            Self::FunctionFailure => Some(0x1400),
46            Self::WriteFailure => Some(0x1500),
47            Self::SyntaxException => Some(0x2000),
48            Self::Unauthorized => Some(0x2100),
49            Self::InvalidRequest => Some(0x2200),
50            Self::ConfigurationException => Some(0x2300),
51            Self::AlreadyExists => Some(0x2400),
52            Self::ConnectionError => None,
53        }
54    }
55
56    /// Human-readable category label used in `Error from server` messages.
57    fn server_label(&self) -> &'static str {
58        match self {
59            Self::ServerError => "Server error",
60            Self::ProtocolError => "Protocol error",
61            Self::AuthenticationError => "Bad credentials",
62            Self::Unavailable => "Unavailable exception",
63            Self::Overloaded => "Overloaded",
64            Self::IsBootstrapping => "Is bootstrapping",
65            Self::TruncateError => "Truncate error",
66            Self::WriteTimeout => "Write timeout",
67            Self::ReadTimeout => "Read timeout",
68            Self::ReadFailure => "Read failure",
69            Self::FunctionFailure => "Function failure",
70            Self::WriteFailure => "Write failure",
71            Self::SyntaxException => "Syntax error",
72            Self::Unauthorized => "Unauthorized",
73            Self::InvalidRequest => "Invalid query",
74            Self::ConfigurationException => "Configuration error",
75            Self::AlreadyExists => "Already exists",
76            Self::ConnectionError => "Connection error",
77        }
78    }
79}
80
81impl std::fmt::Display for ErrorCategory {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        match self {
84            Self::SyntaxException => write!(f, "SyntaxException"),
85            Self::InvalidRequest => write!(f, "InvalidRequest"),
86            Self::Unauthorized => write!(f, "Unauthorized"),
87            Self::Unavailable => write!(f, "Unavailable"),
88            Self::ReadTimeout => write!(f, "ReadTimeout"),
89            Self::WriteTimeout => write!(f, "WriteTimeout"),
90            Self::ConfigurationException => write!(f, "ConfigurationException"),
91            Self::AlreadyExists => write!(f, "AlreadyExists"),
92            Self::Overloaded => write!(f, "Overloaded"),
93            Self::IsBootstrapping => write!(f, "IsBootstrapping"),
94            Self::TruncateError => write!(f, "TruncateError"),
95            Self::ReadFailure => write!(f, "ReadFailure"),
96            Self::WriteFailure => write!(f, "WriteFailure"),
97            Self::FunctionFailure => write!(f, "FunctionFailure"),
98            Self::AuthenticationError => write!(f, "AuthenticationError"),
99            Self::ServerError => write!(f, "ServerError"),
100            Self::ProtocolError => write!(f, "ProtocolError"),
101            Self::ConnectionError => write!(f, "ConnectionError"),
102        }
103    }
104}
105
106/// Classified error with category and cleaned message.
107pub struct ClassifiedError {
108    pub category: ErrorCategory,
109    pub message: String,
110}
111
112/// Classify an anyhow error by walking the chain to find a DbError.
113pub fn classify_error(error: &anyhow::Error) -> ClassifiedError {
114    // Try direct downcast first, then walk the chain
115    for cause in error.chain() {
116        if let Some(exec_err) = cause.downcast_ref::<ExecutionError>() {
117            if let Some(classified) = classify_execution_error(exec_err) {
118                return classified;
119            }
120        }
121        if let Some(req_err) = cause.downcast_ref::<RequestError>() {
122            if let Some(classified) = classify_request_error(req_err) {
123                return classified;
124            }
125        }
126        if let Some(attempt_err) = cause.downcast_ref::<RequestAttemptError>() {
127            if let Some(classified) = classify_attempt_error(attempt_err) {
128                return classified;
129            }
130        }
131    }
132
133    // Fallback: use root cause message
134    ClassifiedError {
135        category: ErrorCategory::ServerError,
136        message: error.root_cause().to_string(),
137    }
138}
139
140/// Format a classified error for display matching Python cqlsh output.
141pub fn format_error(error: &anyhow::Error) -> String {
142    let classified = classify_error(error);
143    match classified.category.error_code() {
144        Some(code) => format!(
145            "{}: Error from server: code={:04X} [{}] message=\"{}\"",
146            classified.category,
147            code,
148            classified.category.server_label(),
149            classified.message
150        ),
151        None => format!("{}: {}", classified.category, classified.message),
152    }
153}
154
155/// Format a classified error with optional color (red bold when enabled).
156pub fn format_error_colored(
157    error: &anyhow::Error,
158    colorizer: &crate::colorizer::CqlColorizer,
159) -> String {
160    let plain = format_error(error);
161    colorizer.colorize_error(&plain)
162}
163
164fn categorize_db_error(db_error: &DbError) -> ErrorCategory {
165    match db_error {
166        DbError::SyntaxError => ErrorCategory::SyntaxException,
167        DbError::Invalid => ErrorCategory::InvalidRequest,
168        DbError::Unauthorized => ErrorCategory::Unauthorized,
169        DbError::Unavailable { .. } => ErrorCategory::Unavailable,
170        DbError::ReadTimeout { .. } => ErrorCategory::ReadTimeout,
171        DbError::WriteTimeout { .. } => ErrorCategory::WriteTimeout,
172        DbError::ConfigError => ErrorCategory::ConfigurationException,
173        DbError::AlreadyExists { .. } => ErrorCategory::AlreadyExists,
174        DbError::Overloaded => ErrorCategory::Overloaded,
175        DbError::IsBootstrapping => ErrorCategory::IsBootstrapping,
176        DbError::TruncateError => ErrorCategory::TruncateError,
177        DbError::ReadFailure { .. } => ErrorCategory::ReadFailure,
178        DbError::WriteFailure { .. } => ErrorCategory::WriteFailure,
179        DbError::FunctionFailure { .. } => ErrorCategory::FunctionFailure,
180        DbError::AuthenticationError => ErrorCategory::AuthenticationError,
181        DbError::ServerError => ErrorCategory::ServerError,
182        DbError::ProtocolError => ErrorCategory::ProtocolError,
183        _ => ErrorCategory::ServerError,
184    }
185}
186
187/// Clean the reason string from a DbError, stripping driver boilerplate.
188fn clean_db_message(reason: &str) -> String {
189    let cleaned = reason;
190    // Strip nested prefixes — apply each in sequence
191    let cleaned = cleaned
192        .strip_prefix("The submitted query has a syntax error, ")
193        .unwrap_or(cleaned);
194    let cleaned = cleaned
195        .strip_prefix("The query is syntactically correct but invalid, ")
196        .unwrap_or(cleaned);
197    let cleaned = cleaned.strip_prefix("Error message: ").unwrap_or(cleaned);
198    cleaned.to_string()
199}
200
201fn classify_execution_error(err: &ExecutionError) -> Option<ClassifiedError> {
202    match err {
203        ExecutionError::LastAttemptError(attempt) => classify_attempt_error(attempt),
204        ExecutionError::EmptyPlan => Some(ClassifiedError {
205            category: ErrorCategory::ConnectionError,
206            message: "No nodes available for query execution".to_string(),
207        }),
208        ExecutionError::RequestTimeout(dur) => Some(ClassifiedError {
209            category: ErrorCategory::ReadTimeout,
210            message: format!("Request timed out after {dur:?}"),
211        }),
212        _ => None,
213    }
214}
215
216fn classify_request_error(err: &RequestError) -> Option<ClassifiedError> {
217    match err {
218        RequestError::LastAttemptError(attempt) => classify_attempt_error(attempt),
219        RequestError::EmptyPlan => Some(ClassifiedError {
220            category: ErrorCategory::ConnectionError,
221            message: "No nodes available for query execution".to_string(),
222        }),
223        RequestError::RequestTimeout(dur) => Some(ClassifiedError {
224            category: ErrorCategory::ReadTimeout,
225            message: format!("Request timed out after {dur:?}"),
226        }),
227        _ => None,
228    }
229}
230
231fn classify_attempt_error(err: &RequestAttemptError) -> Option<ClassifiedError> {
232    match err {
233        RequestAttemptError::DbError(db_error, reason) => {
234            let category = categorize_db_error(db_error);
235            let message = clean_db_message(reason);
236            Some(ClassifiedError { category, message })
237        }
238        _ => None,
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn category_display_names() {
248        assert_eq!(
249            ErrorCategory::SyntaxException.to_string(),
250            "SyntaxException"
251        );
252        assert_eq!(ErrorCategory::InvalidRequest.to_string(), "InvalidRequest");
253        assert_eq!(ErrorCategory::Unauthorized.to_string(), "Unauthorized");
254        assert_eq!(ErrorCategory::ServerError.to_string(), "ServerError");
255        assert_eq!(
256            ErrorCategory::ConfigurationException.to_string(),
257            "ConfigurationException"
258        );
259    }
260
261    #[test]
262    fn categorize_syntax_error() {
263        assert_eq!(
264            categorize_db_error(&DbError::SyntaxError),
265            ErrorCategory::SyntaxException
266        );
267    }
268
269    #[test]
270    fn categorize_invalid() {
271        assert_eq!(
272            categorize_db_error(&DbError::Invalid),
273            ErrorCategory::InvalidRequest
274        );
275    }
276
277    #[test]
278    fn clean_strips_syntax_prefix() {
279        let msg = clean_db_message(
280            "The submitted query has a syntax error, Error message: line 1:0 no viable alternative at input 'SELEC'",
281        );
282        assert_eq!(msg, "line 1:0 no viable alternative at input 'SELEC'");
283    }
284
285    #[test]
286    fn clean_strips_invalid_prefix() {
287        let msg = clean_db_message(
288            "The query is syntactically correct but invalid, Error message: unconfigured table foo",
289        );
290        assert_eq!(msg, "unconfigured table foo");
291    }
292
293    #[test]
294    fn clean_preserves_already_clean() {
295        let msg = clean_db_message("table foo does not exist");
296        assert_eq!(msg, "table foo does not exist");
297    }
298
299    #[test]
300    fn classify_syntax_from_execution_error() {
301        let attempt = RequestAttemptError::DbError(
302            DbError::SyntaxError,
303            "Error message: line 1:0 no viable alternative at input 'SELEC'".to_string(),
304        );
305        let exec = ExecutionError::LastAttemptError(attempt);
306        let err = anyhow::Error::new(exec);
307
308        let classified = classify_error(&err);
309        assert_eq!(classified.category, ErrorCategory::SyntaxException);
310        assert_eq!(
311            classified.message,
312            "line 1:0 no viable alternative at input 'SELEC'"
313        );
314    }
315
316    #[test]
317    fn classify_invalid_from_execution_error() {
318        let attempt = RequestAttemptError::DbError(
319            DbError::Invalid,
320            "Error message: unconfigured table no_such_table".to_string(),
321        );
322        let exec = ExecutionError::LastAttemptError(attempt);
323        let err = anyhow::Error::new(exec);
324
325        let classified = classify_error(&err);
326        assert_eq!(classified.category, ErrorCategory::InvalidRequest);
327        assert_eq!(classified.message, "unconfigured table no_such_table");
328    }
329
330    #[test]
331    fn format_syntax_error() {
332        let attempt = RequestAttemptError::DbError(
333            DbError::SyntaxError,
334            "Error message: line 1:0 bad input".to_string(),
335        );
336        let exec = ExecutionError::LastAttemptError(attempt);
337        let err = anyhow::Error::new(exec);
338
339        assert_eq!(format_error(&err), "SyntaxException: Error from server: code=2000 [Syntax error] message=\"line 1:0 bad input\"");
340    }
341
342    #[test]
343    fn classify_through_anyhow_context() {
344        let attempt = RequestAttemptError::DbError(
345            DbError::SyntaxError,
346            "Error message: line 1:0 bad input".to_string(),
347        );
348        let exec = ExecutionError::LastAttemptError(attempt);
349        let err = anyhow::Error::new(exec).context("executing CQL query");
350
351        let classified = classify_error(&err);
352        assert_eq!(classified.category, ErrorCategory::SyntaxException);
353    }
354
355    #[test]
356    fn classify_fallback_unknown() {
357        let err = anyhow::anyhow!("something went wrong");
358        let classified = classify_error(&err);
359        assert_eq!(classified.category, ErrorCategory::ServerError);
360        assert_eq!(classified.message, "something went wrong");
361    }
362
363    // --- Additional categorize_db_error variant tests ---
364
365    #[test]
366    fn categorize_unauthorized() {
367        assert_eq!(
368            categorize_db_error(&DbError::Unauthorized),
369            ErrorCategory::Unauthorized
370        );
371    }
372
373    #[test]
374    fn categorize_unavailable() {
375        assert_eq!(
376            categorize_db_error(&DbError::Unavailable {
377                consistency: scylla::frame::types::Consistency::Quorum,
378                required: 2,
379                alive: 1,
380            }),
381            ErrorCategory::Unavailable
382        );
383    }
384
385    #[test]
386    fn categorize_read_timeout() {
387        assert_eq!(
388            categorize_db_error(&DbError::ReadTimeout {
389                consistency: scylla::frame::types::Consistency::One,
390                received: 0,
391                required: 1,
392                data_present: false,
393            }),
394            ErrorCategory::ReadTimeout
395        );
396    }
397
398    #[test]
399    fn categorize_write_timeout() {
400        assert_eq!(
401            categorize_db_error(&DbError::WriteTimeout {
402                consistency: scylla::frame::types::Consistency::Quorum,
403                received: 1,
404                required: 2,
405                write_type: scylla::errors::WriteType::Simple,
406            }),
407            ErrorCategory::WriteTimeout
408        );
409    }
410
411    #[test]
412    fn categorize_config_error() {
413        assert_eq!(
414            categorize_db_error(&DbError::ConfigError),
415            ErrorCategory::ConfigurationException
416        );
417    }
418
419    #[test]
420    fn categorize_already_exists() {
421        assert_eq!(
422            categorize_db_error(&DbError::AlreadyExists {
423                keyspace: "ks".to_string(),
424                table: "tbl".to_string(),
425            }),
426            ErrorCategory::AlreadyExists
427        );
428    }
429
430    #[test]
431    fn categorize_overloaded() {
432        assert_eq!(
433            categorize_db_error(&DbError::Overloaded),
434            ErrorCategory::Overloaded
435        );
436    }
437
438    #[test]
439    fn categorize_is_bootstrapping() {
440        assert_eq!(
441            categorize_db_error(&DbError::IsBootstrapping),
442            ErrorCategory::IsBootstrapping
443        );
444    }
445
446    #[test]
447    fn categorize_truncate_error() {
448        assert_eq!(
449            categorize_db_error(&DbError::TruncateError),
450            ErrorCategory::TruncateError
451        );
452    }
453
454    #[test]
455    fn categorize_read_failure() {
456        assert_eq!(
457            categorize_db_error(&DbError::ReadFailure {
458                consistency: scylla::frame::types::Consistency::One,
459                received: 1,
460                required: 1,
461                numfailures: 1,
462                data_present: false,
463            }),
464            ErrorCategory::ReadFailure
465        );
466    }
467
468    #[test]
469    fn categorize_write_failure() {
470        assert_eq!(
471            categorize_db_error(&DbError::WriteFailure {
472                consistency: scylla::frame::types::Consistency::Quorum,
473                received: 1,
474                required: 2,
475                numfailures: 1,
476                write_type: scylla::errors::WriteType::Simple,
477            }),
478            ErrorCategory::WriteFailure
479        );
480    }
481
482    #[test]
483    fn categorize_function_failure() {
484        assert_eq!(
485            categorize_db_error(&DbError::FunctionFailure {
486                keyspace: "ks".to_string(),
487                function: "fn".to_string(),
488                arg_types: vec!["int".to_string()],
489            }),
490            ErrorCategory::FunctionFailure
491        );
492    }
493
494    #[test]
495    fn categorize_authentication_error() {
496        assert_eq!(
497            categorize_db_error(&DbError::AuthenticationError),
498            ErrorCategory::AuthenticationError
499        );
500    }
501
502    #[test]
503    fn categorize_server_error() {
504        assert_eq!(
505            categorize_db_error(&DbError::ServerError),
506            ErrorCategory::ServerError
507        );
508    }
509
510    #[test]
511    fn categorize_protocol_error() {
512        assert_eq!(
513            categorize_db_error(&DbError::ProtocolError),
514            ErrorCategory::ProtocolError
515        );
516    }
517
518    // --- classify_execution_error / classify_request_error paths ---
519
520    #[test]
521    fn classify_empty_plan_execution() {
522        let exec = ExecutionError::EmptyPlan;
523        let err = anyhow::Error::new(exec);
524        let classified = classify_error(&err);
525        assert_eq!(classified.category, ErrorCategory::ConnectionError);
526        assert!(classified.message.contains("No nodes available"));
527    }
528
529    #[test]
530    fn classify_request_timeout_execution() {
531        let exec = ExecutionError::RequestTimeout(std::time::Duration::from_secs(10));
532        let err = anyhow::Error::new(exec);
533        let classified = classify_error(&err);
534        assert_eq!(classified.category, ErrorCategory::ReadTimeout);
535        assert!(classified.message.contains("timed out"));
536    }
537
538    #[test]
539    fn classify_empty_plan_request() {
540        let req = RequestError::EmptyPlan;
541        let err = anyhow::Error::new(req);
542        let classified = classify_error(&err);
543        assert_eq!(classified.category, ErrorCategory::ConnectionError);
544        assert!(classified.message.contains("No nodes available"));
545    }
546
547    #[test]
548    fn classify_request_timeout_request() {
549        let req = RequestError::RequestTimeout(std::time::Duration::from_secs(5));
550        let err = anyhow::Error::new(req);
551        let classified = classify_error(&err);
552        assert_eq!(classified.category, ErrorCategory::ReadTimeout);
553        assert!(classified.message.contains("timed out"));
554    }
555
556    // --- format_error with no error code (ConnectionError) ---
557
558    #[test]
559    fn format_connection_error() {
560        let exec = ExecutionError::EmptyPlan;
561        let err = anyhow::Error::new(exec);
562        let formatted = format_error(&err);
563        assert!(formatted.starts_with("ConnectionError:"));
564        assert!(!formatted.contains("code="));
565    }
566
567    // --- error_code tests ---
568
569    #[test]
570    fn error_codes_some_known() {
571        assert_eq!(ErrorCategory::SyntaxException.error_code(), Some(0x2000));
572        assert_eq!(ErrorCategory::InvalidRequest.error_code(), Some(0x2200));
573        assert_eq!(ErrorCategory::Unavailable.error_code(), Some(0x1000));
574        assert_eq!(ErrorCategory::ReadTimeout.error_code(), Some(0x1200));
575        assert_eq!(ErrorCategory::WriteTimeout.error_code(), Some(0x1100));
576        assert_eq!(ErrorCategory::ConnectionError.error_code(), None);
577    }
578
579    // --- server_label tests ---
580
581    #[test]
582    fn server_labels() {
583        assert_eq!(
584            ErrorCategory::SyntaxException.server_label(),
585            "Syntax error"
586        );
587        assert_eq!(
588            ErrorCategory::InvalidRequest.server_label(),
589            "Invalid query"
590        );
591        assert_eq!(ErrorCategory::Unauthorized.server_label(), "Unauthorized");
592        assert_eq!(
593            ErrorCategory::Unavailable.server_label(),
594            "Unavailable exception"
595        );
596        assert_eq!(ErrorCategory::Overloaded.server_label(), "Overloaded");
597        assert_eq!(
598            ErrorCategory::IsBootstrapping.server_label(),
599            "Is bootstrapping"
600        );
601        assert_eq!(
602            ErrorCategory::TruncateError.server_label(),
603            "Truncate error"
604        );
605        assert_eq!(ErrorCategory::ReadFailure.server_label(), "Read failure");
606        assert_eq!(ErrorCategory::WriteFailure.server_label(), "Write failure");
607        assert_eq!(
608            ErrorCategory::FunctionFailure.server_label(),
609            "Function failure"
610        );
611        assert_eq!(
612            ErrorCategory::AuthenticationError.server_label(),
613            "Bad credentials"
614        );
615        assert_eq!(ErrorCategory::ServerError.server_label(), "Server error");
616        assert_eq!(
617            ErrorCategory::ProtocolError.server_label(),
618            "Protocol error"
619        );
620        assert_eq!(
621            ErrorCategory::ConnectionError.server_label(),
622            "Connection error"
623        );
624    }
625
626    // --- Display trait for all variants ---
627
628    #[test]
629    fn display_all_categories() {
630        let categories = vec![
631            (ErrorCategory::Unavailable, "Unavailable"),
632            (ErrorCategory::ReadTimeout, "ReadTimeout"),
633            (ErrorCategory::WriteTimeout, "WriteTimeout"),
634            (ErrorCategory::Overloaded, "Overloaded"),
635            (ErrorCategory::IsBootstrapping, "IsBootstrapping"),
636            (ErrorCategory::TruncateError, "TruncateError"),
637            (ErrorCategory::ReadFailure, "ReadFailure"),
638            (ErrorCategory::WriteFailure, "WriteFailure"),
639            (ErrorCategory::FunctionFailure, "FunctionFailure"),
640            (ErrorCategory::AuthenticationError, "AuthenticationError"),
641            (ErrorCategory::ProtocolError, "ProtocolError"),
642            (ErrorCategory::ConnectionError, "ConnectionError"),
643            (ErrorCategory::AlreadyExists, "AlreadyExists"),
644        ];
645        for (cat, expected) in categories {
646            assert_eq!(cat.to_string(), expected);
647        }
648    }
649
650    // --- classify_attempt_error with RequestError wrapping ---
651
652    #[test]
653    fn classify_db_error_through_request_error() {
654        let attempt = RequestAttemptError::DbError(
655            DbError::Unauthorized,
656            "User has no permission".to_string(),
657        );
658        let req = RequestError::LastAttemptError(attempt);
659        let err = anyhow::Error::new(req);
660        let classified = classify_error(&err);
661        assert_eq!(classified.category, ErrorCategory::Unauthorized);
662        assert_eq!(classified.message, "User has no permission");
663    }
664}