Skip to main content

cargo_image_runner/config/
loader.rs

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