cargo_image_runner/config/
loader.rs1use 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
8pub struct ConfigLoader {
10 workspace_root: Option<PathBuf>,
12 config_file: Option<PathBuf>,
14 use_cargo_metadata: bool,
16}
17
18impl ConfigLoader {
19 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 pub fn workspace_root(mut self, root: impl Into<PathBuf>) -> Self {
30 self.workspace_root = Some(root.into());
31 self
32 }
33
34 pub fn config_file(mut self, path: impl Into<PathBuf>) -> Self {
36 self.config_file = Some(path.into());
37 self
38 }
39
40 pub fn no_cargo_metadata(mut self) -> Self {
42 self.use_cargo_metadata = false;
43 self
44 }
45
46 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 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 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 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 super::env::apply_env_overrides(&mut config);
113
114 Ok((config, workspace_root))
115 }
116
117 #[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 let workspace_config = if let Some(ws_value) = metadata.workspace_metadata.get("image-runner") {
141 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 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 let package_config = if let Some(package) = package {
166 if let Some(metadata_value) = package.metadata.get("image-runner") {
167 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 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 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 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 for (k, v) in override_cfg.variables {
214 base.variables.insert(k, v);
215 }
216
217 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#[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
248pub(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 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 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 assert_eq!(result.runner.qemu.cores, 1);
486 assert_eq!(result.boot.boot_type, BootType::Uefi);
487 }
488}