use thiserror::Error;
#[derive(Error, Debug)]
pub enum DotmaxError {
#[error("Invalid grid dimensions: width={width}, height={height}")]
InvalidDimensions {
width: usize,
height: usize,
},
#[error("Out of bounds access: ({x}, {y}) in grid of size ({width}, {height})")]
OutOfBounds {
x: usize,
y: usize,
width: usize,
height: usize,
},
#[error("Invalid dot index: {index} (must be 0-7)")]
InvalidDotIndex {
index: u8,
},
#[error("Terminal I/O error: {0}")]
Terminal(#[from] std::io::Error),
#[error("Terminal backend error: {0}")]
TerminalBackend(String),
#[error("Unicode conversion failed for cell ({x}, {y})")]
UnicodeConversion {
x: usize,
y: usize,
},
#[cfg(feature = "image")]
#[error("Failed to load image from {path:?}: {source}")]
ImageLoad {
path: std::path::PathBuf,
#[source]
source: image::ImageError,
},
#[cfg(feature = "image")]
#[error("Unsupported image format: {format}")]
UnsupportedFormat {
format: String,
},
#[cfg(feature = "image")]
#[error("Invalid image dimensions: {width}×{height} exceeds maximum (10,000×10,000)")]
InvalidImageDimensions {
width: u32,
height: u32,
},
#[cfg(feature = "image")]
#[error("Invalid {parameter_name}: {value} (valid range: {min}-{max})")]
InvalidParameter {
parameter_name: String,
value: String,
min: String,
max: String,
},
#[cfg(feature = "svg")]
#[error("SVG rendering error: {0}")]
SvgError(String),
#[error("Invalid line thickness: {thickness} (must be ≥ 1)")]
InvalidThickness {
thickness: u32,
},
#[error("Invalid polygon: {reason}")]
InvalidPolygon {
reason: String,
},
#[error("Density set cannot be empty")]
EmptyDensitySet,
#[error("Density set has too many characters: {count} (max 256)")]
TooManyCharacters {
count: usize,
},
#[error(
"Intensity buffer size mismatch: expected {expected} (grid width × height), got {actual}"
)]
BufferSizeMismatch {
expected: usize,
actual: usize,
},
#[error("Color scheme cannot be empty: at least one color is required")]
EmptyColorScheme,
#[error("Invalid color scheme: {0}")]
InvalidColorScheme(String),
#[error("Invalid intensity value: {0} (must be 0.0-1.0)")]
InvalidIntensity(f32),
#[error("Unsupported media format: {format}. Supported: static (PNG, JPEG, GIF, BMP, WebP, TIFF), vector (SVG), animated (GIF, APNG), video (MP4, MKV, AVI, WebM)")]
FormatError {
format: String,
},
#[cfg(feature = "image")]
#[error("GIF error for {path:?}: {message}")]
GifError {
path: std::path::PathBuf,
message: String,
},
#[cfg(feature = "image")]
#[error("APNG error for {path:?}: {message}")]
ApngError {
path: std::path::PathBuf,
message: String,
},
#[cfg(feature = "video")]
#[error("Video error for {path:?}: {message}")]
VideoError {
path: std::path::PathBuf,
message: String,
},
#[cfg(feature = "video")]
#[error("Webcam error for {device}: {message}")]
WebcamError {
device: String,
message: String,
},
#[cfg(feature = "video")]
#[error("Camera not found: {device}. Available cameras: {}", if available.is_empty() { "none detected".to_string() } else { available.join(", ") })]
CameraNotFound {
device: String,
available: Vec<String>,
},
#[cfg(feature = "video")]
#[error("Camera permission denied: {device}. {hint}")]
CameraPermissionDenied {
device: String,
hint: String,
},
#[cfg(feature = "video")]
#[error("Camera in use: {device}. Close other applications that may be using the camera (video conferencing, browsers, etc.)")]
CameraInUse {
device: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_invalid_dimensions_message_includes_context() {
let err = DotmaxError::InvalidDimensions {
width: 0,
height: 10,
};
let msg = format!("{err}");
assert!(msg.contains('0'));
assert!(msg.contains("10"));
assert!(msg.contains("width"));
assert!(msg.contains("height"));
}
#[test]
fn test_invalid_color_scheme_message_includes_reason() {
let err = DotmaxError::InvalidColorScheme("at least 2 colors required".into());
let msg = format!("{err}");
assert!(msg.contains("Invalid color scheme"));
assert!(msg.contains("at least 2 colors required"));
}
#[test]
fn test_invalid_color_scheme_duplicate_intensity() {
let err = DotmaxError::InvalidColorScheme("duplicate intensity value".into());
let msg = format!("{err}");
assert!(msg.contains("Invalid color scheme"));
assert!(msg.contains("duplicate"));
}
#[test]
fn test_invalid_intensity_negative() {
let err = DotmaxError::InvalidIntensity(-0.5);
let msg = format!("{err}");
assert!(msg.contains("Invalid intensity value"));
assert!(msg.contains("-0.5"));
assert!(msg.contains("0.0-1.0"));
}
#[test]
fn test_invalid_intensity_above_one() {
let err = DotmaxError::InvalidIntensity(1.5);
let msg = format!("{err}");
assert!(msg.contains("Invalid intensity value"));
assert!(msg.contains("1.5"));
assert!(msg.contains("0.0-1.0"));
}
#[test]
fn test_out_of_bounds_message_includes_all_context() {
let err = DotmaxError::OutOfBounds {
x: 100,
y: 50,
width: 80,
height: 24,
};
let msg = format!("{err}");
assert!(msg.contains("100"));
assert!(msg.contains("50"));
assert!(msg.contains("80"));
assert!(msg.contains("24"));
}
#[test]
fn test_invalid_dot_index_message_includes_index() {
let err = DotmaxError::InvalidDotIndex { index: 10 };
let msg = format!("{err}");
assert!(msg.contains("10"));
assert!(msg.contains("0-7"));
}
#[test]
fn test_unicode_conversion_message_includes_coordinates() {
let err = DotmaxError::UnicodeConversion { x: 15, y: 20 };
let msg = format!("{err}");
assert!(msg.contains("15"));
assert!(msg.contains("20"));
}
#[test]
fn test_terminal_backend_message() {
let err = DotmaxError::TerminalBackend("Test error".to_string());
let msg = format!("{err}");
assert!(msg.contains("Test error"));
assert!(msg.contains("Terminal backend error"));
}
#[test]
fn test_io_error_automatic_conversion() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "test file");
let dotmax_err: DotmaxError = io_err.into();
assert!(matches!(dotmax_err, DotmaxError::Terminal(_)));
}
#[test]
fn test_io_error_preserves_source() {
let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "access denied");
let dotmax_err: DotmaxError = io_err.into();
match dotmax_err {
DotmaxError::Terminal(inner) => {
assert_eq!(inner.kind(), std::io::ErrorKind::PermissionDenied);
assert!(inner.to_string().contains("access denied"));
}
_ => panic!("Expected Terminal variant"),
}
}
#[cfg(feature = "image")]
#[test]
fn test_image_load_error_includes_path_and_source() {
use std::path::PathBuf;
let err = DotmaxError::ImageLoad {
path: PathBuf::from("/path/to/image.png"),
source: image::ImageError::IoError(std::io::Error::new(
std::io::ErrorKind::NotFound,
"file not found",
)),
};
let msg = format!("{err}");
assert!(msg.contains("image.png"));
assert!(msg.contains("Failed to load"));
}
#[cfg(feature = "image")]
#[test]
fn test_unsupported_format_error_includes_format() {
let err = DotmaxError::UnsupportedFormat {
format: "xyz".to_string(),
};
let msg = format!("{err}");
assert!(msg.contains("xyz"));
assert!(msg.contains("Unsupported"));
}
#[cfg(feature = "image")]
#[test]
fn test_invalid_image_dimensions_includes_dimensions() {
let err = DotmaxError::InvalidImageDimensions {
width: 15_000,
height: 20_000,
};
let msg = format!("{err}");
assert!(msg.contains("15000") || msg.contains("15,000"));
assert!(msg.contains("20000") || msg.contains("20,000"));
assert!(msg.contains("10,000"));
}
#[cfg(feature = "image")]
#[test]
fn test_invalid_parameter_includes_all_context() {
let err = DotmaxError::InvalidParameter {
parameter_name: "brightness factor".to_string(),
value: "3.5".to_string(),
min: "0.0".to_string(),
max: "2.0".to_string(),
};
let msg = format!("{err}");
assert!(msg.contains("brightness factor"));
assert!(msg.contains("3.5"));
assert!(msg.contains("0.0"));
assert!(msg.contains("2.0"));
assert!(msg.contains("Invalid"));
}
#[test]
fn test_format_error_includes_format_name() {
let err = DotmaxError::FormatError {
format: "unknown format".to_string(),
};
let msg = format!("{err}");
assert!(msg.contains("unknown format"));
assert!(msg.contains("Unsupported media format"));
}
#[test]
fn test_format_error_includes_supported_formats() {
let err = DotmaxError::FormatError {
format: "xyz".to_string(),
};
let msg = format!("{err}");
assert!(msg.contains("PNG"));
assert!(msg.contains("JPEG"));
assert!(msg.contains("GIF"));
assert!(msg.contains("SVG"));
assert!(msg.contains("APNG"));
assert!(msg.contains("MP4"));
assert!(msg.contains("MKV"));
}
#[cfg(feature = "video")]
#[test]
fn test_webcam_error_includes_device_and_message() {
let err = DotmaxError::WebcamError {
device: "/dev/video0".to_string(),
message: "Failed to open device".to_string(),
};
let msg = format!("{err}");
assert!(msg.contains("/dev/video0"));
assert!(msg.contains("Failed to open device"));
assert!(msg.contains("Webcam error"));
}
#[cfg(feature = "video")]
#[test]
fn test_camera_not_found_includes_device_and_available_list() {
let err = DotmaxError::CameraNotFound {
device: "/dev/video5".to_string(),
available: vec!["/dev/video0".to_string(), "/dev/video1".to_string()],
};
let msg = format!("{err}");
assert!(msg.contains("/dev/video5"));
assert!(msg.contains("/dev/video0"));
assert!(msg.contains("/dev/video1"));
assert!(msg.contains("Camera not found"));
assert!(msg.contains("Available cameras"));
}
#[cfg(feature = "video")]
#[test]
fn test_camera_not_found_empty_available_list() {
let err = DotmaxError::CameraNotFound {
device: "camera0".to_string(),
available: vec![],
};
let msg = format!("{err}");
assert!(msg.contains("camera0"));
assert!(msg.contains("none detected"));
}
#[cfg(feature = "video")]
#[test]
fn test_camera_permission_denied_includes_hint() {
let err = DotmaxError::CameraPermissionDenied {
device: "/dev/video0".to_string(),
hint: "Add user to video group".to_string(),
};
let msg = format!("{err}");
assert!(msg.contains("/dev/video0"));
assert!(msg.contains("Add user to video group"));
assert!(msg.contains("permission denied"));
}
#[cfg(feature = "video")]
#[test]
fn test_camera_in_use_includes_remediation() {
let err = DotmaxError::CameraInUse {
device: "Integrated Camera".to_string(),
};
let msg = format!("{err}");
assert!(msg.contains("Integrated Camera"));
assert!(msg.contains("in use"));
assert!(msg.contains("Close other applications"));
}
}