Skip to main content

pixelsrc/config/
loader.rs

1//! Configuration loading and discovery for `pxl.toml`
2//!
3//! Provides functions to find, load, and merge configuration.
4
5use 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/// Configuration loading error
16#[derive(Debug, Error)]
17pub enum ConfigError {
18    /// File I/O error
19    #[error("Failed to read config: {0}")]
20    Io(#[from] std::io::Error),
21    /// TOML parsing error
22    #[error("Failed to parse pxl.toml: {0}")]
23    Parse(#[from] toml::de::Error),
24    /// Validation error
25    #[error("Config validation failed:\n{}", .0.iter().map(|e| format!("  - {}", e)).collect::<Vec<_>>().join("\n"))]
26    Validation(Vec<String>),
27}
28
29/// CLI arguments that can override config values
30#[derive(Debug, Default, Clone)]
31pub struct CliOverrides {
32    /// Override output directory
33    pub out: Option<PathBuf>,
34    /// Override source directory
35    pub src: Option<PathBuf>,
36    /// Override scale factor
37    pub scale: Option<u32>,
38    /// Override padding
39    pub padding: Option<u32>,
40    /// Build specific atlas only
41    pub atlas: Option<String>,
42    /// Build specific export format only
43    pub export: Option<String>,
44    /// Enable strict validation
45    pub strict: Option<bool>,
46    /// Number of parallel jobs
47    pub jobs: Option<usize>,
48}
49
50/// Find pxl.toml by walking up from the current working directory.
51///
52/// Starts from the current directory and walks up parent directories
53/// until a `pxl.toml` file is found or the filesystem root is reached.
54///
55/// # Returns
56/// - `Some(path)` if a pxl.toml file is found
57/// - `None` if no config file is found
58///
59/// # Example
60/// ```ignore
61/// if let Some(config_path) = find_config() {
62///     println!("Found config at: {}", config_path.display());
63/// }
64/// ```
65pub fn find_config() -> Option<PathBuf> {
66    find_config_from(env::current_dir().ok()?)
67}
68
69/// Find pxl.toml by walking up from a specific directory.
70///
71/// This is the internal implementation that allows specifying the start directory,
72/// useful for testing.
73pub 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        // Move to parent directory
83        if !current.pop() {
84            // Reached root, no config found
85            return None;
86        }
87    }
88}
89
90/// Load configuration from a pxl.toml file.
91///
92/// If a path is provided, loads from that file. Otherwise, uses `find_config()`
93/// to locate the config file. If no config file is found, returns a default
94/// configuration.
95///
96/// # Arguments
97/// - `path` - Optional path to a pxl.toml file
98///
99/// # Returns
100/// - `Ok(PxlConfig)` on success
101/// - `Err(ConfigError)` if the file cannot be read or parsed
102///
103/// # Example
104/// ```ignore
105/// // Load from discovered config
106/// let config = load_config(None)?;
107///
108/// // Load from specific path
109/// let config = load_config(Some(Path::new("my-project/pxl.toml")))?;
110/// ```
111pub 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
123/// Load configuration from a specific file path.
124fn 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    // Validate the config
129    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
137/// Create a default configuration when no pxl.toml is found.
138///
139/// Returns a minimal valid configuration with the project name set to
140/// the current directory name.
141pub 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
163/// Merge CLI overrides into a configuration.
164///
165/// CLI arguments take precedence over config file values.
166///
167/// # Arguments
168/// - `config` - The configuration to modify
169/// - `overrides` - CLI overrides to apply
170///
171/// # Example
172/// ```ignore
173/// let mut config = load_config(None)?;
174/// let overrides = CliOverrides {
175///     out: Some(PathBuf::from("dist")),
176///     strict: Some(true),
177///     ..Default::default()
178/// };
179/// merge_cli_overrides(&mut config, &overrides);
180/// ```
181pub fn merge_cli_overrides(config: &mut PxlConfig, overrides: &CliOverrides) {
182    // Override output directory
183    if let Some(ref out) = overrides.out {
184        config.project.out = out.clone();
185    }
186
187    // Override source directory
188    if let Some(ref src) = overrides.src {
189        config.project.src = src.clone();
190    }
191
192    // Override scale
193    if let Some(scale) = overrides.scale {
194        config.defaults.scale = scale;
195    }
196
197    // Override padding
198    if let Some(padding) = overrides.padding {
199        config.defaults.padding = padding;
200    }
201
202    // Override strict mode
203    if let Some(strict) = overrides.strict {
204        config.validate.strict = strict;
205    }
206}
207
208/// Get the project root directory from a config file path.
209///
210/// Returns the parent directory of the pxl.toml file.
211pub fn project_root(config_path: &Path) -> Option<&Path> {
212    config_path.parent()
213}
214
215/// Resolve a path relative to the project root.
216///
217/// If the path is absolute, returns it unchanged.
218/// If relative, joins it with the project root.
219pub 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        // Create a subdirectory
251        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        // When file doesn't exist, load_config with explicit path should error
302        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        // When no config is found via find_config_from, default_config() is used
309        let temp = TempDir::new().unwrap();
310
311        // find_config_from returns None when no pxl.toml exists
312        let found = find_config_from(temp.path().to_path_buf());
313        assert!(found.is_none());
314
315        // default_config should return sensible defaults
316        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}