Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions martin-core/src/resources/sprites/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
220 changes: 199 additions & 21 deletions martin-core/src/resources/sprites/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Copy link

Copilot AI Sep 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parameter should be path: &Path instead of path: &PathBuf. Taking a reference to PathBuf is unnecessary since Path is the borrowed version that should be used in function parameters.

Copilot uses AI. Check for mistakes.
/// - Directory existence and accessibility
/// - Presence of SVG files
/// - Basic SVG format validation
pub async fn validate_source_directory(&self, path: &PathBuf) -> Result<(), SpriteError> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not add dead code.
As I said above, this is never used in the codebase.

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());
Copy link

Copilot AI Sep 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SVG validation is too restrictive. Valid SVG files can start with whitespace, comments, or DOCTYPE declarations before the <?xml or <svg tags. Consider using trim() before checking or using a more flexible approach.

Copilot uses AI. Check for mistakes.
Copy link
Author

@nuts-rice nuts-rice Sep 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let content = content.trim();
is already before the check, correct?


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("<?xml") && !content.starts_with("<svg") {
return Err(SpriteError::InvalidSvgFormat(
path.clone(),
"Missing SVG declaration".to_string(),
));
}

// Check if it contains an SVG tag
if !content.contains("<svg") {
return Err(SpriteError::InvalidSvgFormat(
path.clone(),
"No SVG element found".to_string(),
));
}

Ok(())
}

/// Scans a directory and returns file counts.
async fn scan_directory_files(
path: &PathBuf,
) -> Result<(usize, usize, Vec<String>), 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.
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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(..))));
}
Binary file added tests/fixtures/sprites/notsrc2/ferris.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Empty file.
Loading