1use super::schema::{
6 AnimationsConfig, DefaultsConfig, ExportsConfig, ProjectConfig, PxlConfig, ValidateConfig,
7 WatchConfig,
8};
9use std::collections::HashMap;
10use std::env;
11use std::fs;
12use std::path::{Path, PathBuf};
13use thiserror::Error;
14
15#[derive(Debug, Error)]
17pub enum ConfigError {
18 #[error("Failed to read config: {0}")]
20 Io(#[from] std::io::Error),
21 #[error("Failed to parse pxl.toml: {0}")]
23 Parse(#[from] toml::de::Error),
24 #[error("Config validation failed:\n{}", .0.iter().map(|e| format!(" - {}", e)).collect::<Vec<_>>().join("\n"))]
26 Validation(Vec<String>),
27}
28
29#[derive(Debug, Default, Clone)]
31pub struct CliOverrides {
32 pub out: Option<PathBuf>,
34 pub src: Option<PathBuf>,
36 pub scale: Option<u32>,
38 pub padding: Option<u32>,
40 pub atlas: Option<String>,
42 pub export: Option<String>,
44 pub strict: Option<bool>,
46 pub jobs: Option<usize>,
48}
49
50pub fn find_config() -> Option<PathBuf> {
66 find_config_from(env::current_dir().ok()?)
67}
68
69pub fn find_config_from(start: PathBuf) -> Option<PathBuf> {
74 let mut current = start;
75
76 loop {
77 let config_path = current.join("pxl.toml");
78 if config_path.exists() {
79 return Some(config_path);
80 }
81
82 if !current.pop() {
84 return None;
86 }
87 }
88}
89
90pub fn load_config(path: Option<&Path>) -> Result<PxlConfig, ConfigError> {
112 let config_path = match path {
113 Some(p) => Some(p.to_path_buf()),
114 None => find_config(),
115 };
116
117 match config_path {
118 Some(p) => load_config_file(&p),
119 None => Ok(default_config()),
120 }
121}
122
123fn load_config_file(path: &Path) -> Result<PxlConfig, ConfigError> {
125 let contents = fs::read_to_string(path)?;
126 let config: PxlConfig = toml::from_str(&contents)?;
127
128 let errors = config.validate();
130 if !errors.is_empty() {
131 return Err(ConfigError::Validation(errors.into_iter().map(|e| e.to_string()).collect()));
132 }
133
134 Ok(config)
135}
136
137pub fn default_config() -> PxlConfig {
142 let project_name = env::current_dir()
143 .ok()
144 .and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
145 .unwrap_or_else(|| "unnamed".to_string());
146
147 PxlConfig {
148 project: ProjectConfig {
149 name: project_name,
150 version: "0.1.0".to_string(),
151 src: PathBuf::from("src/pxl"),
152 out: PathBuf::from("build"),
153 },
154 defaults: DefaultsConfig::default(),
155 atlases: HashMap::new(),
156 animations: AnimationsConfig::default(),
157 exports: ExportsConfig::default(),
158 validate: ValidateConfig::default(),
159 watch: WatchConfig::default(),
160 }
161}
162
163pub fn merge_cli_overrides(config: &mut PxlConfig, overrides: &CliOverrides) {
182 if let Some(ref out) = overrides.out {
184 config.project.out = out.clone();
185 }
186
187 if let Some(ref src) = overrides.src {
189 config.project.src = src.clone();
190 }
191
192 if let Some(scale) = overrides.scale {
194 config.defaults.scale = scale;
195 }
196
197 if let Some(padding) = overrides.padding {
199 config.defaults.padding = padding;
200 }
201
202 if let Some(strict) = overrides.strict {
204 config.validate.strict = strict;
205 }
206}
207
208pub fn project_root(config_path: &Path) -> Option<&Path> {
212 config_path.parent()
213}
214
215pub fn resolve_path(project_root: &Path, path: &Path) -> PathBuf {
220 if path.is_absolute() {
221 path.to_path_buf()
222 } else {
223 project_root.join(path)
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use std::fs::File;
231 use std::io::Write;
232 use tempfile::TempDir;
233
234 #[test]
235 fn test_find_config_in_current_dir() {
236 let temp = TempDir::new().unwrap();
237 let config_path = temp.path().join("pxl.toml");
238 File::create(&config_path).unwrap().write_all(b"[project]\nname = \"test\"").unwrap();
239
240 let found = find_config_from(temp.path().to_path_buf());
241 assert_eq!(found, Some(config_path));
242 }
243
244 #[test]
245 fn test_find_config_in_parent_dir() {
246 let temp = TempDir::new().unwrap();
247 let config_path = temp.path().join("pxl.toml");
248 File::create(&config_path).unwrap().write_all(b"[project]\nname = \"test\"").unwrap();
249
250 let subdir = temp.path().join("src").join("sprites");
252 fs::create_dir_all(&subdir).unwrap();
253
254 let found = find_config_from(subdir);
255 assert_eq!(found, Some(config_path));
256 }
257
258 #[test]
259 fn test_find_config_not_found() {
260 let temp = TempDir::new().unwrap();
261 let found = find_config_from(temp.path().to_path_buf());
262 assert_eq!(found, None);
263 }
264
265 #[test]
266 fn test_load_config_from_file() {
267 let temp = TempDir::new().unwrap();
268 let config_path = temp.path().join("pxl.toml");
269 File::create(&config_path)
270 .unwrap()
271 .write_all(
272 br#"
273[project]
274name = "test-project"
275version = "2.0.0"
276
277[defaults]
278scale = 3
279padding = 2
280
281[atlases.main]
282sources = ["sprites/**"]
283max_size = [512, 512]
284"#,
285 )
286 .unwrap();
287
288 let config = load_config(Some(&config_path)).unwrap();
289 assert_eq!(config.project.name, "test-project");
290 assert_eq!(config.project.version, "2.0.0");
291 assert_eq!(config.defaults.scale, 3);
292 assert_eq!(config.defaults.padding, 2);
293 assert!(config.atlases.contains_key("main"));
294 }
295
296 #[test]
297 fn test_load_config_missing_file_uses_defaults() {
298 let temp = TempDir::new().unwrap();
299 let config_path = temp.path().join("nonexistent.toml");
300
301 let result = load_config(Some(&config_path));
303 assert!(result.is_err());
304 }
305
306 #[test]
307 fn test_load_config_no_path_no_file_uses_defaults() {
308 let temp = TempDir::new().unwrap();
310
311 let found = find_config_from(temp.path().to_path_buf());
313 assert!(found.is_none());
314
315 let config = default_config();
317 assert_eq!(config.project.src, PathBuf::from("src/pxl"));
318 assert_eq!(config.project.out, PathBuf::from("build"));
319 assert_eq!(config.defaults.scale, 1);
320 assert_eq!(config.defaults.padding, 1);
321 }
322
323 #[test]
324 fn test_load_config_invalid_toml() {
325 let temp = TempDir::new().unwrap();
326 let config_path = temp.path().join("pxl.toml");
327 File::create(&config_path).unwrap().write_all(b"this is not valid toml {{{").unwrap();
328
329 let result = load_config(Some(&config_path));
330 assert!(matches!(result, Err(ConfigError::Parse(_))));
331 }
332
333 #[test]
334 fn test_load_config_validation_error() {
335 let temp = TempDir::new().unwrap();
336 let config_path = temp.path().join("pxl.toml");
337 File::create(&config_path)
338 .unwrap()
339 .write_all(
340 br#"
341[project]
342name = ""
343
344[defaults]
345scale = 0
346"#,
347 )
348 .unwrap();
349
350 let result = load_config(Some(&config_path));
351 assert!(matches!(result, Err(ConfigError::Validation(_))));
352 }
353
354 #[test]
355 fn test_merge_cli_overrides_out() {
356 let mut config = default_config();
357 let overrides = CliOverrides { out: Some(PathBuf::from("dist")), ..Default::default() };
358
359 merge_cli_overrides(&mut config, &overrides);
360 assert_eq!(config.project.out, PathBuf::from("dist"));
361 }
362
363 #[test]
364 fn test_merge_cli_overrides_src() {
365 let mut config = default_config();
366 let overrides =
367 CliOverrides { src: Some(PathBuf::from("assets/pxl")), ..Default::default() };
368
369 merge_cli_overrides(&mut config, &overrides);
370 assert_eq!(config.project.src, PathBuf::from("assets/pxl"));
371 }
372
373 #[test]
374 fn test_merge_cli_overrides_scale() {
375 let mut config = default_config();
376 let overrides = CliOverrides { scale: Some(4), ..Default::default() };
377
378 merge_cli_overrides(&mut config, &overrides);
379 assert_eq!(config.defaults.scale, 4);
380 }
381
382 #[test]
383 fn test_merge_cli_overrides_strict() {
384 let mut config = default_config();
385 assert!(!config.validate.strict);
386
387 let overrides = CliOverrides { strict: Some(true), ..Default::default() };
388
389 merge_cli_overrides(&mut config, &overrides);
390 assert!(config.validate.strict);
391 }
392
393 #[test]
394 fn test_merge_cli_overrides_multiple() {
395 let mut config = default_config();
396 let overrides = CliOverrides {
397 out: Some(PathBuf::from("output")),
398 scale: Some(2),
399 padding: Some(4),
400 strict: Some(true),
401 ..Default::default()
402 };
403
404 merge_cli_overrides(&mut config, &overrides);
405 assert_eq!(config.project.out, PathBuf::from("output"));
406 assert_eq!(config.defaults.scale, 2);
407 assert_eq!(config.defaults.padding, 4);
408 assert!(config.validate.strict);
409 }
410
411 #[test]
412 fn test_resolve_path_absolute() {
413 let root = Path::new("/project");
414 let absolute = Path::new("/other/path");
415 assert_eq!(resolve_path(root, absolute), PathBuf::from("/other/path"));
416 }
417
418 #[test]
419 fn test_resolve_path_relative() {
420 let root = Path::new("/project");
421 let relative = Path::new("src/pxl");
422 assert_eq!(resolve_path(root, relative), PathBuf::from("/project/src/pxl"));
423 }
424
425 #[test]
426 fn test_project_root() {
427 let config_path = Path::new("/project/pxl.toml");
428 assert_eq!(project_root(config_path), Some(Path::new("/project")));
429 }
430
431 #[test]
432 fn test_default_config() {
433 let config = default_config();
434 assert!(!config.project.name.is_empty());
435 assert_eq!(config.project.version, "0.1.0");
436 assert_eq!(config.project.src, PathBuf::from("src/pxl"));
437 assert_eq!(config.project.out, PathBuf::from("build"));
438 }
439}