cargo_image_runner/config/
loader.rs1use super::Config;
2use crate::core::error::{Error, Result};
3use cargo_metadata::MetadataCommand;
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7pub struct ConfigLoader {
9 workspace_root: Option<PathBuf>,
11 config_file: Option<PathBuf>,
13 use_cargo_metadata: bool,
15}
16
17impl ConfigLoader {
18 pub fn new() -> Self {
20 Self {
21 workspace_root: None,
22 config_file: None,
23 use_cargo_metadata: true,
24 }
25 }
26
27 pub fn workspace_root(mut self, root: impl Into<PathBuf>) -> Self {
29 self.workspace_root = Some(root.into());
30 self
31 }
32
33 pub fn config_file(mut self, path: impl Into<PathBuf>) -> Self {
35 self.config_file = Some(path.into());
36 self
37 }
38
39 pub fn no_cargo_metadata(mut self) -> Self {
41 self.use_cargo_metadata = false;
42 self
43 }
44
45 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 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 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 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 super::env::apply_env_overrides(&mut config);
104
105 Ok((config, workspace_root))
106 }
107
108 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 let workspace_config = if let Some(ws_value) = metadata.workspace_metadata.get("image-runner") {
131 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 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 let package_config = if let Some(package) = package {
156 if let Some(metadata_value) = package.metadata.get("image-runner") {
157 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 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 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 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 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
217fn 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
232pub(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 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 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 assert_eq!(result.runner.qemu.cores, 1);
446 assert_eq!(result.boot.boot_type, BootType::Uefi);
447 }
448}