1use serde::{Deserialize, Serialize};
7use std::fmt;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum ErrorCategory {
13 Auth,
15 NetworkRefused,
17 NetworkTimeout,
19 NotFound,
21 CodecError,
23 IoError,
25 Unknown,
27}
28
29impl ErrorCategory {
30 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
59pub fn classify_error(message: &str) -> ErrorCategory {
79 let lower = message.to_lowercase();
80
81 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 if lower.contains("connection refused") || lower.contains("refused") {
94 return ErrorCategory::NetworkRefused;
95 }
96
97 if lower.contains("timed out")
99 || lower.contains("timeout")
100 || lower.contains("deadline exceeded")
101 {
102 return ErrorCategory::NetworkTimeout;
103 }
104
105 if lower.contains("not found") || lower.contains("404") || lower.contains("no such") {
107 return ErrorCategory::NotFound;
108 }
109
110 if lower.contains("codec") || lower.contains("unsupported format") || lower.contains("encoding")
112 {
113 return ErrorCategory::CodecError;
114 }
115
116 if lower.contains("permission denied")
118 || lower.contains("no space")
119 || lower.contains("disk full")
120 {
121 return ErrorCategory::IoError;
122 }
123
124 ErrorCategory::Unknown
126}
127
128pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192pub struct ClassifiedError {
193 pub category: ErrorCategory,
195 pub message: String,
197 pub suggestion: String,
199}
200
201impl ClassifiedError {
202 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 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 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 assert_eq!(
548 classify_error("401 Unauthorized: connection refused"),
549 ErrorCategory::Auth
550 );
551 }
552
553 #[test]
554 fn test_partial_keyword_matching() {
555 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 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 assert_eq!(
576 classify_error("HTTP request failed with status 404"),
577 ErrorCategory::NotFound
578 );
579
580 assert_eq!(
582 classify_error("Unsupported codec: mjpeg"),
583 ErrorCategory::CodecError
584 );
585
586 assert_eq!(
588 classify_error("Failed to write file: Permission denied"),
589 ErrorCategory::IoError
590 );
591 }
592}