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}