Skip to main content

cargo_image_runner/config/
loader.rs

1use super::Config;
2use crate::core::error::{Error, Result};
3#[cfg(feature = "cargo-metadata")]
4use cargo_metadata::MetadataCommand;
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7
8/// Configuration loader that supports multiple sources.
9pub struct ConfigLoader {
10    /// Path to workspace root.
11    workspace_root: Option<PathBuf>,
12    /// Path to standalone config file.
13    config_file: Option<PathBuf>,
14    /// Whether to load from Cargo.toml metadata.
15    use_cargo_metadata: bool,
16}
17
18impl ConfigLoader {
19    /// Create a new configuration loader.
20    pub fn new() -> Self {
21        Self {
22            workspace_root: None,
23            config_file: None,
24            use_cargo_metadata: cfg!(feature = "cargo-metadata"),
25        }
26    }
27
28    /// Set the workspace root directory.
29    pub fn workspace_root(mut self, root: impl Into<PathBuf>) -> Self {
30        self.workspace_root = Some(root.into());
31        self
32    }
33
34    /// Set a standalone configuration file path.
35    pub fn config_file(mut self, path: impl Into<PathBuf>) -> Self {
36        self.config_file = Some(path.into());
37        self
38    }
39
40    /// Disable loading from Cargo.toml metadata.
41    pub fn no_cargo_metadata(mut self) -> Self {
42        self.use_cargo_metadata = false;
43        self
44    }
45
46    /// Load configuration from all enabled sources.
47    ///
48    /// Priority (later sources override earlier):
49    /// 1. Default values
50    /// 2. Cargo.toml metadata (workspace then package)
51    /// 3. Standalone TOML file
52    /// 4. Profile overlay (`CARGO_IMAGE_RUNNER_PROFILE`)
53    /// 5. Individual env var overrides (`CARGO_IMAGE_RUNNER_*`)
54    pub fn load(self) -> Result<(Config, PathBuf)> {
55        let mut config = Config::default();
56        let workspace_root;
57        #[allow(unused_mut)]
58        let mut profiles: HashMap<String, serde_json::Value> = HashMap::new();
59
60        // Load from Cargo metadata if enabled
61        if self.use_cargo_metadata {
62            #[cfg(feature = "cargo-metadata")]
63            {
64                let (root, cargo_config, cargo_profiles) = self.load_cargo_metadata()?;
65                workspace_root = root;
66                config = Self::merge_configs(config, cargo_config);
67                profiles = cargo_profiles;
68            }
69            #[cfg(not(feature = "cargo-metadata"))]
70            {
71                return Err(Error::feature_not_enabled("cargo-metadata"));
72            }
73        } else {
74            workspace_root = self
75                .workspace_root
76                .clone()
77                .ok_or_else(|| Error::config("workspace root not specified"))?;
78        }
79
80        // Load from standalone file if specified
81        if let Some(ref config_path) = self.config_file {
82            let file_config = self.load_toml_file(config_path)?;
83            config = Self::merge_configs(config, file_config);
84        }
85
86        // Apply profile overlay if CARGO_IMAGE_RUNNER_PROFILE is set
87        if let Some(profile_name) = super::env::get_profile_name() {
88            let profile_value = profiles.get(&profile_name).ok_or_else(|| {
89                let available: Vec<&String> = profiles.keys().collect();
90                if available.is_empty() {
91                    Error::config(format!(
92                        "profile '{}' not found (no profiles defined)",
93                        profile_name,
94                    ))
95                } else {
96                    Error::config(format!(
97                        "profile '{}' not found. Available profiles: {}",
98                        profile_name,
99                        available.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "),
100                    ))
101                }
102            })?;
103
104            let mut base_value = serde_json::to_value(&config)
105                .map_err(|e| Error::config(format!("failed to serialize config: {}", e)))?;
106            deep_merge(&mut base_value, profile_value);
107            config = serde_json::from_value(base_value)
108                .map_err(|e| Error::config(format!("failed to apply profile '{}': {}", profile_name, e)))?;
109        }
110
111        // Apply individual env var overrides (highest priority)
112        super::env::apply_env_overrides(&mut config);
113
114        Ok((config, workspace_root))
115    }
116
117    /// Load configuration from Cargo.toml metadata.
118    ///
119    /// Returns `(workspace_root, config, profiles)`.
120    /// Priority: package metadata > workspace metadata > defaults.
121    /// Profiles are collected from both workspace and package metadata
122    /// (package profiles override workspace profiles with the same name).
123    #[cfg(feature = "cargo-metadata")]
124    fn load_cargo_metadata(
125        &self,
126    ) -> Result<(PathBuf, Config, HashMap<String, serde_json::Value>)> {
127        let manifest_path = std::env::var("CARGO_MANIFEST_PATH").ok();
128
129        let mut cmd = MetadataCommand::new();
130        if let Some(manifest_path) = manifest_path {
131            cmd.manifest_path(manifest_path);
132        }
133
134        let metadata = cmd.exec()?;
135        let workspace_root = metadata.workspace_root.clone().into_std_path_buf();
136
137        let mut profiles: HashMap<String, serde_json::Value> = HashMap::new();
138
139        // Parse workspace metadata: [workspace.metadata.image-runner]
140        let workspace_config = if let Some(ws_value) = metadata.workspace_metadata.get("image-runner") {
141            // Extract profiles before deserializing Config
142            extract_profiles(ws_value, &mut profiles);
143
144            Some(
145                serde_json::from_value::<Config>(ws_value.clone())
146                    .map_err(|e| Error::config(format!("invalid workspace metadata: {}", e)))?,
147            )
148        } else {
149            None
150        };
151
152        // Try to find the package metadata
153        let pkg_name = std::env::var("CARGO_PKG_NAME").ok();
154        let package = if let Some(ref pkg_name) = pkg_name {
155            metadata
156                .packages
157                .iter()
158                .find(|p| &p.name == pkg_name)
159                .or_else(|| metadata.root_package())
160        } else {
161            metadata.root_package()
162        };
163
164        // Parse package metadata: [package.metadata.image-runner]
165        let package_config = if let Some(package) = package {
166            if let Some(metadata_value) = package.metadata.get("image-runner") {
167                // Package profiles override workspace profiles
168                extract_profiles(metadata_value, &mut profiles);
169
170                Some(
171                    serde_json::from_value::<Config>(metadata_value.clone())
172                        .map_err(|e| Error::config(format!("invalid Cargo.toml metadata: {}", e)))?,
173                )
174            } else {
175                None
176            }
177        } else {
178            None
179        };
180
181        // Merge: defaults <- workspace <- package
182        let mut config = Config::default();
183        if let Some(ws_config) = workspace_config {
184            config = Self::merge_configs(config, ws_config);
185        }
186        if let Some(pkg_config) = package_config {
187            config = Self::merge_configs(config, pkg_config);
188        }
189
190        Ok((workspace_root, config, profiles))
191    }
192
193    /// Load configuration from a standalone TOML file.
194    fn load_toml_file(&self, path: &Path) -> Result<Config> {
195        let content = std::fs::read_to_string(path)
196            .map_err(|e| Error::config(format!("failed to read config file: {}", e)))?;
197
198        toml::from_str(&content)
199            .map_err(|e| Error::config(format!("failed to parse TOML config: {}", e)))
200    }
201
202    /// Merge two configurations, with `override_config` taking precedence.
203    pub(crate) fn merge_configs(mut base: Config, override_cfg: Config) -> Config {
204        base.boot = override_cfg.boot;
205        base.bootloader = override_cfg.bootloader;
206        base.image = override_cfg.image;
207        base.runner = override_cfg.runner;
208        base.test = override_cfg.test;
209        base.run = override_cfg.run;
210        base.verbose = override_cfg.verbose;
211
212        // Merge variables (override wins per-key, base keys preserved)
213        for (k, v) in override_cfg.variables {
214            base.variables.insert(k, v);
215        }
216
217        // Merge extra_files (override wins per-key, base keys preserved)
218        for (k, v) in override_cfg.extra_files {
219            base.extra_files.insert(k, v);
220        }
221
222        base
223    }
224}
225
226impl Default for ConfigLoader {
227    fn default() -> Self {
228        Self::new()
229    }
230}
231
232/// Extract profile definitions from a metadata JSON value.
233///
234/// Profiles live at `value["profiles"]` as `{ name: { ...config fields... } }`.
235/// Package-level profiles override workspace-level profiles with the same name.
236#[cfg(feature = "cargo-metadata")]
237fn extract_profiles(
238    value: &serde_json::Value,
239    profiles: &mut HashMap<String, serde_json::Value>,
240) {
241    if let Some(serde_json::Value::Object(map)) = value.get("profiles") {
242        for (name, profile_value) in map {
243            profiles.insert(name.clone(), profile_value.clone());
244        }
245    }
246}
247
248/// Recursively deep-merge `overlay` into `base`.
249///
250/// - Objects: keys are merged recursively (overlay keys win for conflicts).
251/// - Scalars and arrays: overlay replaces base entirely.
252pub(crate) fn deep_merge(base: &mut serde_json::Value, overlay: &serde_json::Value) {
253    match (base, overlay) {
254        (serde_json::Value::Object(base_map), serde_json::Value::Object(overlay_map)) => {
255            for (key, overlay_val) in overlay_map {
256                let entry = base_map
257                    .entry(key.clone())
258                    .or_insert(serde_json::Value::Null);
259                deep_merge(entry, overlay_val);
260            }
261        }
262        (base, overlay) => {
263            *base = overlay.clone();
264        }
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use crate::config::{BootType, BootloaderKind, ImageFormat};
272
273    #[test]
274    fn test_load_standalone_toml_file() {
275        let dir = tempfile::tempdir().unwrap();
276        let config_path = dir.path().join("image-runner.toml");
277        std::fs::write(
278            &config_path,
279            r#"
280[boot]
281type = "hybrid"
282
283[bootloader]
284kind = "limine"
285config-file = "limine.conf"
286
287[image]
288format = "iso"
289
290[variables]
291TIMEOUT = "5"
292"#,
293        )
294        .unwrap();
295
296        let loader = ConfigLoader::new()
297            .no_cargo_metadata()
298            .workspace_root(dir.path())
299            .config_file(&config_path);
300        let (config, root) = loader.load().unwrap();
301
302        assert_eq!(config.boot.boot_type, BootType::Hybrid);
303        assert_eq!(config.bootloader.kind, BootloaderKind::Limine);
304        assert_eq!(config.image.format, ImageFormat::Iso);
305        assert_eq!(config.variables.get("TIMEOUT").unwrap(), "5");
306        assert_eq!(root, dir.path());
307    }
308
309    #[test]
310    fn test_merge_configs_override_behavior() {
311        let base = Config::default();
312        let mut override_cfg = Config::default();
313        override_cfg.boot.boot_type = BootType::Hybrid;
314        override_cfg.bootloader.kind = BootloaderKind::Limine;
315        override_cfg.image.format = ImageFormat::Iso;
316
317        let merged = ConfigLoader::merge_configs(base, override_cfg);
318        assert_eq!(merged.boot.boot_type, BootType::Hybrid);
319        assert_eq!(merged.bootloader.kind, BootloaderKind::Limine);
320        assert_eq!(merged.image.format, ImageFormat::Iso);
321    }
322
323    #[test]
324    fn test_merge_configs_variable_merging() {
325        let mut base = Config::default();
326        base.variables
327            .insert("A".to_string(), "base_a".to_string());
328        base.variables
329            .insert("B".to_string(), "base_b".to_string());
330
331        let mut override_cfg = Config::default();
332        override_cfg
333            .variables
334            .insert("B".to_string(), "override_b".to_string());
335        override_cfg
336            .variables
337            .insert("C".to_string(), "override_c".to_string());
338
339        let merged = ConfigLoader::merge_configs(base, override_cfg);
340        assert_eq!(merged.variables.get("A").unwrap(), "base_a");
341        assert_eq!(merged.variables.get("B").unwrap(), "override_b");
342        assert_eq!(merged.variables.get("C").unwrap(), "override_c");
343    }
344
345    #[test]
346    fn test_missing_config_file_error() {
347        let loader = ConfigLoader::new()
348            .no_cargo_metadata()
349            .workspace_root("/tmp")
350            .config_file("/nonexistent/config.toml");
351        let result = loader.load();
352        assert!(result.is_err());
353    }
354
355    #[test]
356    fn test_invalid_toml_error() {
357        let dir = tempfile::tempdir().unwrap();
358        let config_path = dir.path().join("bad.toml");
359        std::fs::write(&config_path, "this is not valid { toml [[[").unwrap();
360
361        let loader = ConfigLoader::new()
362            .no_cargo_metadata()
363            .workspace_root(dir.path())
364            .config_file(&config_path);
365        let result = loader.load();
366        assert!(result.is_err());
367    }
368
369    #[test]
370    fn test_no_cargo_metadata_requires_workspace_root() {
371        let loader = ConfigLoader::new().no_cargo_metadata();
372        let result = loader.load();
373        assert!(result.is_err());
374    }
375
376    #[test]
377    fn test_merge_configs_extra_files_merging() {
378        let mut base = Config::default();
379        base.extra_files
380            .insert("boot/a.txt".to_string(), "a.txt".to_string());
381        base.extra_files
382            .insert("boot/b.txt".to_string(), "b.txt".to_string());
383
384        let mut override_cfg = Config::default();
385        override_cfg
386            .extra_files
387            .insert("boot/b.txt".to_string(), "new_b.txt".to_string());
388        override_cfg
389            .extra_files
390            .insert("boot/c.txt".to_string(), "c.txt".to_string());
391
392        let merged = ConfigLoader::merge_configs(base, override_cfg);
393        assert_eq!(merged.extra_files.get("boot/a.txt").unwrap(), "a.txt");
394        assert_eq!(merged.extra_files.get("boot/b.txt").unwrap(), "new_b.txt");
395        assert_eq!(merged.extra_files.get("boot/c.txt").unwrap(), "c.txt");
396    }
397
398    #[test]
399    fn test_deep_merge_objects() {
400        let mut base = serde_json::json!({
401            "boot": { "type": "uefi" },
402            "runner": { "qemu": { "memory": 1024, "cores": 1 } }
403        });
404        let overlay = serde_json::json!({
405            "runner": { "qemu": { "memory": 4096 } }
406        });
407        deep_merge(&mut base, &overlay);
408        // memory overridden, cores preserved, boot preserved
409        assert_eq!(base["runner"]["qemu"]["memory"], 4096);
410        assert_eq!(base["runner"]["qemu"]["cores"], 1);
411        assert_eq!(base["boot"]["type"], "uefi");
412    }
413
414    #[test]
415    fn test_deep_merge_array_replaces() {
416        let mut base = serde_json::json!({
417            "runner": { "qemu": { "extra_args": ["-serial", "stdio"] } }
418        });
419        let overlay = serde_json::json!({
420            "runner": { "qemu": { "extra_args": ["-s", "-S"] } }
421        });
422        deep_merge(&mut base, &overlay);
423        assert_eq!(
424            base["runner"]["qemu"]["extra_args"],
425            serde_json::json!(["-s", "-S"])
426        );
427    }
428
429    #[test]
430    fn test_deep_merge_scalar_replaces() {
431        let mut base = serde_json::json!({ "verbose": false });
432        let overlay = serde_json::json!({ "verbose": true });
433        deep_merge(&mut base, &overlay);
434        assert_eq!(base["verbose"], true);
435    }
436
437    #[cfg(feature = "cargo-metadata")]
438    #[test]
439    fn test_extract_profiles_from_json() {
440        let value = serde_json::json!({
441            "boot": { "type": "uefi" },
442            "profiles": {
443                "debug": {
444                    "verbose": true,
445                    "runner": { "qemu": { "memory": 4096 } }
446                },
447                "ci": {
448                    "runner": { "qemu": { "kvm": false } }
449                }
450            }
451        });
452        let mut profiles = HashMap::new();
453        extract_profiles(&value, &mut profiles);
454        assert_eq!(profiles.len(), 2);
455        assert!(profiles.contains_key("debug"));
456        assert!(profiles.contains_key("ci"));
457        assert_eq!(profiles["debug"]["verbose"], true);
458    }
459
460    #[cfg(feature = "cargo-metadata")]
461    #[test]
462    fn test_extract_profiles_none() {
463        let value = serde_json::json!({ "boot": { "type": "uefi" } });
464        let mut profiles = HashMap::new();
465        extract_profiles(&value, &mut profiles);
466        assert!(profiles.is_empty());
467    }
468
469    #[test]
470    fn test_profile_application_via_deep_merge() {
471        // Simulate what load() does: serialize config, merge profile, deserialize
472        let config = Config::default();
473        let mut base_value = serde_json::to_value(&config).unwrap();
474
475        let profile = serde_json::json!({
476            "verbose": true,
477            "runner": { "qemu": { "memory": 4096 } }
478        });
479        deep_merge(&mut base_value, &profile);
480
481        let result: Config = serde_json::from_value(base_value).unwrap();
482        assert!(result.verbose);
483        assert_eq!(result.runner.qemu.memory, 4096);
484        // Other defaults preserved
485        assert_eq!(result.runner.qemu.cores, 1);
486        assert_eq!(result.boot.boot_type, BootType::Uefi);
487    }
488}