diff --git a/pixstrip-core/src/error.rs b/pixstrip-core/src/error.rs index 6d278a7..1881db4 100644 --- a/pixstrip-core/src/error.rs +++ b/pixstrip-core/src/error.rs @@ -1 +1,27 @@ -// Error types +use std::path::PathBuf; + +#[derive(Debug, thiserror::Error)] +pub enum PixstripError { + #[error("Failed to load image '{path}': {reason}")] + ImageLoad { path: PathBuf, reason: String }, + + #[error("Unsupported format: {format}")] + UnsupportedFormat { format: String }, + + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("Output file already exists: {path}")] + OutputExists { path: PathBuf }, + + #[error("Processing error in '{operation}': {reason}")] + Processing { operation: String, reason: String }, + + #[error("Preset error: {0}")] + Preset(String), + + #[error("Configuration error: {0}")] + Config(String), +} + +pub type Result = std::result::Result; diff --git a/pixstrip-core/src/types.rs b/pixstrip-core/src/types.rs index 2fe9693..f512637 100644 --- a/pixstrip-core/src/types.rs +++ b/pixstrip-core/src/types.rs @@ -1 +1,147 @@ -// Core domain types for image processing +use std::fmt; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ImageFormat { + Jpeg, + Png, + WebP, + Avif, + Gif, + Tiff, +} + +impl ImageFormat { + pub fn from_extension(ext: &str) -> Option { + match ext.to_lowercase().as_str() { + "jpg" | "jpeg" => Some(Self::Jpeg), + "png" => Some(Self::Png), + "webp" => Some(Self::WebP), + "avif" => Some(Self::Avif), + "gif" => Some(Self::Gif), + "tiff" | "tif" => Some(Self::Tiff), + _ => None, + } + } + + pub fn extension(&self) -> &'static str { + match self { + Self::Jpeg => "jpg", + Self::Png => "png", + Self::WebP => "webp", + Self::Avif => "avif", + Self::Gif => "gif", + Self::Tiff => "tiff", + } + } +} + +#[derive(Debug, Clone)] +pub struct ImageSource { + pub path: PathBuf, + pub original_format: Option, +} + +impl ImageSource { + pub fn from_path(path: impl AsRef) -> Self { + let path = path.as_ref().to_path_buf(); + let original_format = path + .extension() + .and_then(|ext| ext.to_str()) + .and_then(ImageFormat::from_extension); + Self { + path, + original_format, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Dimensions { + pub width: u32, + pub height: u32, +} + +impl Dimensions { + pub fn aspect_ratio(&self) -> f64 { + self.width as f64 / self.height as f64 + } + + pub fn fit_within(self, max: Dimensions, allow_upscale: bool) -> Dimensions { + if !allow_upscale && self.width <= max.width && self.height <= max.height { + return self; + } + + let scale_w = max.width as f64 / self.width as f64; + let scale_h = max.height as f64 / self.height as f64; + let scale = scale_w.min(scale_h); + + if !allow_upscale && scale >= 1.0 { + return self; + } + + Dimensions { + width: (self.width as f64 * scale).round() as u32, + height: (self.height as f64 * scale).round() as u32, + } + } +} + +impl fmt::Display for Dimensions { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}x{}", self.width, self.height) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum QualityPreset { + Maximum, + High, + Medium, + Low, + WebOptimized, +} + +impl QualityPreset { + pub fn jpeg_quality(&self) -> u8 { + match self { + Self::Maximum => 95, + Self::High => 85, + Self::Medium => 72, + Self::Low => 55, + Self::WebOptimized => 65, + } + } + + pub fn png_level(&self) -> u8 { + match self { + Self::Maximum => 2, + Self::High => 3, + Self::Medium => 4, + Self::Low => 6, + Self::WebOptimized => 5, + } + } + + pub fn webp_quality(&self) -> f32 { + match self { + Self::Maximum => 95.0, + Self::High => 85.0, + Self::Medium => 72.0, + Self::Low => 50.0, + Self::WebOptimized => 75.0, + } + } + + pub fn label(&self) -> &'static str { + match self { + Self::Maximum => "Maximum", + Self::High => "High", + Self::Medium => "Medium", + Self::Low => "Low", + Self::WebOptimized => "Web-optimized", + } + } +} diff --git a/pixstrip-core/tests/error_tests.rs b/pixstrip-core/tests/error_tests.rs new file mode 100644 index 0000000..04e7779 --- /dev/null +++ b/pixstrip-core/tests/error_tests.rs @@ -0,0 +1,46 @@ +use pixstrip_core::error::PixstripError; + +#[test] +fn error_display_image_load() { + let err = PixstripError::ImageLoad { + path: "/tmp/bad.jpg".into(), + reason: "corrupt header".into(), + }; + let msg = err.to_string(); + assert!(msg.contains("/tmp/bad.jpg")); + assert!(msg.contains("corrupt header")); +} + +#[test] +fn error_display_unsupported_format() { + let err = PixstripError::UnsupportedFormat { + format: "bmp".into(), + }; + assert!(err.to_string().contains("bmp")); +} + +#[test] +fn error_display_io() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing"); + let err = PixstripError::Io(io_err); + assert!(err.to_string().contains("file missing")); +} + +#[test] +fn error_display_output_exists() { + let err = PixstripError::OutputExists { + path: "/tmp/out.jpg".into(), + }; + assert!(err.to_string().contains("/tmp/out.jpg")); +} + +#[test] +fn error_display_processing() { + let err = PixstripError::Processing { + operation: "resize".into(), + reason: "invalid dimensions".into(), + }; + let msg = err.to_string(); + assert!(msg.contains("resize")); + assert!(msg.contains("invalid dimensions")); +} diff --git a/pixstrip-core/tests/types_tests.rs b/pixstrip-core/tests/types_tests.rs new file mode 100644 index 0000000..5ef828b --- /dev/null +++ b/pixstrip-core/tests/types_tests.rs @@ -0,0 +1,83 @@ +use pixstrip_core::types::*; +use std::path::PathBuf; + +#[test] +fn image_format_from_extension() { + assert_eq!(ImageFormat::from_extension("jpg"), Some(ImageFormat::Jpeg)); + assert_eq!(ImageFormat::from_extension("JPEG"), Some(ImageFormat::Jpeg)); + assert_eq!(ImageFormat::from_extension("png"), Some(ImageFormat::Png)); + assert_eq!(ImageFormat::from_extension("webp"), Some(ImageFormat::WebP)); + assert_eq!(ImageFormat::from_extension("avif"), Some(ImageFormat::Avif)); + assert_eq!(ImageFormat::from_extension("gif"), Some(ImageFormat::Gif)); + assert_eq!(ImageFormat::from_extension("tiff"), Some(ImageFormat::Tiff)); + assert_eq!(ImageFormat::from_extension("tif"), Some(ImageFormat::Tiff)); + assert_eq!(ImageFormat::from_extension("xyz"), None); +} + +#[test] +fn image_format_extension_string() { + assert_eq!(ImageFormat::Jpeg.extension(), "jpg"); + assert_eq!(ImageFormat::Png.extension(), "png"); + assert_eq!(ImageFormat::WebP.extension(), "webp"); + assert_eq!(ImageFormat::Avif.extension(), "avif"); +} + +#[test] +fn image_source_from_path() { + let source = ImageSource::from_path("/tmp/photo.jpg"); + assert_eq!(source.path, PathBuf::from("/tmp/photo.jpg")); + assert_eq!(source.original_format, Some(ImageFormat::Jpeg)); +} + +#[test] +fn image_source_unknown_format() { + let source = ImageSource::from_path("/tmp/file.xyz"); + assert_eq!(source.original_format, None); +} + +#[test] +fn dimensions_display() { + let dims = Dimensions { width: 1920, height: 1080 }; + assert_eq!(dims.to_string(), "1920x1080"); +} + +#[test] +fn dimensions_aspect_ratio() { + let dims = Dimensions { width: 1920, height: 1080 }; + let ratio = dims.aspect_ratio(); + assert!((ratio - 16.0 / 9.0).abs() < 0.01); +} + +#[test] +fn dimensions_fit_within() { + let original = Dimensions { width: 4000, height: 3000 }; + let max_box = Dimensions { width: 1200, height: 1200 }; + let fitted = original.fit_within(max_box, false); + assert_eq!(fitted.width, 1200); + assert_eq!(fitted.height, 900); +} + +#[test] +fn dimensions_fit_within_no_upscale() { + let original = Dimensions { width: 800, height: 600 }; + let max_box = Dimensions { width: 1200, height: 1200 }; + let fitted = original.fit_within(max_box, false); + assert_eq!(fitted.width, 800); + assert_eq!(fitted.height, 600); +} + +#[test] +fn dimensions_fit_within_allow_upscale() { + let original = Dimensions { width: 800, height: 600 }; + let max_box = Dimensions { width: 1200, height: 1200 }; + let fitted = original.fit_within(max_box, true); + assert_eq!(fitted.width, 1200); + assert_eq!(fitted.height, 900); +} + +#[test] +fn quality_preset_values() { + assert!(QualityPreset::Maximum.jpeg_quality() > QualityPreset::High.jpeg_quality()); + assert!(QualityPreset::High.jpeg_quality() > QualityPreset::Medium.jpeg_quality()); + assert!(QualityPreset::Medium.jpeg_quality() > QualityPreset::Low.jpeg_quality()); +}