diff --git a/martin-core/src/resources/sprites/error.rs b/martin-core/src/resources/sprites/error.rs index d6fafcb9c..69caf32be 100644 --- a/martin-core/src/resources/sprites/error.rs +++ b/martin-core/src/resources/sprites/error.rs @@ -47,4 +47,28 @@ pub enum SpriteError { /// Failed to create sprite from SVG file. #[error("Unable to create a sprite from file {0}")] SpriteInstError(PathBuf), + + /// Empty sprite directory. + #[error("Empty sprite directory: {0}")] + EmptyDirectory(PathBuf), + + /// Invalid SVG format. + #[error("Invalid SVG format in {0}: {1}")] + InvalidSvgFormat(PathBuf, String), + + /// File is empty. + #[error("File is empty: {0}")] + EmptyFile(PathBuf), + + /// Directory does not exist. + #[error("Directory does not exist: {0}")] + DirectoryNotFound(PathBuf), + + /// Path is not a directory. + #[error("Path is not a directory: {0}")] + NotADirectory(PathBuf), + + /// Directory validation failed. + #[error("Directory validation failed for {0}: {1}")] + DirectoryValidationFailed(PathBuf, String), } diff --git a/martin-core/src/resources/sprites/mod.rs b/martin-core/src/resources/sprites/mod.rs index 62e50d6ed..0f85df0e9 100644 --- a/martin-core/src/resources/sprites/mod.rs +++ b/martin-core/src/resources/sprites/mod.rs @@ -71,27 +71,153 @@ impl SpriteSources { /// Adds a sprite source directory containing SVG files. /// Files are ignored - only directories accepted. Duplicates ignored with warning. - pub fn add_source(&mut self, id: String, path: PathBuf) { + /// Performs basic validation of SVG format before adding the source. + pub fn add_source(&mut self, id: String, path: PathBuf) -> Result<(), SpriteError> { let disp_path = path.display(); + if path.is_file() { - warn!("Ignoring non-directory sprite source {id} from {disp_path}"); - } else { - match self.0.entry(id) { - Entry::Occupied(v) => { - warn!( - "Ignoring duplicate sprite source {} from {disp_path} because it was already configured for {}", - v.key(), - v.get().path.display() - ); + return Err(SpriteError::NotADirectory(path)); + } + + if !path.exists() { + return Err(SpriteError::DirectoryNotFound(path)); + } + + match self.0.entry(id) { + Entry::Occupied(v) => { + warn!( + "Ignoring duplicate sprite source {} from {disp_path}: Already configured from {}", + v.key(), + v.get().path.display() + ); + } + Entry::Vacant(v) => { + info!("Configured sprite source {} from {disp_path}", v.key()); + v.insert(SpriteSource { path }); + } + } + + Ok(()) + } + + /// Validates a sprite source directory to ensure it contains valid SVG files. + /// Checks include: + /// - Directory existence and accessibility + /// - Presence of SVG files + /// - Basic SVG format validation + pub async fn validate_source_directory(&self, path: &PathBuf) -> Result<(), SpriteError> { + let disp_path = path.display(); + let on_err = |e| SpriteError::IoError(e, path.clone()); + + // Check if path exists and get metadata + let metadata = tokio::fs::metadata(path).await.map_err(on_err)?; + + if !metadata.is_dir() { + return Err(SpriteError::NotADirectory(path.clone())); + } + + let (total_files, svg_count, sprite_output_files) = + Self::scan_directory_files(path).await?; + + let mut entries = tokio::fs::read_dir(path).await.map_err(on_err)?; + while let Some(entry) = entries.next_entry().await.map_err(on_err)? { + let entry_path = entry.path(); + if entry_path.is_file() { + if let Some(extension) = entry_path.extension() { + if extension.to_string_lossy().to_lowercase() == "svg" { + self.validate_svg_file(&entry_path).await?; + } } - Entry::Vacant(v) => { - info!("Configured sprite source {} from {disp_path}", v.key()); - v.insert(SpriteSource { path }); + } + } + + if total_files == 0 { + return Err(SpriteError::EmptyDirectory(path.clone())); + } + + if svg_count == 0 && sprite_output_files.is_empty() { + return Err(SpriteError::DirectoryValidationFailed( + path.clone(), + "Directory contains no SVG files".to_string(), + )); + } + + info!( + "Validated sprite directory {disp_path}: found {svg_count} SVG files out of {total_files} total files" + ); + Ok(()) + } + + /// Validates an individual SVG file for format. + async fn validate_svg_file(&self, path: &PathBuf) -> Result<(), SpriteError> { + let on_err = |e| SpriteError::IoError(e, path.clone()); + + let content = tokio::fs::read_to_string(path).await.map_err(on_err)?; + let content = content.trim(); + + if content.is_empty() { + return Err(SpriteError::EmptyFile(path.clone())); + } + + if !content.starts_with(" Result<(usize, usize, Vec), SpriteError> { + let on_err = |e| SpriteError::IoError(e, path.clone()); + let mut entries = tokio::fs::read_dir(path).await.map_err(on_err)?; + let mut total_files = 0; + let mut svg_count = 0; + let mut sprite_output_files = Vec::new(); + + while let Some(entry) = entries.next_entry().await.map_err(on_err)? { + let entry_path = entry.path(); + if entry_path.is_file() { + total_files += 1; + if let Some(extension) = entry_path.extension() { + let ext = extension.to_string_lossy().to_lowercase(); + if ext == "svg" { + svg_count += 1; + } else if ext == "png" || ext == "json" { + let filename = entry_path + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + if filename.contains("sprite") || filename.contains("@2x") { + sprite_output_files.push( + entry_path + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(), + ); + } + } } } } + + Ok((total_files, svg_count, sprite_output_files)) } +} +impl SpriteSources { /// Generates a spritesheet from comma-separated sprite source IDs. /// /// Append "@2x" for high-DPI sprites. @@ -191,14 +317,18 @@ mod tests { #[tokio::test] async fn test_sprites() { let mut sprites = SpriteSources::default(); - sprites.add_source( - "src1".to_string(), - PathBuf::from("../tests/fixtures/sprites/src1"), - ); - sprites.add_source( - "src2".to_string(), - PathBuf::from("../tests/fixtures/sprites/src2"), - ); + sprites + .add_source( + "src1".to_string(), + PathBuf::from("../tests/fixtures/sprites/src1"), + ) + .unwrap(); + sprites + .add_source( + "src2".to_string(), + PathBuf::from("../tests/fixtures/sprites/src2"), + ) + .unwrap(); assert_eq!(sprites.0.len(), 2); @@ -240,3 +370,51 @@ mod tests { insta::assert_binary_snapshot!(&format!("{filename}.png"), png); } } + +#[tokio::test] +async fn test_directory_not_found() { + let mut sprites = SpriteSources::default(); + let result = sprites.add_source("nothere".to_string(), PathBuf::from("/path/to/nowhere")); + assert!(matches!(result, Err(SpriteError::DirectoryNotFound(..)))); +} + +#[tokio::test] +async fn test_not_a_directory() { + let mut sprites = SpriteSources::default(); + let result = sprites.add_source( + "notadir".to_string(), + PathBuf::from("../tests/fixtures/sprites/notsrc2/ferris.png"), + ); + assert!(matches!(result, Err(SpriteError::NotADirectory(..)))); +} + +#[tokio::test] +async fn test_empty_directory() { + let sprites = SpriteSources::default(); + let result = sprites + .validate_source_directory(&PathBuf::from("../tests/fixtures/sprites/notsrc1")) + .await; + assert!(matches!(result, Err(SpriteError::EmptyDirectory(..)))); +} + +#[tokio::test] +async fn test_sprite_source_scan() { + use crate::sprites::SpriteSources; + let result = + SpriteSources::scan_directory_files(&PathBuf::from("../tests/fixtures/sprites/notsrc2")) + .await; + assert_eq!(result.as_ref().unwrap().0, 2); + assert_eq!(result.unwrap().1, 0); +} + +#[tokio::test] +async fn test_empty_file() { + use crate::sprites::SpriteSources; + let sprites = SpriteSources::default(); + let result = SpriteSources::validate_svg_file( + &sprites, + &PathBuf::from("../tests/fixtures/sprites/notsrc2/notasprite.txt"), + ) + .await; + assert!(matches!(result, Err(SpriteError::EmptyFile(..)))); +} diff --git a/tests/fixtures/sprites/notsrc2/ferris.png b/tests/fixtures/sprites/notsrc2/ferris.png new file mode 100644 index 000000000..9a39db49f Binary files /dev/null and b/tests/fixtures/sprites/notsrc2/ferris.png differ diff --git a/tests/fixtures/sprites/notsrc2/notasprite.txt b/tests/fixtures/sprites/notsrc2/notasprite.txt new file mode 100644 index 000000000..e69de29bb