Skip to main content

camgrab_core/
error.rs

1//! Error classification module for camgrab-core.
2//!
3//! This module provides a comprehensive error classification system to categorize
4//! and provide actionable suggestions for common camera-related errors.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9/// Categorizes errors into common types for better error handling and user feedback.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum ErrorCategory {
13    /// Authentication or authorization failures (401, 403, etc.)
14    Auth,
15    /// Connection refused errors
16    NetworkRefused,
17    /// Timeout errors
18    NetworkTimeout,
19    /// Resource not found errors (404, etc.)
20    NotFound,
21    /// Codec or format errors
22    CodecError,
23    /// I/O errors (permissions, disk space, etc.)
24    IoError,
25    /// Unknown or uncategorized errors
26    Unknown,
27}
28
29impl ErrorCategory {
30    /// Returns all possible error categories.
31    pub fn all() -> &'static [ErrorCategory] {
32        &[
33            ErrorCategory::Auth,
34            ErrorCategory::NetworkRefused,
35            ErrorCategory::NetworkTimeout,
36            ErrorCategory::NotFound,
37            ErrorCategory::CodecError,
38            ErrorCategory::IoError,
39            ErrorCategory::Unknown,
40        ]
41    }
42}
43
44impl fmt::Display for ErrorCategory {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        let description = match self {
47            ErrorCategory::Auth => "authentication failure",
48            ErrorCategory::NetworkRefused => "connection refused",
49            ErrorCategory::NetworkTimeout => "network timeout",
50            ErrorCategory::NotFound => "resource not found",
51            ErrorCategory::CodecError => "codec error",
52            ErrorCategory::IoError => "I/O error",
53            ErrorCategory::Unknown => "unknown error",
54        };
55        write!(f, "{description}")
56    }
57}
58
59/// Classifies an error message into a category based on keyword matching.
60///
61/// # Arguments
62///
63/// * `message` - The error message to classify
64///
65/// # Returns
66///
67/// The most appropriate `ErrorCategory` for the given error message.
68///
69/// # Examples
70///
71/// ```
72/// use camgrab_core::error::{classify_error, ErrorCategory};
73///
74/// assert_eq!(classify_error("401 Unauthorized"), ErrorCategory::Auth);
75/// assert_eq!(classify_error("connection refused"), ErrorCategory::NetworkRefused);
76/// assert_eq!(classify_error("request timed out"), ErrorCategory::NetworkTimeout);
77/// ```
78pub fn classify_error(message: &str) -> ErrorCategory {
79    let lower = message.to_lowercase();
80
81    // Check for authentication errors
82    if lower.contains("401")
83        || lower.contains("unauthorized")
84        || lower.contains("not authorized")
85        || lower.contains("authentication")
86        || lower.contains("forbidden")
87        || lower.contains("403")
88    {
89        return ErrorCategory::Auth;
90    }
91
92    // Check for connection refused errors
93    if lower.contains("connection refused") || lower.contains("refused") {
94        return ErrorCategory::NetworkRefused;
95    }
96
97    // Check for timeout errors
98    if lower.contains("timed out")
99        || lower.contains("timeout")
100        || lower.contains("deadline exceeded")
101    {
102        return ErrorCategory::NetworkTimeout;
103    }
104
105    // Check for not found errors
106    if lower.contains("not found") || lower.contains("404") || lower.contains("no such") {
107        return ErrorCategory::NotFound;
108    }
109
110    // Check for codec errors
111    if lower.contains("codec") || lower.contains("unsupported format") || lower.contains("encoding")
112    {
113        return ErrorCategory::CodecError;
114    }
115
116    // Check for I/O errors
117    if lower.contains("permission denied")
118        || lower.contains("no space")
119        || lower.contains("disk full")
120    {
121        return ErrorCategory::IoError;
122    }
123
124    // Default to unknown
125    ErrorCategory::Unknown
126}
127
128/// Returns an actionable suggestion for a given error category.
129///
130/// # Arguments
131///
132/// * `category` - The error category to get a suggestion for
133///
134/// # Returns
135///
136/// A string containing an actionable suggestion for addressing the error.
137///
138/// # Examples
139///
140/// ```
141/// use camgrab_core::error::{suggestion_for, ErrorCategory};
142///
143/// let suggestion = suggestion_for(ErrorCategory::Auth);
144/// assert!(suggestion.contains("username and password"));
145/// ```
146pub fn suggestion_for(category: ErrorCategory) -> String {
147    match category {
148        ErrorCategory::Auth => {
149            "Check your username and password. Verify that the camera's authentication \
150             credentials are correct and that the account has sufficient permissions."
151                .to_string()
152        }
153        ErrorCategory::NetworkRefused => {
154            "Verify the camera is powered on and reachable on the network. Check that the \
155             IP address and port are correct, and ensure no firewall is blocking the connection."
156                .to_string()
157        }
158        ErrorCategory::NetworkTimeout => {
159            "The camera is not responding in time. Check your network connection, verify the \
160             camera is online, and consider increasing timeout values if the network is slow."
161                .to_string()
162        }
163        ErrorCategory::NotFound => {
164            "The requested resource does not exist. Verify the camera URL, stream path, or \
165             endpoint configuration. Check that the camera supports the requested feature."
166                .to_string()
167        }
168        ErrorCategory::CodecError => {
169            "The media format is not supported. Check that the camera's video codec settings \
170             are compatible (H.264 or H.265 recommended). Try adjusting the camera's encoding \
171             settings or updating your software."
172                .to_string()
173        }
174        ErrorCategory::IoError => {
175            "A file system error occurred. Check disk space availability, verify write \
176             permissions for the output directory, and ensure the file system is not read-only."
177                .to_string()
178        }
179        ErrorCategory::Unknown => {
180            "An unexpected error occurred. Check the error message details, review the logs \
181             for more information, and consider reporting this issue if it persists."
182                .to_string()
183        }
184    }
185}
186
187/// A classified error with category, message, and actionable suggestion.
188///
189/// This struct wraps an error message with its classification and provides
190/// helpful suggestions for resolving the issue.
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192pub struct ClassifiedError {
193    /// The error category
194    pub category: ErrorCategory,
195    /// The original error message
196    pub message: String,
197    /// An actionable suggestion for resolving the error
198    pub suggestion: String,
199}
200
201impl ClassifiedError {
202    /// Creates a new classified error from an error message.
203    ///
204    /// # Arguments
205    ///
206    /// * `message` - The error message to classify
207    ///
208    /// # Examples
209    ///
210    /// ```
211    /// use camgrab_core::error::ClassifiedError;
212    ///
213    /// let error = ClassifiedError::new("401 Unauthorized");
214    /// assert_eq!(error.category, camgrab_core::error::ErrorCategory::Auth);
215    /// assert!(error.suggestion.contains("username and password"));
216    /// ```
217    pub fn new(message: impl Into<String>) -> Self {
218        let message = message.into();
219        let category = classify_error(&message);
220        let suggestion = suggestion_for(category);
221
222        ClassifiedError {
223            category,
224            message,
225            suggestion,
226        }
227    }
228
229    /// Creates a new classified error with an explicit category.
230    ///
231    /// # Arguments
232    ///
233    /// * `category` - The error category
234    /// * `message` - The error message
235    ///
236    /// # Examples
237    ///
238    /// ```
239    /// use camgrab_core::error::{ClassifiedError, ErrorCategory};
240    ///
241    /// let error = ClassifiedError::with_category(
242    ///     ErrorCategory::NetworkTimeout,
243    ///     "Connection timed out after 30s"
244    /// );
245    /// assert_eq!(error.category, ErrorCategory::NetworkTimeout);
246    /// ```
247    pub fn with_category(category: ErrorCategory, message: impl Into<String>) -> Self {
248        let message = message.into();
249        let suggestion = suggestion_for(category);
250
251        ClassifiedError {
252            category,
253            message,
254            suggestion,
255        }
256    }
257
258    /// Creates a new classified error with a custom suggestion.
259    ///
260    /// # Arguments
261    ///
262    /// * `category` - The error category
263    /// * `message` - The error message
264    /// * `suggestion` - A custom suggestion
265    pub fn with_custom_suggestion(
266        category: ErrorCategory,
267        message: impl Into<String>,
268        suggestion: impl Into<String>,
269    ) -> Self {
270        ClassifiedError {
271            category,
272            message: message.into(),
273            suggestion: suggestion.into(),
274        }
275    }
276}
277
278impl fmt::Display for ClassifiedError {
279    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
280        write!(
281            f,
282            "{} ({}): {}",
283            self.message, self.category, self.suggestion
284        )
285    }
286}
287
288impl std::error::Error for ClassifiedError {}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn test_classify_auth_errors() {
296        assert_eq!(classify_error("401 Unauthorized"), ErrorCategory::Auth);
297        assert_eq!(classify_error("Authentication failed"), ErrorCategory::Auth);
298        assert_eq!(classify_error("403 Forbidden"), ErrorCategory::Auth);
299        assert_eq!(
300            classify_error("User is not authorized to access this resource"),
301            ErrorCategory::Auth
302        );
303    }
304
305    #[test]
306    fn test_classify_network_refused_errors() {
307        assert_eq!(
308            classify_error("connection refused"),
309            ErrorCategory::NetworkRefused
310        );
311        assert_eq!(
312            classify_error("Connection refused by peer"),
313            ErrorCategory::NetworkRefused
314        );
315        assert_eq!(
316            classify_error("The server refused the connection"),
317            ErrorCategory::NetworkRefused
318        );
319    }
320
321    #[test]
322    fn test_classify_timeout_errors() {
323        assert_eq!(
324            classify_error("request timed out"),
325            ErrorCategory::NetworkTimeout
326        );
327        assert_eq!(
328            classify_error("Connection timeout after 30s"),
329            ErrorCategory::NetworkTimeout
330        );
331        assert_eq!(
332            classify_error("deadline exceeded"),
333            ErrorCategory::NetworkTimeout
334        );
335    }
336
337    #[test]
338    fn test_classify_not_found_errors() {
339        assert_eq!(classify_error("404 Not Found"), ErrorCategory::NotFound);
340        assert_eq!(
341            classify_error("Resource not found"),
342            ErrorCategory::NotFound
343        );
344        assert_eq!(
345            classify_error("No such file or directory"),
346            ErrorCategory::NotFound
347        );
348    }
349
350    #[test]
351    fn test_classify_codec_errors() {
352        assert_eq!(
353            classify_error("Unsupported codec"),
354            ErrorCategory::CodecError
355        );
356        assert_eq!(
357            classify_error("Unsupported format: MJPEG"),
358            ErrorCategory::CodecError
359        );
360        assert_eq!(
361            classify_error("Encoding error occurred"),
362            ErrorCategory::CodecError
363        );
364    }
365
366    #[test]
367    fn test_classify_io_errors() {
368        assert_eq!(classify_error("Permission denied"), ErrorCategory::IoError);
369        assert_eq!(
370            classify_error("No space left on device"),
371            ErrorCategory::IoError
372        );
373        assert_eq!(classify_error("Disk full"), ErrorCategory::IoError);
374    }
375
376    #[test]
377    fn test_classify_unknown_errors() {
378        assert_eq!(
379            classify_error("Something went wrong"),
380            ErrorCategory::Unknown
381        );
382        assert_eq!(
383            classify_error("Unexpected error occurred"),
384            ErrorCategory::Unknown
385        );
386    }
387
388    #[test]
389    fn test_case_insensitive_classification() {
390        assert_eq!(classify_error("401 UNAUTHORIZED"), ErrorCategory::Auth);
391        assert_eq!(
392            classify_error("CONNECTION REFUSED"),
393            ErrorCategory::NetworkRefused
394        );
395        assert_eq!(classify_error("TIMED OUT"), ErrorCategory::NetworkTimeout);
396    }
397
398    #[test]
399    fn test_error_category_display() {
400        assert_eq!(ErrorCategory::Auth.to_string(), "authentication failure");
401        assert_eq!(
402            ErrorCategory::NetworkRefused.to_string(),
403            "connection refused"
404        );
405        assert_eq!(ErrorCategory::NetworkTimeout.to_string(), "network timeout");
406        assert_eq!(ErrorCategory::NotFound.to_string(), "resource not found");
407        assert_eq!(ErrorCategory::CodecError.to_string(), "codec error");
408        assert_eq!(ErrorCategory::IoError.to_string(), "I/O error");
409        assert_eq!(ErrorCategory::Unknown.to_string(), "unknown error");
410    }
411
412    #[test]
413    fn test_suggestion_for_auth() {
414        let suggestion = suggestion_for(ErrorCategory::Auth);
415        assert!(suggestion.contains("username and password"));
416        assert!(suggestion.contains("credentials"));
417    }
418
419    #[test]
420    fn test_suggestion_for_network_refused() {
421        let suggestion = suggestion_for(ErrorCategory::NetworkRefused);
422        assert!(suggestion.contains("powered on"));
423        assert!(suggestion.contains("reachable"));
424        assert!(suggestion.contains("firewall"));
425    }
426
427    #[test]
428    fn test_suggestion_for_timeout() {
429        let suggestion = suggestion_for(ErrorCategory::NetworkTimeout);
430        assert!(suggestion.contains("not responding"));
431        assert!(suggestion.contains("network connection"));
432    }
433
434    #[test]
435    fn test_suggestion_for_not_found() {
436        let suggestion = suggestion_for(ErrorCategory::NotFound);
437        assert!(suggestion.contains("does not exist"));
438        assert!(suggestion.contains("URL"));
439    }
440
441    #[test]
442    fn test_suggestion_for_codec() {
443        let suggestion = suggestion_for(ErrorCategory::CodecError);
444        assert!(suggestion.contains("format"));
445        assert!(suggestion.contains("codec"));
446        assert!(suggestion.contains("H.264"));
447    }
448
449    #[test]
450    fn test_suggestion_for_io() {
451        let suggestion = suggestion_for(ErrorCategory::IoError);
452        assert!(suggestion.contains("disk space"));
453        assert!(suggestion.contains("permissions"));
454    }
455
456    #[test]
457    fn test_suggestion_for_unknown() {
458        let suggestion = suggestion_for(ErrorCategory::Unknown);
459        assert!(suggestion.contains("unexpected"));
460        assert!(suggestion.contains("logs"));
461    }
462
463    #[test]
464    fn test_classified_error_new() {
465        let error = ClassifiedError::new("401 Unauthorized");
466        assert_eq!(error.category, ErrorCategory::Auth);
467        assert_eq!(error.message, "401 Unauthorized");
468        assert!(error.suggestion.contains("username and password"));
469    }
470
471    #[test]
472    fn test_classified_error_with_category() {
473        let error = ClassifiedError::with_category(
474            ErrorCategory::NetworkTimeout,
475            "Connection timed out after 30s",
476        );
477        assert_eq!(error.category, ErrorCategory::NetworkTimeout);
478        assert_eq!(error.message, "Connection timed out after 30s");
479        assert!(error.suggestion.contains("not responding"));
480    }
481
482    #[test]
483    fn test_classified_error_with_custom_suggestion() {
484        let error = ClassifiedError::with_custom_suggestion(
485            ErrorCategory::Auth,
486            "Login failed",
487            "Try using admin credentials",
488        );
489        assert_eq!(error.category, ErrorCategory::Auth);
490        assert_eq!(error.message, "Login failed");
491        assert_eq!(error.suggestion, "Try using admin credentials");
492    }
493
494    #[test]
495    fn test_classified_error_display() {
496        let error = ClassifiedError::new("401 Unauthorized");
497        let display = format!("{error}");
498        assert!(display.contains("401 Unauthorized"));
499        assert!(display.contains("authentication failure"));
500        assert!(display.contains("username and password"));
501    }
502
503    #[test]
504    fn test_classified_error_equality() {
505        let error1 = ClassifiedError::new("401 Unauthorized");
506        let error2 = ClassifiedError::new("401 Unauthorized");
507        assert_eq!(error1, error2);
508    }
509
510    #[test]
511    fn test_classified_error_serialization() {
512        let error = ClassifiedError::new("401 Unauthorized");
513        let json = serde_json::to_string(&error).unwrap();
514        assert!(json.contains("auth"));
515        assert!(json.contains("401 Unauthorized"));
516
517        let deserialized: ClassifiedError = serde_json::from_str(&json).unwrap();
518        assert_eq!(deserialized, error);
519    }
520
521    #[test]
522    fn test_error_category_serialization() {
523        let category = ErrorCategory::Auth;
524        let json = serde_json::to_string(&category).unwrap();
525        assert_eq!(json, "\"auth\"");
526
527        let deserialized: ErrorCategory = serde_json::from_str(&json).unwrap();
528        assert_eq!(deserialized, category);
529    }
530
531    #[test]
532    fn test_error_category_all() {
533        let all = ErrorCategory::all();
534        assert_eq!(all.len(), 7);
535        assert!(all.contains(&ErrorCategory::Auth));
536        assert!(all.contains(&ErrorCategory::NetworkRefused));
537        assert!(all.contains(&ErrorCategory::NetworkTimeout));
538        assert!(all.contains(&ErrorCategory::NotFound));
539        assert!(all.contains(&ErrorCategory::CodecError));
540        assert!(all.contains(&ErrorCategory::IoError));
541        assert!(all.contains(&ErrorCategory::Unknown));
542    }
543
544    #[test]
545    fn test_multiple_keywords_in_message() {
546        // Should prioritize first match (Auth over NetworkRefused)
547        assert_eq!(
548            classify_error("401 Unauthorized: connection refused"),
549            ErrorCategory::Auth
550        );
551    }
552
553    #[test]
554    fn test_partial_keyword_matching() {
555        // "refused" should match even within "connection refused"
556        assert_eq!(
557            classify_error("TCP connection was refused by host"),
558            ErrorCategory::NetworkRefused
559        );
560    }
561
562    #[test]
563    fn test_real_world_error_messages() {
564        // Retina RTSP errors
565        assert_eq!(
566            classify_error("RTSP DESCRIBE request failed: 401"),
567            ErrorCategory::Auth
568        );
569        assert_eq!(
570            classify_error("RTSP connection timeout"),
571            ErrorCategory::NetworkTimeout
572        );
573
574        // HTTP errors
575        assert_eq!(
576            classify_error("HTTP request failed with status 404"),
577            ErrorCategory::NotFound
578        );
579
580        // FFmpeg errors
581        assert_eq!(
582            classify_error("Unsupported codec: mjpeg"),
583            ErrorCategory::CodecError
584        );
585
586        // System errors
587        assert_eq!(
588            classify_error("Failed to write file: Permission denied"),
589            ErrorCategory::IoError
590        );
591    }
592}