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 std::fmt::Display for ErrorCategory {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            Self::SyntaxException => write!(f, "SyntaxException"),
35            Self::InvalidRequest => write!(f, "InvalidRequest"),
36            Self::Unauthorized => write!(f, "Unauthorized"),
37            Self::Unavailable => write!(f, "Unavailable"),
38            Self::ReadTimeout => write!(f, "ReadTimeout"),
39            Self::WriteTimeout => write!(f, "WriteTimeout"),
40            Self::ConfigurationException => write!(f, "ConfigurationException"),
41            Self::AlreadyExists => write!(f, "AlreadyExists"),
42            Self::Overloaded => write!(f, "Overloaded"),
43            Self::IsBootstrapping => write!(f, "IsBootstrapping"),
44            Self::TruncateError => write!(f, "TruncateError"),
45            Self::ReadFailure => write!(f, "ReadFailure"),
46            Self::WriteFailure => write!(f, "WriteFailure"),
47            Self::FunctionFailure => write!(f, "FunctionFailure"),
48            Self::AuthenticationError => write!(f, "AuthenticationError"),
49            Self::ServerError => write!(f, "ServerError"),
50            Self::ProtocolError => write!(f, "ProtocolError"),
51            Self::ConnectionError => write!(f, "ConnectionError"),
52        }
53    }
54}
55
56/// Classified error with category and cleaned message.
57pub struct ClassifiedError {
58    pub category: ErrorCategory,
59    pub message: String,
60}
61
62/// Classify an anyhow error by walking the chain to find a DbError.
63pub fn classify_error(error: &anyhow::Error) -> ClassifiedError {
64    // Try direct downcast first, then walk the chain
65    for cause in error.chain() {
66        if let Some(exec_err) = cause.downcast_ref::<ExecutionError>() {
67            if let Some(classified) = classify_execution_error(exec_err) {
68                return classified;
69            }
70        }
71        if let Some(req_err) = cause.downcast_ref::<RequestError>() {
72            if let Some(classified) = classify_request_error(req_err) {
73                return classified;
74            }
75        }
76        if let Some(attempt_err) = cause.downcast_ref::<RequestAttemptError>() {
77            if let Some(classified) = classify_attempt_error(attempt_err) {
78                return classified;
79            }
80        }
81    }
82
83    // Fallback: use root cause message
84    ClassifiedError {
85        category: ErrorCategory::ServerError,
86        message: error.root_cause().to_string(),
87    }
88}
89
90/// Format a classified error for display matching Python cqlsh output.
91pub fn format_error(error: &anyhow::Error) -> String {
92    let classified = classify_error(error);
93    format!("{}: {}", classified.category, classified.message)
94}
95
96/// Format a classified error with optional color (red bold when enabled).
97pub fn format_error_colored(
98    error: &anyhow::Error,
99    colorizer: &crate::colorizer::CqlColorizer,
100) -> String {
101    let plain = format_error(error);
102    colorizer.colorize_error(&plain)
103}
104
105fn categorize_db_error(db_error: &DbError) -> ErrorCategory {
106    match db_error {
107        DbError::SyntaxError => ErrorCategory::SyntaxException,
108        DbError::Invalid => ErrorCategory::InvalidRequest,
109        DbError::Unauthorized => ErrorCategory::Unauthorized,
110        DbError::Unavailable { .. } => ErrorCategory::Unavailable,
111        DbError::ReadTimeout { .. } => ErrorCategory::ReadTimeout,
112        DbError::WriteTimeout { .. } => ErrorCategory::WriteTimeout,
113        DbError::ConfigError => ErrorCategory::ConfigurationException,
114        DbError::AlreadyExists { .. } => ErrorCategory::AlreadyExists,
115        DbError::Overloaded => ErrorCategory::Overloaded,
116        DbError::IsBootstrapping => ErrorCategory::IsBootstrapping,
117        DbError::TruncateError => ErrorCategory::TruncateError,
118        DbError::ReadFailure { .. } => ErrorCategory::ReadFailure,
119        DbError::WriteFailure { .. } => ErrorCategory::WriteFailure,
120        DbError::FunctionFailure { .. } => ErrorCategory::FunctionFailure,
121        DbError::AuthenticationError => ErrorCategory::AuthenticationError,
122        DbError::ServerError => ErrorCategory::ServerError,
123        DbError::ProtocolError => ErrorCategory::ProtocolError,
124        _ => ErrorCategory::ServerError,
125    }
126}
127
128/// Clean the reason string from a DbError, stripping driver boilerplate.
129fn clean_db_message(reason: &str) -> String {
130    let cleaned = reason;
131    // Strip nested prefixes — apply each in sequence
132    let cleaned = cleaned
133        .strip_prefix("The submitted query has a syntax error, ")
134        .unwrap_or(cleaned);
135    let cleaned = cleaned
136        .strip_prefix("The query is syntactically correct but invalid, ")
137        .unwrap_or(cleaned);
138    let cleaned = cleaned.strip_prefix("Error message: ").unwrap_or(cleaned);
139    cleaned.to_string()
140}
141
142fn classify_execution_error(err: &ExecutionError) -> Option<ClassifiedError> {
143    match err {
144        ExecutionError::LastAttemptError(attempt) => classify_attempt_error(attempt),
145        ExecutionError::EmptyPlan => Some(ClassifiedError {
146            category: ErrorCategory::ConnectionError,
147            message: "No nodes available for query execution".to_string(),
148        }),
149        ExecutionError::RequestTimeout(dur) => Some(ClassifiedError {
150            category: ErrorCategory::ReadTimeout,
151            message: format!("Request timed out after {dur:?}"),
152        }),
153        _ => None,
154    }
155}
156
157fn classify_request_error(err: &RequestError) -> Option<ClassifiedError> {
158    match err {
159        RequestError::LastAttemptError(attempt) => classify_attempt_error(attempt),
160        RequestError::EmptyPlan => Some(ClassifiedError {
161            category: ErrorCategory::ConnectionError,
162            message: "No nodes available for query execution".to_string(),
163        }),
164        RequestError::RequestTimeout(dur) => Some(ClassifiedError {
165            category: ErrorCategory::ReadTimeout,
166            message: format!("Request timed out after {dur:?}"),
167        }),
168        _ => None,
169    }
170}
171
172fn classify_attempt_error(err: &RequestAttemptError) -> Option<ClassifiedError> {
173    match err {
174        RequestAttemptError::DbError(db_error, reason) => {
175            let category = categorize_db_error(db_error);
176            let message = clean_db_message(reason);
177            Some(ClassifiedError { category, message })
178        }
179        _ => None,
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn category_display_names() {
189        assert_eq!(
190            ErrorCategory::SyntaxException.to_string(),
191            "SyntaxException"
192        );
193        assert_eq!(ErrorCategory::InvalidRequest.to_string(), "InvalidRequest");
194        assert_eq!(ErrorCategory::Unauthorized.to_string(), "Unauthorized");
195        assert_eq!(ErrorCategory::ServerError.to_string(), "ServerError");
196        assert_eq!(
197            ErrorCategory::ConfigurationException.to_string(),
198            "ConfigurationException"
199        );
200    }
201
202    #[test]
203    fn categorize_syntax_error() {
204        assert_eq!(
205            categorize_db_error(&DbError::SyntaxError),
206            ErrorCategory::SyntaxException
207        );
208    }
209
210    #[test]
211    fn categorize_invalid() {
212        assert_eq!(
213            categorize_db_error(&DbError::Invalid),
214            ErrorCategory::InvalidRequest
215        );
216    }
217
218    #[test]
219    fn clean_strips_syntax_prefix() {
220        let msg = clean_db_message(
221            "The submitted query has a syntax error, Error message: line 1:0 no viable alternative at input 'SELEC'",
222        );
223        assert_eq!(msg, "line 1:0 no viable alternative at input 'SELEC'");
224    }
225
226    #[test]
227    fn clean_strips_invalid_prefix() {
228        let msg = clean_db_message(
229            "The query is syntactically correct but invalid, Error message: unconfigured table foo",
230        );
231        assert_eq!(msg, "unconfigured table foo");
232    }
233
234    #[test]
235    fn clean_preserves_already_clean() {
236        let msg = clean_db_message("table foo does not exist");
237        assert_eq!(msg, "table foo does not exist");
238    }
239
240    #[test]
241    fn classify_syntax_from_execution_error() {
242        let attempt = RequestAttemptError::DbError(
243            DbError::SyntaxError,
244            "Error message: line 1:0 no viable alternative at input 'SELEC'".to_string(),
245        );
246        let exec = ExecutionError::LastAttemptError(attempt);
247        let err = anyhow::Error::new(exec);
248
249        let classified = classify_error(&err);
250        assert_eq!(classified.category, ErrorCategory::SyntaxException);
251        assert_eq!(
252            classified.message,
253            "line 1:0 no viable alternative at input 'SELEC'"
254        );
255    }
256
257    #[test]
258    fn classify_invalid_from_execution_error() {
259        let attempt = RequestAttemptError::DbError(
260            DbError::Invalid,
261            "Error message: unconfigured table no_such_table".to_string(),
262        );
263        let exec = ExecutionError::LastAttemptError(attempt);
264        let err = anyhow::Error::new(exec);
265
266        let classified = classify_error(&err);
267        assert_eq!(classified.category, ErrorCategory::InvalidRequest);
268        assert_eq!(classified.message, "unconfigured table no_such_table");
269    }
270
271    #[test]
272    fn format_syntax_error() {
273        let attempt = RequestAttemptError::DbError(
274            DbError::SyntaxError,
275            "Error message: line 1:0 bad input".to_string(),
276        );
277        let exec = ExecutionError::LastAttemptError(attempt);
278        let err = anyhow::Error::new(exec);
279
280        assert_eq!(format_error(&err), "SyntaxException: line 1:0 bad input");
281    }
282
283    #[test]
284    fn classify_through_anyhow_context() {
285        let attempt = RequestAttemptError::DbError(
286            DbError::SyntaxError,
287            "Error message: line 1:0 bad input".to_string(),
288        );
289        let exec = ExecutionError::LastAttemptError(attempt);
290        let err = anyhow::Error::new(exec).context("executing CQL query");
291
292        let classified = classify_error(&err);
293        assert_eq!(classified.category, ErrorCategory::SyntaxException);
294    }
295
296    #[test]
297    fn classify_fallback_unknown() {
298        let err = anyhow::anyhow!("something went wrong");
299        let classified = classify_error(&err);
300        assert_eq!(classified.category, ErrorCategory::ServerError);
301        assert_eq!(classified.message, "something went wrong");
302    }
303}