use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[derive(Default)]
pub enum ValidationLevel {
Error,
#[default]
Warn,
Ignore,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum SheetLayout {
#[default]
Horizontal,
Vertical,
Grid,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum FilterMode {
#[default]
Point,
Bilinear,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectConfig {
pub name: String,
#[serde(default = "default_version")]
pub version: String,
#[serde(default = "default_src")]
pub src: PathBuf,
#[serde(default = "default_out")]
pub out: PathBuf,
}
fn default_version() -> String {
"0.1.0".to_string()
}
fn default_src() -> PathBuf {
PathBuf::from("src/pxl")
}
fn default_out() -> PathBuf {
PathBuf::from("build")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DefaultsConfig {
#[serde(default = "default_scale")]
pub scale: u32,
#[serde(default = "default_padding")]
pub padding: u32,
}
impl Default for DefaultsConfig {
fn default() -> Self {
Self { scale: default_scale(), padding: default_padding() }
}
}
fn default_scale() -> u32 {
1
}
fn default_padding() -> u32 {
1
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AtlasConfig {
pub sources: Vec<String>,
#[serde(default = "default_max_size")]
pub max_size: [u32; 2],
#[serde(skip_serializing_if = "Option::is_none")]
pub padding: Option<u32>,
#[serde(default)]
pub power_of_two: bool,
#[serde(default)]
pub nine_slice: bool,
}
fn default_max_size() -> [u32; 2] {
[1024, 1024]
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnimationsConfig {
#[serde(default = "default_animation_sources")]
pub sources: Vec<String>,
#[serde(default)]
pub preview: bool,
#[serde(default = "default_scale")]
pub preview_scale: u32,
#[serde(default)]
pub sheet_layout: SheetLayout,
}
impl Default for AnimationsConfig {
fn default() -> Self {
Self {
sources: default_animation_sources(),
preview: false,
preview_scale: default_scale(),
sheet_layout: SheetLayout::default(),
}
}
}
fn default_animation_sources() -> Vec<String> {
vec!["animations/**".to_string()]
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GenericExportConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_json_format")]
pub atlas_format: String,
}
fn default_true() -> bool {
true
}
fn default_json_format() -> String {
"json".to_string()
}
impl Default for GenericExportConfig {
fn default() -> Self {
Self { enabled: true, atlas_format: "json".to_string() }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GodotExportConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_godot_format")]
pub atlas_format: String,
#[serde(default = "default_godot_resource_path")]
pub resource_path: String,
#[serde(default = "default_true")]
pub animation_player: bool,
#[serde(default = "default_true")]
pub sprite_frames: bool,
}
fn default_godot_format() -> String {
"godot".to_string()
}
fn default_godot_resource_path() -> String {
"res://assets/sprites".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UnityExportConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_unity_format")]
pub atlas_format: String,
#[serde(default = "default_pixels_per_unit")]
pub pixels_per_unit: u32,
#[serde(default)]
pub filter_mode: FilterMode,
#[serde(default = "default_true")]
pub generate_meta: bool,
#[serde(default = "default_true")]
pub generate_anim: bool,
#[serde(default = "default_true")]
pub generate_json: bool,
}
fn default_unity_format() -> String {
"unity".to_string()
}
fn default_pixels_per_unit() -> u32 {
16
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct LibGdxExportConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_libgdx_format")]
pub atlas_format: String,
}
fn default_libgdx_format() -> String {
"libgdx".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExportsConfig {
#[serde(default)]
pub generic: GenericExportConfig,
#[serde(default)]
pub godot: GodotExportConfig,
#[serde(default)]
pub unity: UnityExportConfig,
#[serde(default)]
pub libgdx: LibGdxExportConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ValidateConfig {
#[serde(default)]
pub strict: bool,
#[serde(default)]
pub unused_palettes: ValidationLevel,
#[serde(default = "default_missing_refs")]
pub missing_refs: ValidationLevel,
}
fn default_missing_refs() -> ValidationLevel {
ValidationLevel::Error
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WatchConfig {
#[serde(default = "default_debounce_ms")]
pub debounce_ms: u32,
#[serde(default = "default_true")]
pub clear_screen: bool,
}
fn default_debounce_ms() -> u32 {
100
}
impl Default for WatchConfig {
fn default() -> Self {
Self { debounce_ms: 100, clear_screen: true }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PxlConfig {
pub project: ProjectConfig,
#[serde(default)]
pub defaults: DefaultsConfig,
#[serde(default)]
pub atlases: HashMap<String, AtlasConfig>,
#[serde(default)]
pub animations: AnimationsConfig,
#[serde(default, rename = "export")]
pub exports: ExportsConfig,
#[serde(default)]
pub validate: ValidateConfig,
#[serde(default)]
pub watch: WatchConfig,
}
#[derive(Debug, Clone)]
pub struct ConfigValidationError {
pub field: String,
pub message: String,
}
impl std::fmt::Display for ConfigValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "pxl.toml: '{}' {}", self.field, self.message)
}
}
impl PxlConfig {
pub fn validate(&self) -> Vec<ConfigValidationError> {
let mut errors = Vec::new();
if self.project.name.is_empty() {
errors.push(ConfigValidationError {
field: "project.name".to_string(),
message: "must be a non-empty string".to_string(),
});
}
if self.defaults.scale == 0 {
errors.push(ConfigValidationError {
field: "defaults.scale".to_string(),
message: "must be a positive integer".to_string(),
});
}
for (name, atlas) in &self.atlases {
if atlas.sources.is_empty() {
errors.push(ConfigValidationError {
field: format!("atlases.{}.sources", name),
message: "must contain at least one glob pattern".to_string(),
});
}
if atlas.max_size[0] == 0 || atlas.max_size[1] == 0 {
errors.push(ConfigValidationError {
field: format!("atlases.{}.max_size", name),
message: "dimensions must be positive".to_string(),
});
}
}
if self.animations.preview_scale == 0 {
errors.push(ConfigValidationError {
field: "animations.preview_scale".to_string(),
message: "must be a positive integer".to_string(),
});
}
if self.exports.unity.enabled && self.exports.unity.pixels_per_unit == 0 {
errors.push(ConfigValidationError {
field: "export.unity.pixels_per_unit".to_string(),
message: "must be a positive integer".to_string(),
});
}
errors
}
pub fn is_valid(&self) -> bool {
self.validate().is_empty()
}
pub fn effective_padding(&self, atlas: &AtlasConfig) -> u32 {
atlas.padding.unwrap_or(self.defaults.padding)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_minimal_config_parse() {
let toml = r#"
[project]
name = "test-project"
"#;
let config: PxlConfig = toml::from_str(toml).unwrap();
assert_eq!(config.project.name, "test-project");
assert_eq!(config.project.version, "0.1.0");
assert_eq!(config.project.src, PathBuf::from("src/pxl"));
assert_eq!(config.project.out, PathBuf::from("build"));
}
#[test]
fn test_full_config_parse() {
let toml = r#"
[project]
name = "full-project"
version = "1.0.0"
src = "assets/pxl"
out = "dist"
[defaults]
scale = 2
padding = 4
[atlases.characters]
sources = ["sprites/player/**", "sprites/enemies/**"]
max_size = [2048, 2048]
padding = 2
power_of_two = true
[animations]
sources = ["anims/**"]
preview = true
preview_scale = 4
sheet_layout = "vertical"
[export.godot]
enabled = true
resource_path = "res://sprites"
[export.unity]
enabled = true
pixels_per_unit = 32
filter_mode = "bilinear"
[validate]
strict = true
unused_palettes = "error"
missing_refs = "warn"
[watch]
debounce_ms = 200
clear_screen = false
"#;
let config: PxlConfig = toml::from_str(toml).unwrap();
assert_eq!(config.project.name, "full-project");
assert_eq!(config.project.version, "1.0.0");
assert_eq!(config.defaults.scale, 2);
assert_eq!(config.defaults.padding, 4);
let chars_atlas = config.atlases.get("characters").unwrap();
assert_eq!(chars_atlas.sources.len(), 2);
assert_eq!(chars_atlas.max_size, [2048, 2048]);
assert_eq!(chars_atlas.padding, Some(2));
assert!(chars_atlas.power_of_two);
assert!(config.animations.preview);
assert_eq!(config.animations.preview_scale, 4);
assert_eq!(config.animations.sheet_layout, SheetLayout::Vertical);
assert!(config.exports.godot.enabled);
assert_eq!(config.exports.godot.resource_path, "res://sprites");
assert!(config.exports.unity.enabled);
assert_eq!(config.exports.unity.pixels_per_unit, 32);
assert_eq!(config.exports.unity.filter_mode, FilterMode::Bilinear);
assert!(config.validate.strict);
assert_eq!(config.validate.unused_palettes, ValidationLevel::Error);
assert_eq!(config.validate.missing_refs, ValidationLevel::Warn);
assert_eq!(config.watch.debounce_ms, 200);
assert!(!config.watch.clear_screen);
}
#[test]
fn test_validation_empty_name() {
let toml = r#"
[project]
name = ""
"#;
let config: PxlConfig = toml::from_str(toml).unwrap();
let errors = config.validate();
assert!(!errors.is_empty());
assert!(errors.iter().any(|e| e.field == "project.name"));
}
#[test]
fn test_validation_zero_scale() {
let toml = r#"
[project]
name = "test"
[defaults]
scale = 0
"#;
let config: PxlConfig = toml::from_str(toml).unwrap();
let errors = config.validate();
assert!(errors.iter().any(|e| e.field == "defaults.scale"));
}
#[test]
fn test_validation_empty_atlas_sources() {
let toml = r#"
[project]
name = "test"
[atlases.empty]
sources = []
"#;
let config: PxlConfig = toml::from_str(toml).unwrap();
let errors = config.validate();
assert!(errors.iter().any(|e| e.field == "atlases.empty.sources"));
}
#[test]
fn test_validation_zero_max_size() {
let toml = r#"
[project]
name = "test"
[atlases.bad]
sources = ["sprites/**"]
max_size = [0, 1024]
"#;
let config: PxlConfig = toml::from_str(toml).unwrap();
let errors = config.validate();
assert!(errors.iter().any(|e| e.field == "atlases.bad.max_size"));
}
#[test]
fn test_effective_padding() {
let toml = r#"
[project]
name = "test"
[defaults]
padding = 4
[atlases.with_padding]
sources = ["a/**"]
padding = 2
[atlases.without_padding]
sources = ["b/**"]
"#;
let config: PxlConfig = toml::from_str(toml).unwrap();
let with = config.atlases.get("with_padding").unwrap();
let without = config.atlases.get("without_padding").unwrap();
assert_eq!(config.effective_padding(with), 2);
assert_eq!(config.effective_padding(without), 4);
}
#[test]
fn test_validation_level_serde() {
let toml = r#"
[project]
name = "test"
[validate]
unused_palettes = "error"
missing_refs = "ignore"
"#;
let config: PxlConfig = toml::from_str(toml).unwrap();
assert_eq!(config.validate.unused_palettes, ValidationLevel::Error);
assert_eq!(config.validate.missing_refs, ValidationLevel::Ignore);
}
#[test]
fn test_sheet_layout_serde() {
let toml = r#"
[project]
name = "test"
[animations]
sheet_layout = "grid"
"#;
let config: PxlConfig = toml::from_str(toml).unwrap();
assert_eq!(config.animations.sheet_layout, SheetLayout::Grid);
}
#[test]
fn test_filter_mode_serde() {
let toml = r#"
[project]
name = "test"
[export.unity]
enabled = true
filter_mode = "bilinear"
"#;
let config: PxlConfig = toml::from_str(toml).unwrap();
assert_eq!(config.exports.unity.filter_mode, FilterMode::Bilinear);
}
}