use super::schema::{
AnimationsConfig, DefaultsConfig, ExportsConfig, ProjectConfig, PxlConfig, ValidateConfig,
WatchConfig,
};
use std::collections::HashMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Failed to read config: {0}")]
Io(#[from] std::io::Error),
#[error("Failed to parse pxl.toml: {0}")]
Parse(#[from] toml::de::Error),
#[error("Config validation failed:\n{}", .0.iter().map(|e| format!(" - {}", e)).collect::<Vec<_>>().join("\n"))]
Validation(Vec<String>),
}
#[derive(Debug, Default, Clone)]
pub struct CliOverrides {
pub out: Option<PathBuf>,
pub src: Option<PathBuf>,
pub scale: Option<u32>,
pub padding: Option<u32>,
pub atlas: Option<String>,
pub export: Option<String>,
pub strict: Option<bool>,
pub jobs: Option<usize>,
}
pub fn find_config() -> Option<PathBuf> {
find_config_from(env::current_dir().ok()?)
}
pub fn find_config_from(start: PathBuf) -> Option<PathBuf> {
let mut current = start;
loop {
let config_path = current.join("pxl.toml");
if config_path.exists() {
return Some(config_path);
}
if !current.pop() {
return None;
}
}
}
pub fn load_config(path: Option<&Path>) -> Result<PxlConfig, ConfigError> {
let config_path = match path {
Some(p) => Some(p.to_path_buf()),
None => find_config(),
};
match config_path {
Some(p) => load_config_file(&p),
None => Ok(default_config()),
}
}
fn load_config_file(path: &Path) -> Result<PxlConfig, ConfigError> {
let contents = fs::read_to_string(path)?;
let config: PxlConfig = toml::from_str(&contents)?;
let errors = config.validate();
if !errors.is_empty() {
return Err(ConfigError::Validation(errors.into_iter().map(|e| e.to_string()).collect()));
}
Ok(config)
}
pub fn default_config() -> PxlConfig {
let project_name = env::current_dir()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.unwrap_or_else(|| "unnamed".to_string());
PxlConfig {
project: ProjectConfig {
name: project_name,
version: "0.1.0".to_string(),
src: PathBuf::from("src/pxl"),
out: PathBuf::from("build"),
},
defaults: DefaultsConfig::default(),
atlases: HashMap::new(),
animations: AnimationsConfig::default(),
exports: ExportsConfig::default(),
validate: ValidateConfig::default(),
watch: WatchConfig::default(),
}
}
pub fn merge_cli_overrides(config: &mut PxlConfig, overrides: &CliOverrides) {
if let Some(ref out) = overrides.out {
config.project.out = out.clone();
}
if let Some(ref src) = overrides.src {
config.project.src = src.clone();
}
if let Some(scale) = overrides.scale {
config.defaults.scale = scale;
}
if let Some(padding) = overrides.padding {
config.defaults.padding = padding;
}
if let Some(strict) = overrides.strict {
config.validate.strict = strict;
}
}
pub fn project_root(config_path: &Path) -> Option<&Path> {
config_path.parent()
}
pub fn resolve_path(project_root: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
project_root.join(path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
use tempfile::TempDir;
#[test]
fn test_find_config_in_current_dir() {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("pxl.toml");
File::create(&config_path).unwrap().write_all(b"[project]\nname = \"test\"").unwrap();
let found = find_config_from(temp.path().to_path_buf());
assert_eq!(found, Some(config_path));
}
#[test]
fn test_find_config_in_parent_dir() {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("pxl.toml");
File::create(&config_path).unwrap().write_all(b"[project]\nname = \"test\"").unwrap();
let subdir = temp.path().join("src").join("sprites");
fs::create_dir_all(&subdir).unwrap();
let found = find_config_from(subdir);
assert_eq!(found, Some(config_path));
}
#[test]
fn test_find_config_not_found() {
let temp = TempDir::new().unwrap();
let found = find_config_from(temp.path().to_path_buf());
assert_eq!(found, None);
}
#[test]
fn test_load_config_from_file() {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("pxl.toml");
File::create(&config_path)
.unwrap()
.write_all(
br#"
[project]
name = "test-project"
version = "2.0.0"
[defaults]
scale = 3
padding = 2
[atlases.main]
sources = ["sprites/**"]
max_size = [512, 512]
"#,
)
.unwrap();
let config = load_config(Some(&config_path)).unwrap();
assert_eq!(config.project.name, "test-project");
assert_eq!(config.project.version, "2.0.0");
assert_eq!(config.defaults.scale, 3);
assert_eq!(config.defaults.padding, 2);
assert!(config.atlases.contains_key("main"));
}
#[test]
fn test_load_config_missing_file_uses_defaults() {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("nonexistent.toml");
let result = load_config(Some(&config_path));
assert!(result.is_err());
}
#[test]
fn test_load_config_no_path_no_file_uses_defaults() {
let temp = TempDir::new().unwrap();
let found = find_config_from(temp.path().to_path_buf());
assert!(found.is_none());
let config = default_config();
assert_eq!(config.project.src, PathBuf::from("src/pxl"));
assert_eq!(config.project.out, PathBuf::from("build"));
assert_eq!(config.defaults.scale, 1);
assert_eq!(config.defaults.padding, 1);
}
#[test]
fn test_load_config_invalid_toml() {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("pxl.toml");
File::create(&config_path).unwrap().write_all(b"this is not valid toml {{{").unwrap();
let result = load_config(Some(&config_path));
assert!(matches!(result, Err(ConfigError::Parse(_))));
}
#[test]
fn test_load_config_validation_error() {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("pxl.toml");
File::create(&config_path)
.unwrap()
.write_all(
br#"
[project]
name = ""
[defaults]
scale = 0
"#,
)
.unwrap();
let result = load_config(Some(&config_path));
assert!(matches!(result, Err(ConfigError::Validation(_))));
}
#[test]
fn test_merge_cli_overrides_out() {
let mut config = default_config();
let overrides = CliOverrides { out: Some(PathBuf::from("dist")), ..Default::default() };
merge_cli_overrides(&mut config, &overrides);
assert_eq!(config.project.out, PathBuf::from("dist"));
}
#[test]
fn test_merge_cli_overrides_src() {
let mut config = default_config();
let overrides =
CliOverrides { src: Some(PathBuf::from("assets/pxl")), ..Default::default() };
merge_cli_overrides(&mut config, &overrides);
assert_eq!(config.project.src, PathBuf::from("assets/pxl"));
}
#[test]
fn test_merge_cli_overrides_scale() {
let mut config = default_config();
let overrides = CliOverrides { scale: Some(4), ..Default::default() };
merge_cli_overrides(&mut config, &overrides);
assert_eq!(config.defaults.scale, 4);
}
#[test]
fn test_merge_cli_overrides_strict() {
let mut config = default_config();
assert!(!config.validate.strict);
let overrides = CliOverrides { strict: Some(true), ..Default::default() };
merge_cli_overrides(&mut config, &overrides);
assert!(config.validate.strict);
}
#[test]
fn test_merge_cli_overrides_multiple() {
let mut config = default_config();
let overrides = CliOverrides {
out: Some(PathBuf::from("output")),
scale: Some(2),
padding: Some(4),
strict: Some(true),
..Default::default()
};
merge_cli_overrides(&mut config, &overrides);
assert_eq!(config.project.out, PathBuf::from("output"));
assert_eq!(config.defaults.scale, 2);
assert_eq!(config.defaults.padding, 4);
assert!(config.validate.strict);
}
#[test]
fn test_resolve_path_absolute() {
let root = Path::new("/project");
let absolute = Path::new("/other/path");
assert_eq!(resolve_path(root, absolute), PathBuf::from("/other/path"));
}
#[test]
fn test_resolve_path_relative() {
let root = Path::new("/project");
let relative = Path::new("src/pxl");
assert_eq!(resolve_path(root, relative), PathBuf::from("/project/src/pxl"));
}
#[test]
fn test_project_root() {
let config_path = Path::new("/project/pxl.toml");
assert_eq!(project_root(config_path), Some(Path::new("/project")));
}
#[test]
fn test_default_config() {
let config = default_config();
assert!(!config.project.name.is_empty());
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"));
}
}