Skip to content
Draft
12 changes: 12 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,16 @@ 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),

/// Directory validation failed.
#[error("Directory validation failed for {0}: {1}")]
DirectoryValidationFailed(PathBuf, String),
}
264 changes: 255 additions & 9 deletions martin-core/src/resources/sprites/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,27 +71,273 @@ impl SpriteSources {

/// Adds a sprite source directory containing SVG files.
/// Files are ignored - only directories accepted. Duplicates ignored with warning.
/// Performs basic validation of SVG format before adding the source.
pub fn add_source(&mut self, id: String, path: PathBuf) {
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) => {
return;
}
if !path.exists() {
warn!("Sprite source {id} path doesn't exist: {disp_path}");
return;
}

match std::fs::read_dir(&path) {
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 move this validation to a new function. It is kind of messy to read otherwise

Ok(entries) => {
let mut file_count = 0;
let mut svg_count = 0;
let mut sprite_output_files = Vec::new();

for entry in entries.flatten() {
let entry_path = entry.path();
if entry_path.is_file() {
file_count += 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(),
);
}
}
}
}
}

if file_count == 0 {
warn!("Sprite source {id} directory is empty: {disp_path}");
} else if svg_count == 0 && !sprite_output_files.is_empty() {
warn!(
"Ignoring duplicate sprite source {} from {disp_path} because it was already configured for {}",
v.key(),
v.get().path.display()
"Sprite source {id} contains files ({}) but no valid SVG sources: {disp_path}. \
Martin requires source SVG files.",
sprite_output_files.join(", ")
);
} else if svg_count == 0 {
warn!("Sprite source {id} contains no SVG files: {disp_path}");
}
Entry::Vacant(v) => {
info!("Configured sprite source {} from {disp_path}", v.key());
v.insert(SpriteSource { path });
}
Err(e) => {
warn!("Cannot read sprite source {id} directory {disp_path}: {e}");
return;
}
}

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 });
}
}
}

/// 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() {
warn!("Sprite source is not a directory: {disp_path}");
return Err(SpriteError::DirectoryValidationFailed(
path.clone(),
"Path is not a directory".to_string(),
));
}

// Check directory permissions by trying to read it
let mut entries = tokio::fs::read_dir(path).await.map_err(on_err)?;
let mut svg_count = 0;
let mut total_files = 0;
let mut sprite_output_files = Vec::new(); // Track pre-generated sprite files

while let Some(entry) = entries.next_entry().await.map_err(on_err)? {
let entry_path = entry.path();
total_files += 1;

if entry_path.is_file() {
if let Some(extension) = entry_path.extension() {
let ext = extension.to_string_lossy().to_lowercase();
if ext == "svg" {
svg_count += 1;
// Validate individual SVG file
if let Err(e) = self.validate_svg_file(&entry_path).await {
warn!("Invalid SVG file {}: {}", entry_path.display(), e);
}
} 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(),
);
}
}
}
}
}

if total_files == 0 {
warn!("Empty sprite directory: {disp_path}");
return Err(SpriteError::EmptyDirectory(path.clone()));
}

if svg_count == 0 && sprite_output_files.is_empty() {
warn!(
"No SVG source files found in sprite directory: {disp_path}. \
Found pre-generated sprite files: {}. \
Martin requires source SVG files, not pre-generated sprite outputs.",
sprite_output_files.join(", ")
);
return Err(SpriteError::DirectoryValidationFailed(
path.clone(),
format!(
"Directory contains pre-generated sprite files ({}) but no source SVG files. \
Please provide a directory with .svg files instead.",
sprite_output_files.join(", ")
),
));
}

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::InvalidSvgFormat(
path.clone(),
"File is empty".to_string(),
));
}

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

/// Validates all configured sprite sources and logs a summary.
pub async fn validate_all_sources(&self) -> 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.

validate_all_sources is never used.
As I asked before, please do a polishing pass.

if self.0.is_empty() {
info!("No sprite sources configured");
return Ok(());
}

let mut total_sources = 0;
let mut valid_sources = 0;
let mut total_svg_files = 0;
let mut validation_errors = Vec::new();

info!("Validating {} configured sprite sources...", self.0.len());

for source in &self.0 {
total_sources += 1;
let id = source.key();
let path = &source.value().path;

match self.validate_source_directory(path).await {
Ok(()) => {
valid_sources += 1;
if let Ok(svg_count) = count_svg_files(path).await {
total_svg_files += svg_count;
}
}
Err(e) => {
warn!("Validation failed for sprite source {id}: {e}");
validation_errors.push((id.clone(), e));
}
}
}

if validation_errors.is_empty() {
info!(
"Sprite source validation completed successfully: {valid_sources}/{total_sources} sources valid, {total_svg_files} total SVG files",
);
} else {
warn!(
"Sprite source validation completed with errors: {valid_sources}/{total_sources} sources valid, {} errors encountered",
validation_errors.len()
);
for (id, error) in &validation_errors {
warn!(" - {id}: {error}");
}
}

Ok(())
}
}

/// Helper function to count SVG files in a directory.
async fn count_svg_files(path: &PathBuf) -> Result<usize, 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 count = 0;

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" {
count += 1;
}
}
}
}

Ok(count)
}

impl SpriteSources {
/// Generates a spritesheet from comma-separated sprite source IDs.
///
/// Append "@2x" for high-DPI sprites.
Expand Down
Loading