Add ImageLoader and file discovery modules
ImageLoader: load image info (dimensions, format, file size) and pixels. Discovery: find image files by extension, flat or recursive, single file or directory. All 9 tests passing.
This commit is contained in:
57
pixstrip-core/src/discovery.rs
Normal file
57
pixstrip-core/src/discovery.rs
Normal file
@@ -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<PathBuf> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod discovery;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod loader;
|
||||||
pub mod operations;
|
pub mod operations;
|
||||||
pub mod pipeline;
|
pub mod pipeline;
|
||||||
pub mod preset;
|
pub mod preset;
|
||||||
|
|||||||
69
pixstrip-core/src/loader.rs
Normal file
69
pixstrip-core/src/loader.rs
Normal file
@@ -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<ImageFormat>,
|
||||||
|
pub file_size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ImageLoader;
|
||||||
|
|
||||||
|
impl ImageLoader {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_info(&self, path: &Path) -> Result<ImageInfo> {
|
||||||
|
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::DynamicImage> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
52
pixstrip-core/tests/discovery_tests.rs
Normal file
52
pixstrip-core/tests/discovery_tests.rs
Normal file
@@ -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<String> = 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);
|
||||||
|
}
|
||||||
73
pixstrip-core/tests/loader_tests.rs
Normal file
73
pixstrip-core/tests/loader_tests.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user