diff --git a/pixstrip-core/src/discovery.rs b/pixstrip-core/src/discovery.rs new file mode 100644 index 0000000..21fc102 --- /dev/null +++ b/pixstrip-core/src/discovery.rs @@ -0,0 +1,57 @@ +use std::path::{Path, PathBuf}; + +use walkdir::WalkDir; + +const IMAGE_EXTENSIONS: &[&str] = &[ + "jpg", "jpeg", "png", "webp", "avif", "gif", "tiff", "tif", "bmp", "heic", "heif", "jxl", + "svg", "ico", +]; + +fn is_image_extension(ext: &str) -> bool { + IMAGE_EXTENSIONS.contains(&ext.to_lowercase().as_str()) +} + +pub fn discover_images(path: &Path, recursive: bool) -> Vec { + if path.is_file() { + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + if is_image_extension(ext) { + return vec![path.to_path_buf()]; + } + } + return Vec::new(); + } + + if !path.is_dir() { + return Vec::new(); + } + + let max_depth = if recursive { usize::MAX } else { 1 }; + + WalkDir::new(path) + .max_depth(max_depth) + .into_iter() + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.file_type().is_file()) + .filter(|entry| { + entry + .path() + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(is_image_extension) + }) + .map(|entry| entry.into_path()) + .collect() +} + +pub fn has_subdirectories(path: &Path) -> bool { + if !path.is_dir() { + return false; + } + std::fs::read_dir(path) + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .any(|e| e.file_type().is_ok_and(|ft| ft.is_dir())) + }) + .unwrap_or(false) +} diff --git a/pixstrip-core/src/lib.rs b/pixstrip-core/src/lib.rs index 6871bbf..2c6699f 100644 --- a/pixstrip-core/src/lib.rs +++ b/pixstrip-core/src/lib.rs @@ -1,5 +1,7 @@ pub mod config; +pub mod discovery; pub mod error; +pub mod loader; pub mod operations; pub mod pipeline; pub mod preset; diff --git a/pixstrip-core/src/loader.rs b/pixstrip-core/src/loader.rs new file mode 100644 index 0000000..9f5098a --- /dev/null +++ b/pixstrip-core/src/loader.rs @@ -0,0 +1,69 @@ +use std::path::Path; + +use crate::error::{PixstripError, Result}; +use crate::types::{Dimensions, ImageFormat}; + +#[derive(Debug, Clone)] +pub struct ImageInfo { + pub dimensions: Dimensions, + pub format: Option, + pub file_size: u64, +} + +pub struct ImageLoader; + +impl ImageLoader { + pub fn new() -> Self { + Self + } + + pub fn load_info(&self, path: &Path) -> Result { + let metadata = std::fs::metadata(path).map_err(|e| PixstripError::ImageLoad { + path: path.to_path_buf(), + reason: e.to_string(), + })?; + + let reader = + image::ImageReader::open(path).map_err(|e| PixstripError::ImageLoad { + path: path.to_path_buf(), + reason: e.to_string(), + })?; + + let reader = reader.with_guessed_format().map_err(|e| PixstripError::ImageLoad { + path: path.to_path_buf(), + reason: e.to_string(), + })?; + + let img = reader.decode().map_err(|e| PixstripError::ImageLoad { + path: path.to_path_buf(), + reason: e.to_string(), + })?; + + let format = path + .extension() + .and_then(|ext| ext.to_str()) + .and_then(ImageFormat::from_extension); + + Ok(ImageInfo { + dimensions: Dimensions { + width: img.width(), + height: img.height(), + }, + format, + file_size: metadata.len(), + }) + } + + pub fn load_pixels(&self, path: &Path) -> Result { + image::open(path).map_err(|e| PixstripError::ImageLoad { + path: path.to_path_buf(), + reason: e.to_string(), + }) + } +} + +impl Default for ImageLoader { + fn default() -> Self { + Self::new() + } +} diff --git a/pixstrip-core/tests/discovery_tests.rs b/pixstrip-core/tests/discovery_tests.rs new file mode 100644 index 0000000..e524264 --- /dev/null +++ b/pixstrip-core/tests/discovery_tests.rs @@ -0,0 +1,52 @@ +use pixstrip_core::discovery::discover_images; +use std::fs; + +fn create_test_tree(root: &std::path::Path) { + fs::create_dir_all(root.join("subdir")).unwrap(); + fs::write(root.join("photo1.jpg"), b"fake jpeg").unwrap(); + fs::write(root.join("photo2.png"), b"fake png").unwrap(); + fs::write(root.join("readme.txt"), b"not an image").unwrap(); + fs::write(root.join("subdir/nested.webp"), b"fake webp").unwrap(); + fs::write(root.join("subdir/doc.pdf"), b"not an image").unwrap(); +} + +#[test] +fn discover_flat() { + let dir = tempfile::tempdir().unwrap(); + create_test_tree(dir.path()); + + let images = discover_images(dir.path(), false); + assert_eq!(images.len(), 2); + let names: Vec = images + .iter() + .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) + .collect(); + assert!(names.contains(&"photo1.jpg".to_string())); + assert!(names.contains(&"photo2.png".to_string())); +} + +#[test] +fn discover_recursive() { + let dir = tempfile::tempdir().unwrap(); + create_test_tree(dir.path()); + + let images = discover_images(dir.path(), true); + assert_eq!(images.len(), 3); +} + +#[test] +fn discover_empty_dir() { + let dir = tempfile::tempdir().unwrap(); + let images = discover_images(dir.path(), true); + assert!(images.is_empty()); +} + +#[test] +fn discover_single_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("single.jpg"); + fs::write(&path, b"fake jpeg").unwrap(); + + let images = discover_images(&path, false); + assert_eq!(images.len(), 1); +} diff --git a/pixstrip-core/tests/loader_tests.rs b/pixstrip-core/tests/loader_tests.rs new file mode 100644 index 0000000..645f3be --- /dev/null +++ b/pixstrip-core/tests/loader_tests.rs @@ -0,0 +1,73 @@ +use pixstrip_core::loader::ImageLoader; +use pixstrip_core::types::Dimensions; +use std::path::Path; + +fn create_test_jpeg(path: &Path) { + use image::{RgbImage, ImageFormat}; + let img = RgbImage::from_fn(100, 80, |x, y| { + image::Rgb([(x % 256) as u8, (y % 256) as u8, 128]) + }); + img.save_with_format(path, ImageFormat::Jpeg).unwrap(); +} + +fn create_test_png(path: &Path) { + use image::{RgbaImage, ImageFormat}; + let img = RgbaImage::from_fn(200, 150, |x, y| { + image::Rgba([(x % 256) as u8, (y % 256) as u8, 100, 255]) + }); + img.save_with_format(path, ImageFormat::Png).unwrap(); +} + +#[test] +fn load_jpeg_dimensions() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.jpg"); + create_test_jpeg(&path); + + let loader = ImageLoader::new(); + let info = loader.load_info(&path).unwrap(); + assert_eq!(info.dimensions, Dimensions { width: 100, height: 80 }); + assert_eq!(info.format, Some(pixstrip_core::types::ImageFormat::Jpeg)); +} + +#[test] +fn load_png_dimensions() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.png"); + create_test_png(&path); + + let loader = ImageLoader::new(); + let info = loader.load_info(&path).unwrap(); + assert_eq!(info.dimensions, Dimensions { width: 200, height: 150 }); + assert_eq!(info.format, Some(pixstrip_core::types::ImageFormat::Png)); +} + +#[test] +fn load_pixels() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.jpg"); + create_test_jpeg(&path); + + let loader = ImageLoader::new(); + let pixels = loader.load_pixels(&path).unwrap(); + assert_eq!(pixels.width(), 100); + assert_eq!(pixels.height(), 80); +} + +#[test] +fn load_nonexistent_file() { + let loader = ImageLoader::new(); + let result = loader.load_info(Path::new("/tmp/does_not_exist_pixstrip.jpg")); + assert!(result.is_err()); +} + +#[test] +fn file_size_bytes() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("test.jpg"); + create_test_jpeg(&path); + + let loader = ImageLoader::new(); + let info = loader.load_info(&path).unwrap(); + assert!(info.file_size > 0); +}