dotmax/error.rs
1//! Error types for dotmax operations
2//!
3//! This module defines `DotmaxError`, the primary error type returned by all
4//! public dotmax APIs. All errors include contextual information (coordinates,
5//! dimensions, indices) to aid debugging.
6//!
7//! # Zero Panics Policy
8//!
9//! All public API methods return `Result<T, DotmaxError>` instead of panicking.
10//! This ensures applications can gracefully handle all error conditions.
11//!
12//! # Examples
13//!
14//! ```
15//! use dotmax::{BrailleGrid, DotmaxError};
16//!
17//! // Create grid with invalid dimensions
18//! let result = BrailleGrid::new(0, 10);
19//! match result {
20//! Err(DotmaxError::InvalidDimensions { width, height }) => {
21//! println!("Invalid dimensions: {}×{}", width, height);
22//! }
23//! _ => unreachable!(),
24//! }
25//!
26//! // Access out-of-bounds coordinates
27//! let mut grid = BrailleGrid::new(10, 10).unwrap();
28//! let result = grid.set_dot(100, 50);
29//! match result {
30//! Err(DotmaxError::OutOfBounds { x, y, width, height }) => {
31//! println!("({}, {}) is outside {}×{} grid", x, y, width, height);
32//! }
33//! _ => unreachable!(),
34//! }
35//! ```
36
37use thiserror::Error;
38
39/// Comprehensive error type for all dotmax operations
40///
41/// All variants include contextual information to aid debugging and provide
42/// actionable error messages to end users.
43#[derive(Error, Debug)]
44pub enum DotmaxError {
45 /// Grid dimensions are invalid (zero or exceeding maximum limits)
46 ///
47 /// Valid dimensions must satisfy:
48 /// - `width > 0 && width <= 10,000`
49 /// - `height > 0 && height <= 10,000`
50 #[error("Invalid grid dimensions: width={width}, height={height}")]
51 InvalidDimensions {
52 /// The invalid width value
53 width: usize,
54 /// The invalid height value
55 height: usize,
56 },
57
58 /// Coordinate access is outside grid boundaries
59 ///
60 /// Valid coordinates must satisfy:
61 /// - `x < width`
62 /// - `y < height`
63 #[error("Out of bounds access: ({x}, {y}) in grid of size ({width}, {height})")]
64 OutOfBounds {
65 /// The X coordinate that was out of bounds
66 x: usize,
67 /// The Y coordinate that was out of bounds
68 y: usize,
69 /// The grid width
70 width: usize,
71 /// The grid height
72 height: usize,
73 },
74
75 /// Dot index is invalid (must be 0-7 for 2×4 braille cells)
76 ///
77 /// Valid dot indices:
78 /// ```text
79 /// 0 3 (positions in braille cell)
80 /// 1 4
81 /// 2 5
82 /// 6 7
83 /// ```
84 #[error("Invalid dot index: {index} (must be 0-7)")]
85 InvalidDotIndex {
86 /// The invalid dot index (must be 0-7)
87 index: u8,
88 },
89
90 /// Terminal I/O error from underlying terminal backend
91 ///
92 /// This wraps `std::io::Error` using `#[from]` to preserve the error source
93 /// chain for proper debugging and error context propagation.
94 #[error("Terminal I/O error: {0}")]
95 Terminal(#[from] std::io::Error),
96
97 /// Terminal backend operation failed
98 ///
99 /// Used for terminal-specific errors that don't map to standard I/O errors
100 /// (e.g., capability detection failures, initialization errors).
101 #[error("Terminal backend error: {0}")]
102 TerminalBackend(String),
103
104 /// Unicode braille character conversion failed
105 ///
106 /// This should rarely occur as braille Unicode range (U+2800–U+28FF) is
107 /// well-defined, but may happen if cell data becomes corrupted.
108 #[error("Unicode conversion failed for cell ({x}, {y})")]
109 UnicodeConversion {
110 /// The X coordinate of the cell
111 x: usize,
112 /// The Y coordinate of the cell
113 y: usize,
114 },
115
116 /// Image loading failed (file not found, decode error, etc.)
117 ///
118 /// This error wraps the underlying `image::ImageError` using `#[source]`
119 /// to preserve the error chain for debugging.
120 ///
121 /// Common causes:
122 /// - File does not exist or is not readable
123 /// - File format is corrupted or unsupported
124 /// - Memory allocation failure during decode
125 #[cfg(feature = "image")]
126 #[error("Failed to load image from {path:?}: {source}")]
127 ImageLoad {
128 /// Path to the image file
129 path: std::path::PathBuf,
130 /// Underlying image loading error
131 #[source]
132 source: image::ImageError,
133 },
134
135 /// Unsupported image format
136 ///
137 /// The provided file or byte buffer is not in a supported image format.
138 /// See [`crate::image::supported_formats`] for the list of valid formats.
139 #[cfg(feature = "image")]
140 #[error("Unsupported image format: {format}")]
141 UnsupportedFormat {
142 /// The unsupported format name
143 format: String,
144 },
145
146 /// Image dimensions exceed maximum limits
147 ///
148 /// Images larger than 10,000×10,000 pixels are rejected to prevent
149 /// memory exhaustion attacks.
150 #[cfg(feature = "image")]
151 #[error("Invalid image dimensions: {width}×{height} exceeds maximum (10,000×10,000)")]
152 InvalidImageDimensions {
153 /// The image width in pixels
154 width: u32,
155 /// The image height in pixels
156 height: u32,
157 },
158
159 /// Invalid parameter value provided to image processing function
160 ///
161 /// This error is returned when a function parameter (brightness, contrast,
162 /// gamma, etc.) is outside its valid range.
163 ///
164 /// The error message includes:
165 /// - Parameter name (e.g., "brightness factor")
166 /// - Provided value
167 /// - Valid range (min-max)
168 #[cfg(feature = "image")]
169 #[error("Invalid {parameter_name}: {value} (valid range: {min}-{max})")]
170 InvalidParameter {
171 /// Name of the invalid parameter
172 parameter_name: String,
173 /// The invalid value provided
174 value: String,
175 /// Minimum valid value
176 min: String,
177 /// Maximum valid value
178 max: String,
179 },
180
181 /// SVG rendering error (parsing or rasterization failure)
182 ///
183 /// This error is returned when SVG loading fails due to:
184 /// - Malformed or invalid SVG syntax
185 /// - Unsupported SVG features (complex filters, animations)
186 /// - Rasterization failures (pixmap creation, rendering errors)
187 /// - Font loading issues for text-heavy SVGs
188 ///
189 /// The error message includes descriptive context to aid debugging.
190 #[cfg(feature = "svg")]
191 #[error("SVG rendering error: {0}")]
192 SvgError(String),
193
194 /// Invalid line thickness (must be ≥ 1)
195 ///
196 /// This error is returned when attempting to draw a line with thickness=0.
197 /// Valid thickness values must be at least 1. For braille resolution,
198 /// recommended maximum is 10 dots.
199 #[error("Invalid line thickness: {thickness} (must be ≥ 1)")]
200 InvalidThickness {
201 /// The invalid thickness value
202 thickness: u32,
203 },
204
205 /// Invalid polygon definition
206 ///
207 /// This error is returned when attempting to draw a polygon with invalid
208 /// parameters (e.g., fewer than 3 vertices, empty vertex list).
209 /// Polygons require at least 3 vertices to form a closed shape.
210 #[error("Invalid polygon: {reason}")]
211 InvalidPolygon {
212 /// The reason the polygon is invalid
213 reason: String,
214 },
215
216 /// Density set cannot be empty
217 ///
218 /// This error is returned when attempting to create a `DensitySet` with an
219 /// empty character list. A valid density set must contain at least one
220 /// character for intensity mapping.
221 #[error("Density set cannot be empty")]
222 EmptyDensitySet,
223
224 /// Density set has too many characters (max 256)
225 ///
226 /// This error is returned when attempting to create a `DensitySet` with more
227 /// than 256 characters. The limit ensures reasonable memory usage and
228 /// mapping performance.
229 #[error("Density set has too many characters: {count} (max 256)")]
230 TooManyCharacters {
231 /// The number of characters in the set
232 count: usize,
233 },
234
235 /// Intensity buffer size mismatch with grid dimensions
236 ///
237 /// This error is returned when the intensity buffer length does not match
238 /// the expected grid size (width × height). All intensity buffers must
239 /// have exactly one f32 value per grid cell.
240 #[error(
241 "Intensity buffer size mismatch: expected {expected} (grid width × height), got {actual}"
242 )]
243 BufferSizeMismatch {
244 /// Expected buffer size (grid width × height)
245 expected: usize,
246 /// Actual buffer size provided
247 actual: usize,
248 },
249
250 /// Color scheme cannot have an empty color list
251 ///
252 /// This error is returned when attempting to create a `ColorScheme` with an
253 /// empty color vector. A valid color scheme must contain at least one color
254 /// stop for intensity mapping.
255 #[error("Color scheme cannot be empty: at least one color is required")]
256 EmptyColorScheme,
257
258 /// Invalid color scheme configuration
259 ///
260 /// This error is returned when attempting to build a `ColorScheme` with an
261 /// invalid configuration. Common causes include:
262 /// - Fewer than 2 color stops defined
263 /// - Duplicate intensity values at the same position
264 ///
265 /// The error message provides specific details about the validation failure.
266 #[error("Invalid color scheme: {0}")]
267 InvalidColorScheme(String),
268
269 /// Invalid intensity value for color scheme
270 ///
271 /// This error is returned when a color stop's intensity value is outside
272 /// the valid range of 0.0 to 1.0 (inclusive).
273 ///
274 /// Valid intensity values must satisfy: `0.0 <= intensity <= 1.0`
275 #[error("Invalid intensity value: {0} (must be 0.0-1.0)")]
276 InvalidIntensity(f32),
277
278 /// Unsupported or unknown media format
279 ///
280 /// This error is returned when attempting to display or load a file
281 /// with an unsupported or unrecognized format. The format detection
282 /// system could not identify the file type from magic bytes or extension.
283 ///
284 /// Supported formats include:
285 /// - Static images: PNG, JPEG, GIF (single frame), BMP, WebP, TIFF
286 /// - Vector graphics: SVG (requires `svg` feature)
287 /// - Animated: GIF (multi-frame), APNG
288 /// - Video: MP4, MKV, AVI, WebM (requires `video` feature)
289 #[error("Unsupported media format: {format}. Supported: static (PNG, JPEG, GIF, BMP, WebP, TIFF), vector (SVG), animated (GIF, APNG), video (MP4, MKV, AVI, WebM)")]
290 FormatError {
291 /// Description of the detected or unknown format
292 format: String,
293 },
294
295 /// GIF decoding or playback error
296 ///
297 /// This error is returned when a GIF file cannot be decoded or played back.
298 /// Common causes include:
299 /// - Corrupted GIF file
300 /// - Invalid GIF structure
301 /// - Memory allocation failure during decode
302 /// - Frame decode errors
303 #[cfg(feature = "image")]
304 #[error("GIF error for {path:?}: {message}")]
305 GifError {
306 /// Path to the GIF file
307 path: std::path::PathBuf,
308 /// Error message
309 message: String,
310 },
311
312 /// APNG decoding or playback error
313 ///
314 /// This error is returned when an APNG file cannot be decoded or played back.
315 /// Common causes include:
316 /// - Corrupted APNG file or invalid chunk structure
317 /// - Missing or invalid animation control (acTL) chunk
318 /// - Missing or invalid frame control (fcTL) chunks
319 /// - Memory allocation failure during decode
320 /// - Frame decode errors
321 #[cfg(feature = "image")]
322 #[error("APNG error for {path:?}: {message}")]
323 ApngError {
324 /// Path to the APNG file
325 path: std::path::PathBuf,
326 /// Error message
327 message: String,
328 },
329
330 /// Video decoding or playback error
331 ///
332 /// This error is returned when a video file cannot be decoded or played back.
333 /// Common causes include:
334 /// - File not found or cannot be opened
335 /// - No video stream found in container
336 /// - Unsupported video codec
337 /// - FFmpeg initialization failure
338 /// - Frame decode errors
339 ///
340 /// Requires the `video` feature and FFmpeg system libraries.
341 #[cfg(feature = "video")]
342 #[error("Video error for {path:?}: {message}")]
343 VideoError {
344 /// Path to the video file
345 path: std::path::PathBuf,
346 /// Error message
347 message: String,
348 },
349
350 /// Webcam capture error
351 ///
352 /// This error is returned when a webcam cannot be accessed or captured from.
353 /// Common causes include:
354 /// - No webcam detected on the system
355 /// - Camera is in use by another application
356 /// - Permission denied to access camera
357 /// - Invalid device ID or path
358 /// - FFmpeg device capture initialization failure
359 ///
360 /// Requires the `video` feature and FFmpeg system libraries.
361 #[cfg(feature = "video")]
362 #[error("Webcam error for {device}: {message}")]
363 WebcamError {
364 /// Device identifier (path, name, or index)
365 device: String,
366 /// Error message
367 message: String,
368 },
369
370 /// Camera device not found
371 ///
372 /// This error is returned when the specified camera device cannot be found.
373 /// The error includes a list of available cameras to help the user select
374 /// a valid device.
375 ///
376 /// # Remediation
377 ///
378 /// - Check the device path/name is correct
379 /// - Use `list_webcams()` to see available devices
380 /// - Ensure the camera is connected and recognized by the OS
381 #[cfg(feature = "video")]
382 #[error("Camera not found: {device}. Available cameras: {}", if available.is_empty() { "none detected".to_string() } else { available.join(", ") })]
383 CameraNotFound {
384 /// The device that was requested but not found
385 device: String,
386 /// List of available camera names/paths
387 available: Vec<String>,
388 },
389
390 /// Camera permission denied
391 ///
392 /// This error is returned when the application lacks permission to access
393 /// the camera. This is common on systems with privacy controls.
394 ///
395 /// # Remediation
396 ///
397 /// - **Linux**: Add user to `video` group (`sudo usermod -aG video $USER`)
398 /// - **macOS**: Grant camera access in System Preferences > Security & Privacy
399 /// - **Windows**: Grant camera access in Settings > Privacy > Camera
400 #[cfg(feature = "video")]
401 #[error("Camera permission denied: {device}. {hint}")]
402 CameraPermissionDenied {
403 /// The device that permission was denied for
404 device: String,
405 /// Platform-specific remediation hint
406 hint: String,
407 },
408
409 /// Camera is in use by another application
410 ///
411 /// This error is returned when the camera is exclusively locked by another
412 /// process. Most cameras can only be used by one application at a time.
413 ///
414 /// # Remediation
415 ///
416 /// - Close other applications that might be using the camera
417 /// - Check for video conferencing apps (Zoom, Teams, Meet, etc.)
418 /// - Check for other terminal applications using the camera
419 #[cfg(feature = "video")]
420 #[error("Camera in use: {device}. Close other applications that may be using the camera (video conferencing, browsers, etc.)")]
421 CameraInUse {
422 /// The device that is in use
423 device: String,
424 },
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[test]
432 fn test_invalid_dimensions_message_includes_context() {
433 let err = DotmaxError::InvalidDimensions {
434 width: 0,
435 height: 10,
436 };
437 let msg = format!("{err}");
438 assert!(msg.contains('0'));
439 assert!(msg.contains("10"));
440 assert!(msg.contains("width"));
441 assert!(msg.contains("height"));
442 }
443
444 // ========================================================================
445 // Story 5.4: InvalidColorScheme and InvalidIntensity Error Tests
446 // ========================================================================
447
448 #[test]
449 fn test_invalid_color_scheme_message_includes_reason() {
450 let err = DotmaxError::InvalidColorScheme("at least 2 colors required".into());
451 let msg = format!("{err}");
452 assert!(msg.contains("Invalid color scheme"));
453 assert!(msg.contains("at least 2 colors required"));
454 }
455
456 #[test]
457 fn test_invalid_color_scheme_duplicate_intensity() {
458 let err = DotmaxError::InvalidColorScheme("duplicate intensity value".into());
459 let msg = format!("{err}");
460 assert!(msg.contains("Invalid color scheme"));
461 assert!(msg.contains("duplicate"));
462 }
463
464 #[test]
465 fn test_invalid_intensity_negative() {
466 let err = DotmaxError::InvalidIntensity(-0.5);
467 let msg = format!("{err}");
468 assert!(msg.contains("Invalid intensity value"));
469 assert!(msg.contains("-0.5"));
470 assert!(msg.contains("0.0-1.0"));
471 }
472
473 #[test]
474 fn test_invalid_intensity_above_one() {
475 let err = DotmaxError::InvalidIntensity(1.5);
476 let msg = format!("{err}");
477 assert!(msg.contains("Invalid intensity value"));
478 assert!(msg.contains("1.5"));
479 assert!(msg.contains("0.0-1.0"));
480 }
481
482 #[test]
483 fn test_out_of_bounds_message_includes_all_context() {
484 let err = DotmaxError::OutOfBounds {
485 x: 100,
486 y: 50,
487 width: 80,
488 height: 24,
489 };
490 let msg = format!("{err}");
491 assert!(msg.contains("100"));
492 assert!(msg.contains("50"));
493 assert!(msg.contains("80"));
494 assert!(msg.contains("24"));
495 }
496
497 #[test]
498 fn test_invalid_dot_index_message_includes_index() {
499 let err = DotmaxError::InvalidDotIndex { index: 10 };
500 let msg = format!("{err}");
501 assert!(msg.contains("10"));
502 assert!(msg.contains("0-7"));
503 }
504
505 #[test]
506 fn test_unicode_conversion_message_includes_coordinates() {
507 let err = DotmaxError::UnicodeConversion { x: 15, y: 20 };
508 let msg = format!("{err}");
509 assert!(msg.contains("15"));
510 assert!(msg.contains("20"));
511 }
512
513 #[test]
514 fn test_terminal_backend_message() {
515 let err = DotmaxError::TerminalBackend("Test error".to_string());
516 let msg = format!("{err}");
517 assert!(msg.contains("Test error"));
518 assert!(msg.contains("Terminal backend error"));
519 }
520
521 #[test]
522 fn test_io_error_automatic_conversion() {
523 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "test file");
524 let dotmax_err: DotmaxError = io_err.into();
525 assert!(matches!(dotmax_err, DotmaxError::Terminal(_)));
526 }
527
528 #[test]
529 fn test_io_error_preserves_source() {
530 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
531 let dotmax_err: DotmaxError = io_err.into();
532
533 match dotmax_err {
534 DotmaxError::Terminal(inner) => {
535 assert_eq!(inner.kind(), std::io::ErrorKind::PermissionDenied);
536 assert!(inner.to_string().contains("access denied"));
537 }
538 _ => panic!("Expected Terminal variant"),
539 }
540 }
541
542 #[cfg(feature = "image")]
543 #[test]
544 fn test_image_load_error_includes_path_and_source() {
545 use std::path::PathBuf;
546 let err = DotmaxError::ImageLoad {
547 path: PathBuf::from("/path/to/image.png"),
548 source: image::ImageError::IoError(std::io::Error::new(
549 std::io::ErrorKind::NotFound,
550 "file not found",
551 )),
552 };
553 let msg = format!("{err}");
554 assert!(msg.contains("image.png"));
555 assert!(msg.contains("Failed to load"));
556 }
557
558 #[cfg(feature = "image")]
559 #[test]
560 fn test_unsupported_format_error_includes_format() {
561 let err = DotmaxError::UnsupportedFormat {
562 format: "xyz".to_string(),
563 };
564 let msg = format!("{err}");
565 assert!(msg.contains("xyz"));
566 assert!(msg.contains("Unsupported"));
567 }
568
569 #[cfg(feature = "image")]
570 #[test]
571 fn test_invalid_image_dimensions_includes_dimensions() {
572 let err = DotmaxError::InvalidImageDimensions {
573 width: 15_000,
574 height: 20_000,
575 };
576 let msg = format!("{err}");
577 assert!(msg.contains("15000") || msg.contains("15,000"));
578 assert!(msg.contains("20000") || msg.contains("20,000"));
579 assert!(msg.contains("10,000"));
580 }
581
582 #[cfg(feature = "image")]
583 #[test]
584 fn test_invalid_parameter_includes_all_context() {
585 let err = DotmaxError::InvalidParameter {
586 parameter_name: "brightness factor".to_string(),
587 value: "3.5".to_string(),
588 min: "0.0".to_string(),
589 max: "2.0".to_string(),
590 };
591 let msg = format!("{err}");
592 assert!(msg.contains("brightness factor"));
593 assert!(msg.contains("3.5"));
594 assert!(msg.contains("0.0"));
595 assert!(msg.contains("2.0"));
596 assert!(msg.contains("Invalid"));
597 }
598
599 // ========================================================================
600 // Story 9.1: FormatError Tests (AC: #6)
601 // ========================================================================
602
603 #[test]
604 fn test_format_error_includes_format_name() {
605 let err = DotmaxError::FormatError {
606 format: "unknown format".to_string(),
607 };
608 let msg = format!("{err}");
609 assert!(msg.contains("unknown format"));
610 assert!(msg.contains("Unsupported media format"));
611 }
612
613 #[test]
614 fn test_format_error_includes_supported_formats() {
615 let err = DotmaxError::FormatError {
616 format: "xyz".to_string(),
617 };
618 let msg = format!("{err}");
619 // Static formats
620 assert!(msg.contains("PNG"));
621 assert!(msg.contains("JPEG"));
622 assert!(msg.contains("GIF"));
623 // Vector formats
624 assert!(msg.contains("SVG"));
625 // Animated formats
626 assert!(msg.contains("APNG"));
627 // Video formats
628 assert!(msg.contains("MP4"));
629 assert!(msg.contains("MKV"));
630 }
631
632 // ========================================================================
633 // Story 9.6: Webcam Error Tests (AC: #7)
634 // ========================================================================
635
636 #[cfg(feature = "video")]
637 #[test]
638 fn test_webcam_error_includes_device_and_message() {
639 let err = DotmaxError::WebcamError {
640 device: "/dev/video0".to_string(),
641 message: "Failed to open device".to_string(),
642 };
643 let msg = format!("{err}");
644 assert!(msg.contains("/dev/video0"));
645 assert!(msg.contains("Failed to open device"));
646 assert!(msg.contains("Webcam error"));
647 }
648
649 #[cfg(feature = "video")]
650 #[test]
651 fn test_camera_not_found_includes_device_and_available_list() {
652 let err = DotmaxError::CameraNotFound {
653 device: "/dev/video5".to_string(),
654 available: vec!["/dev/video0".to_string(), "/dev/video1".to_string()],
655 };
656 let msg = format!("{err}");
657 assert!(msg.contains("/dev/video5"));
658 assert!(msg.contains("/dev/video0"));
659 assert!(msg.contains("/dev/video1"));
660 assert!(msg.contains("Camera not found"));
661 assert!(msg.contains("Available cameras"));
662 }
663
664 #[cfg(feature = "video")]
665 #[test]
666 fn test_camera_not_found_empty_available_list() {
667 let err = DotmaxError::CameraNotFound {
668 device: "camera0".to_string(),
669 available: vec![],
670 };
671 let msg = format!("{err}");
672 assert!(msg.contains("camera0"));
673 assert!(msg.contains("none detected"));
674 }
675
676 #[cfg(feature = "video")]
677 #[test]
678 fn test_camera_permission_denied_includes_hint() {
679 let err = DotmaxError::CameraPermissionDenied {
680 device: "/dev/video0".to_string(),
681 hint: "Add user to video group".to_string(),
682 };
683 let msg = format!("{err}");
684 assert!(msg.contains("/dev/video0"));
685 assert!(msg.contains("Add user to video group"));
686 assert!(msg.contains("permission denied"));
687 }
688
689 #[cfg(feature = "video")]
690 #[test]
691 fn test_camera_in_use_includes_remediation() {
692 let err = DotmaxError::CameraInUse {
693 device: "Integrated Camera".to_string(),
694 };
695 let msg = format!("{err}");
696 assert!(msg.contains("Integrated Camera"));
697 assert!(msg.contains("in use"));
698 assert!(msg.contains("Close other applications"));
699 }
700}