config.rs (22697B)
1 use config::{Config, Environment, File, Map, Value}; 2 use serde::de::DeserializeOwned; 3 use std::collections::{BTreeMap, BTreeSet}; 4 use std::fs; 5 use std::path::{Path, PathBuf}; 6 use thiserror::Error; 7 8 use crate::error::RuntimeConfigError; 9 10 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 11 pub enum ConfigSourceKind { 12 ProcessEnv, 13 EnvFile, 14 Toml, 15 Caller, 16 Default, 17 } 18 19 impl ConfigSourceKind { 20 #[must_use] 21 pub fn as_str(self) -> &'static str { 22 match self { 23 Self::ProcessEnv => "process_env", 24 Self::EnvFile => "env_file", 25 Self::Toml => "toml", 26 Self::Caller => "caller", 27 Self::Default => "default", 28 } 29 } 30 31 #[must_use] 32 pub fn key_label(self, key: &str) -> String { 33 format!("{}:{key}", self.as_str()) 34 } 35 } 36 37 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 38 pub struct ConfigKeySpec { 39 pub name: &'static str, 40 } 41 42 impl ConfigKeySpec { 43 #[must_use] 44 pub const fn new(name: &'static str) -> Self { 45 Self { name } 46 } 47 } 48 49 #[derive(Debug, Clone, PartialEq, Eq)] 50 pub struct StrictEnvFileValues { 51 values: BTreeMap<String, String>, 52 } 53 54 impl StrictEnvFileValues { 55 #[must_use] 56 pub fn get(&self, key: &str) -> Option<&str> { 57 self.values.get(key).map(String::as_str) 58 } 59 60 pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> { 61 self.values 62 .iter() 63 .map(|(key, value)| (key.as_str(), value.as_str())) 64 } 65 66 #[must_use] 67 pub fn into_inner(self) -> BTreeMap<String, String> { 68 self.values 69 } 70 } 71 72 #[derive(Debug, Error)] 73 pub enum RuntimeEnvFileError { 74 #[error("failed to read env file {path}: {source}")] 75 Read { 76 path: PathBuf, 77 #[source] 78 source: std::io::Error, 79 }, 80 81 #[error("invalid env file {path} line {line}: expected KEY=VALUE")] 82 InvalidLine { path: PathBuf, line: usize }, 83 84 #[error("invalid env file {path} line {line}: empty key")] 85 EmptyKey { path: PathBuf, line: usize }, 86 87 #[error("invalid env file {path} line {line}: unknown environment variable `{key}`")] 88 UnknownKey { 89 path: PathBuf, 90 line: usize, 91 key: String, 92 }, 93 94 #[error( 95 "invalid env file {path} line {line}: duplicate environment variable `{key}` first set on line {first_line}" 96 )] 97 DuplicateKey { 98 path: PathBuf, 99 line: usize, 100 key: String, 101 first_line: usize, 102 }, 103 104 #[error("invalid env file {path} line {line}: unterminated quoted environment value")] 105 UnterminatedQuotedValue { path: PathBuf, line: usize }, 106 } 107 108 #[derive(Debug, Error, Clone, PartialEq, Eq)] 109 pub enum RuntimeConfigValueError { 110 #[error("{key} must be a boolean value, got `{value}`")] 111 Bool { key: String, value: String }, 112 113 #[error("{key} must be an unsigned integer, got `{value}`")] 114 U64 { key: String, value: String }, 115 116 #[error("{key} must be a non-negative integer, got `{value}`")] 117 Usize { key: String, value: String }, 118 } 119 120 pub fn load_strict_env_file( 121 path: impl AsRef<Path>, 122 supported_keys: &[&str], 123 ) -> Result<StrictEnvFileValues, RuntimeEnvFileError> { 124 let path = path.as_ref(); 125 let raw = fs::read_to_string(path).map_err(|source| RuntimeEnvFileError::Read { 126 path: path.to_path_buf(), 127 source, 128 })?; 129 parse_strict_env_file(raw.as_str(), path, supported_keys) 130 } 131 132 pub fn load_strict_env_file_with_specs( 133 path: impl AsRef<Path>, 134 supported_keys: &[ConfigKeySpec], 135 ) -> Result<StrictEnvFileValues, RuntimeEnvFileError> { 136 let keys: Vec<&str> = supported_keys.iter().map(|spec| spec.name).collect(); 137 load_strict_env_file(path, keys.as_slice()) 138 } 139 140 pub fn parse_strict_env_file( 141 raw: &str, 142 path: impl AsRef<Path>, 143 supported_keys: &[&str], 144 ) -> Result<StrictEnvFileValues, RuntimeEnvFileError> { 145 let path = path.as_ref(); 146 let supported_keys: BTreeSet<&str> = supported_keys.iter().copied().collect(); 147 let mut values = BTreeMap::new(); 148 let mut first_lines = BTreeMap::new(); 149 150 for (index, line) in raw.lines().enumerate() { 151 let line_number = index + 1; 152 let trimmed = line.trim(); 153 if trimmed.is_empty() || trimmed.starts_with('#') { 154 continue; 155 } 156 let Some((key, value)) = trimmed.split_once('=') else { 157 return Err(RuntimeEnvFileError::InvalidLine { 158 path: path.to_path_buf(), 159 line: line_number, 160 }); 161 }; 162 let key = key.trim(); 163 if key.is_empty() { 164 return Err(RuntimeEnvFileError::EmptyKey { 165 path: path.to_path_buf(), 166 line: line_number, 167 }); 168 } 169 if !supported_keys.contains(key) { 170 return Err(RuntimeEnvFileError::UnknownKey { 171 path: path.to_path_buf(), 172 line: line_number, 173 key: key.to_owned(), 174 }); 175 } 176 if let Some(first_line) = first_lines.get(key) { 177 return Err(RuntimeEnvFileError::DuplicateKey { 178 path: path.to_path_buf(), 179 line: line_number, 180 key: key.to_owned(), 181 first_line: *first_line, 182 }); 183 } 184 let value = normalize_env_value(value.trim(), path, line_number)?; 185 first_lines.insert(key.to_owned(), line_number); 186 values.insert(key.to_owned(), value); 187 } 188 189 Ok(StrictEnvFileValues { values }) 190 } 191 192 pub fn parse_strict_env_file_with_specs( 193 raw: &str, 194 path: impl AsRef<Path>, 195 supported_keys: &[ConfigKeySpec], 196 ) -> Result<StrictEnvFileValues, RuntimeEnvFileError> { 197 let keys: Vec<&str> = supported_keys.iter().map(|spec| spec.name).collect(); 198 parse_strict_env_file(raw, path, keys.as_slice()) 199 } 200 201 pub fn parse_bool_value(key: &str, value: &str) -> Result<bool, RuntimeConfigValueError> { 202 match value.trim().to_ascii_lowercase().as_str() { 203 "1" | "true" | "yes" | "on" => Ok(true), 204 "0" | "false" | "no" | "off" => Ok(false), 205 other => Err(RuntimeConfigValueError::Bool { 206 key: key.to_owned(), 207 value: other.to_owned(), 208 }), 209 } 210 } 211 212 pub fn parse_u64_value(key: &str, value: &str) -> Result<u64, RuntimeConfigValueError> { 213 value 214 .trim() 215 .parse::<u64>() 216 .map_err(|_| RuntimeConfigValueError::U64 { 217 key: key.to_owned(), 218 value: value.trim().to_owned(), 219 }) 220 } 221 222 pub fn parse_usize_value(key: &str, value: &str) -> Result<usize, RuntimeConfigValueError> { 223 value 224 .trim() 225 .parse::<usize>() 226 .map_err(|_| RuntimeConfigValueError::Usize { 227 key: key.to_owned(), 228 value: value.trim().to_owned(), 229 }) 230 } 231 232 #[must_use] 233 pub fn parse_optional_string_value(value: &str) -> Option<String> { 234 let trimmed = value.trim(); 235 if trimmed.is_empty() { 236 None 237 } else { 238 Some(trimmed.to_owned()) 239 } 240 } 241 242 #[must_use] 243 pub fn parse_string_list_value(value: &str) -> Vec<String> { 244 value 245 .split(',') 246 .map(str::trim) 247 .filter(|item| !item.is_empty()) 248 .map(str::to_owned) 249 .collect() 250 } 251 252 #[must_use] 253 pub fn parse_optional_path_value(value: &str) -> Option<PathBuf> { 254 parse_optional_string_value(value).map(PathBuf::from) 255 } 256 257 pub fn load_required_file<T>(path: impl AsRef<Path>) -> Result<T, RuntimeConfigError> 258 where 259 T: DeserializeOwned, 260 { 261 let p: &Path = path.as_ref(); 262 263 let cfg = Config::builder() 264 .add_source(File::from(p).required(true)) 265 .build() 266 .map_err(|source| RuntimeConfigError::Load { 267 path: p.to_path_buf(), 268 source, 269 })?; 270 271 try_deser::<T>(cfg, p) 272 } 273 274 pub fn load_required_file_with_env<T>( 275 path: impl AsRef<Path>, 276 env_prefix: &str, 277 ) -> Result<T, RuntimeConfigError> 278 where 279 T: DeserializeOwned, 280 { 281 let p: &Path = path.as_ref(); 282 283 let cfg = Config::builder() 284 .add_source(File::from(p).required(true)) 285 .add_source(Environment::with_prefix(env_prefix).separator("__")) 286 .build() 287 .map_err(|source| RuntimeConfigError::Load { 288 path: p.to_path_buf(), 289 source, 290 })?; 291 292 try_deser::<T>(cfg, p) 293 } 294 295 pub fn load_required_file_with_env_and_overrides<T>( 296 path: impl AsRef<Path>, 297 env_prefix: Option<&str>, 298 overrides: Option<Map<String, Value>>, 299 ) -> Result<T, RuntimeConfigError> 300 where 301 T: DeserializeOwned, 302 { 303 let p: &Path = path.as_ref(); 304 let mut builder = Config::builder().add_source(File::from(p).required(true)); 305 306 if let Some(prefix) = env_prefix { 307 builder = builder.add_source(Environment::with_prefix(prefix).separator("__")); 308 } 309 310 if let Some(ovr) = overrides { 311 for (k, v) in ovr { 312 builder = builder 313 .set_override(k, v) 314 .map_err(|source| RuntimeConfigError::Load { 315 path: p.to_path_buf(), 316 source, 317 })?; 318 } 319 } 320 321 let cfg = builder.build().map_err(|source| RuntimeConfigError::Load { 322 path: p.to_path_buf(), 323 source, 324 })?; 325 326 try_deser::<T>(cfg, p) 327 } 328 329 fn try_deser<T>(cfg: Config, p: &Path) -> Result<T, RuntimeConfigError> 330 where 331 T: DeserializeOwned, 332 { 333 cfg.try_deserialize::<T>() 334 .map_err(|source| RuntimeConfigError::Load { 335 path: PathBuf::from(p), 336 source, 337 }) 338 } 339 340 fn normalize_env_value( 341 value: &str, 342 path: &Path, 343 line_number: usize, 344 ) -> Result<String, RuntimeEnvFileError> { 345 if value.starts_with('"') || value.starts_with('\'') { 346 let quote = value.chars().next().expect("quoted env value prefix"); 347 if !value.ends_with(quote) || value.len() < 2 { 348 return Err(RuntimeEnvFileError::UnterminatedQuotedValue { 349 path: path.to_path_buf(), 350 line: line_number, 351 }); 352 } 353 return Ok(value[1..value.len() - 1].to_owned()); 354 } 355 Ok(value.to_owned()) 356 } 357 358 #[cfg(test)] 359 mod tests { 360 use super::{ 361 ConfigKeySpec, ConfigSourceKind, RuntimeConfigValueError, load_required_file, 362 load_required_file_with_env, load_required_file_with_env_and_overrides, 363 load_strict_env_file, load_strict_env_file_with_specs, parse_bool_value, 364 parse_optional_path_value, parse_optional_string_value, parse_strict_env_file, 365 parse_strict_env_file_with_specs, parse_string_list_value, parse_u64_value, 366 parse_usize_value, 367 }; 368 use config::{Map, Value}; 369 use serde::Deserialize; 370 use tempfile::tempdir; 371 372 use crate::error::RuntimeConfigError; 373 374 #[derive(Debug, Deserialize, PartialEq)] 375 struct RuntimeCfg { 376 logs_dir: String, 377 enabled: bool, 378 } 379 380 #[derive(Debug, Deserialize, PartialEq)] 381 struct NumberCfg { 382 count: u32, 383 } 384 385 fn write_config(contents: &str) -> (tempfile::TempDir, std::path::PathBuf) { 386 let dir = tempdir().expect("tempdir"); 387 let path = dir.path().join("runtime.toml"); 388 std::fs::write(&path, contents).expect("write config"); 389 (dir, path) 390 } 391 392 #[test] 393 fn config_source_kind_formats_labels() { 394 assert_eq!(ConfigSourceKind::ProcessEnv.as_str(), "process_env"); 395 assert_eq!(ConfigSourceKind::Toml.as_str(), "toml"); 396 assert_eq!(ConfigSourceKind::Caller.as_str(), "caller"); 397 assert_eq!(ConfigSourceKind::Default.as_str(), "default"); 398 assert_eq!( 399 ConfigSourceKind::EnvFile.key_label("RADROOTS_CLI_OUTPUT_FORMAT"), 400 "env_file:RADROOTS_CLI_OUTPUT_FORMAT" 401 ); 402 } 403 404 #[test] 405 fn strict_env_file_parses_supported_keys() { 406 let values = parse_strict_env_file( 407 r#" 408 # ignored 409 RADROOTS_CLI_OUTPUT_FORMAT = "json" 410 RADROOTS_CLI_HYF_ENABLED='true' 411 "#, 412 "runtime.env", 413 &["RADROOTS_CLI_OUTPUT_FORMAT", "RADROOTS_CLI_HYF_ENABLED"], 414 ) 415 .expect("parse env file"); 416 417 assert_eq!(values.get("RADROOTS_CLI_OUTPUT_FORMAT"), Some("json")); 418 assert_eq!(values.get("RADROOTS_CLI_HYF_ENABLED"), Some("true")); 419 assert_eq!( 420 values.iter().collect::<Vec<_>>(), 421 vec![ 422 ("RADROOTS_CLI_HYF_ENABLED", "true"), 423 ("RADROOTS_CLI_OUTPUT_FORMAT", "json") 424 ] 425 ); 426 } 427 428 #[test] 429 fn strict_env_file_rejects_unknown_keys() { 430 let err = parse_strict_env_file("RADROOTS_OUTPUT=json", "runtime.env", &[]) 431 .expect_err("unknown key should fail"); 432 433 assert_eq!( 434 err.to_string(), 435 "invalid env file runtime.env line 1: unknown environment variable `RADROOTS_OUTPUT`" 436 ); 437 } 438 439 #[test] 440 fn strict_env_file_rejects_duplicate_keys() { 441 let err = parse_strict_env_file( 442 r#" 443 RADROOTS_CLI_OUTPUT_FORMAT=json 444 RADROOTS_CLI_OUTPUT_FORMAT=ndjson 445 "#, 446 "runtime.env", 447 &["RADROOTS_CLI_OUTPUT_FORMAT"], 448 ) 449 .expect_err("duplicate key should fail"); 450 451 assert_eq!( 452 err.to_string(), 453 "invalid env file runtime.env line 3: duplicate environment variable `RADROOTS_CLI_OUTPUT_FORMAT` first set on line 2" 454 ); 455 } 456 457 #[test] 458 fn strict_env_file_rejects_invalid_line_empty_key_and_read_error() { 459 let invalid_line = parse_strict_env_file( 460 "RADROOTS_CLI_OUTPUT_FORMAT", 461 "runtime.env", 462 &["RADROOTS_CLI_OUTPUT_FORMAT"], 463 ) 464 .expect_err("missing equals should fail"); 465 assert_eq!( 466 invalid_line.to_string(), 467 "invalid env file runtime.env line 1: expected KEY=VALUE" 468 ); 469 470 let empty_key = 471 parse_strict_env_file("=json", "runtime.env", &["RADROOTS_CLI_OUTPUT_FORMAT"]) 472 .expect_err("empty key should fail"); 473 assert_eq!( 474 empty_key.to_string(), 475 "invalid env file runtime.env line 1: empty key" 476 ); 477 478 let dir = tempdir().expect("tempdir"); 479 let missing_path = dir.path().join("missing.env"); 480 let read_error = load_strict_env_file(&missing_path, &["RADROOTS_CLI_OUTPUT_FORMAT"]) 481 .expect_err("missing env file should fail"); 482 assert!(read_error.to_string().starts_with(&format!( 483 "failed to read env file {}", 484 missing_path.display() 485 ))); 486 } 487 488 #[test] 489 fn strict_env_file_rejects_unterminated_quotes() { 490 let err = parse_strict_env_file( 491 "RADROOTS_CLI_OUTPUT_FORMAT=\"json", 492 "runtime.env", 493 &["RADROOTS_CLI_OUTPUT_FORMAT"], 494 ) 495 .expect_err("unterminated quote should fail"); 496 497 assert_eq!( 498 err.to_string(), 499 "invalid env file runtime.env line 1: unterminated quoted environment value" 500 ); 501 502 let err = parse_strict_env_file( 503 "RADROOTS_CLI_OUTPUT_FORMAT=\"", 504 "runtime.env", 505 &["RADROOTS_CLI_OUTPUT_FORMAT"], 506 ) 507 .expect_err("single quote marker should fail"); 508 509 assert_eq!( 510 err.to_string(), 511 "invalid env file runtime.env line 1: unterminated quoted environment value" 512 ); 513 } 514 515 #[test] 516 fn strict_env_file_supports_key_specs_and_file_loading() { 517 let dir = tempdir().expect("tempdir"); 518 let path = dir.path().join("runtime.env"); 519 std::fs::write(&path, "RHI_PATHS_PROFILE=repo_local").expect("write env file"); 520 521 let values = 522 load_strict_env_file_with_specs(&path, &[ConfigKeySpec::new("RHI_PATHS_PROFILE")]) 523 .expect("load env file with specs"); 524 525 assert_eq!(values.get("RHI_PATHS_PROFILE"), Some("repo_local")); 526 527 let values = 528 load_strict_env_file(&path, &["RHI_PATHS_PROFILE"]).expect("load env file with keys"); 529 assert_eq!(values.into_inner().len(), 1); 530 531 let values = parse_strict_env_file_with_specs( 532 "RHI_PATHS_PROFILE=service_host", 533 "runtime.env", 534 &[ConfigKeySpec::new("RHI_PATHS_PROFILE")], 535 ) 536 .expect("parse env file with specs"); 537 assert_eq!(values.get("RHI_PATHS_PROFILE"), Some("service_host")); 538 } 539 540 #[test] 541 fn config_value_parsers_handle_shared_scalars() { 542 assert_eq!(parse_bool_value("KEY", "yes"), Ok(true)); 543 assert_eq!(parse_bool_value("KEY", "off"), Ok(false)); 544 assert_eq!(parse_u64_value("KEY_MS", "250"), Ok(250)); 545 assert_eq!(parse_usize_value("KEY_COUNT", "8"), Ok(8)); 546 assert_eq!(parse_optional_string_value(" "), None); 547 assert_eq!( 548 parse_optional_path_value(" state ").unwrap(), 549 std::path::PathBuf::from("state") 550 ); 551 assert_eq!( 552 parse_string_list_value("a, b,,c"), 553 vec!["a".to_owned(), "b".to_owned(), "c".to_owned()] 554 ); 555 } 556 557 #[test] 558 fn config_value_parsers_report_keyed_errors() { 559 assert_eq!( 560 parse_bool_value("KEY", "maybe"), 561 Err(RuntimeConfigValueError::Bool { 562 key: "KEY".to_owned(), 563 value: "maybe".to_owned(), 564 }) 565 ); 566 assert_eq!( 567 parse_u64_value("KEY_MS", "soon"), 568 Err(RuntimeConfigValueError::U64 { 569 key: "KEY_MS".to_owned(), 570 value: "soon".to_owned(), 571 }) 572 ); 573 assert_eq!( 574 parse_usize_value("KEY_COUNT", "many"), 575 Err(RuntimeConfigValueError::Usize { 576 key: "KEY_COUNT".to_owned(), 577 value: "many".to_owned(), 578 }) 579 ); 580 } 581 582 #[test] 583 fn load_required_file_reads_toml() { 584 let (_dir, path) = write_config( 585 r#" 586 logs_dir = "logs" 587 enabled = false 588 "#, 589 ); 590 591 let cfg: RuntimeCfg = load_required_file(&path).expect("load config"); 592 assert_eq!( 593 cfg, 594 RuntimeCfg { 595 logs_dir: "logs".to_string(), 596 enabled: false, 597 } 598 ); 599 } 600 601 #[test] 602 fn load_required_file_reports_missing_path() { 603 let path = std::path::PathBuf::from("/tmp/radroots_runtime-config-does-not-exist.toml"); 604 let err = load_required_file::<RuntimeCfg>(&path).expect_err("missing config should fail"); 605 match err { 606 RuntimeConfigError::Load { path: p, .. } => assert_eq!(p, path), 607 } 608 } 609 610 #[test] 611 fn load_required_file_reports_missing_path_for_number_cfg_owned_path() { 612 let path = std::path::PathBuf::from("/tmp/radroots_runtime-config-missing-number.toml"); 613 let err = 614 load_required_file::<NumberCfg>(path.clone()).expect_err("missing config should fail"); 615 match err { 616 RuntimeConfigError::Load { path: p, .. } => assert_eq!(p, path), 617 } 618 } 619 620 #[test] 621 fn load_required_file_reports_deserialize_failure() { 622 let (_dir, path) = write_config( 623 r#" 624 count = "not-a-number" 625 "#, 626 ); 627 628 let err = 629 load_required_file::<NumberCfg>(path.clone()).expect_err("invalid value should fail"); 630 match err { 631 RuntimeConfigError::Load { path: p, .. } => assert_eq!(p, path), 632 } 633 } 634 635 #[test] 636 fn load_required_file_with_env_path_executes_env_source() { 637 let (_dir, path) = write_config( 638 r#" 639 logs_dir = "logs" 640 enabled = true 641 "#, 642 ); 643 644 let cfg: RuntimeCfg = load_required_file_with_env(path.clone(), "RADROOTS_RUNTIME_TEST") 645 .expect("load config with env source"); 646 assert_eq!(cfg.logs_dir, "logs"); 647 assert!(cfg.enabled); 648 } 649 650 #[test] 651 fn load_required_file_with_env_reports_missing_path() { 652 let path = 653 std::path::PathBuf::from("/tmp/radroots_runtime-config-does-not-exist-with-env.toml"); 654 let err = load_required_file_with_env::<RuntimeCfg>(path.clone(), "RADROOTS_RUNTIME_TEST") 655 .expect_err("missing config should fail"); 656 match err { 657 RuntimeConfigError::Load { path: p, .. } => assert_eq!(p, path), 658 } 659 } 660 661 #[test] 662 fn load_required_file_with_env_and_overrides_applies_overrides() { 663 let (_dir, path) = write_config( 664 r#" 665 logs_dir = "logs" 666 enabled = false 667 "#, 668 ); 669 670 let mut overrides = Map::new(); 671 overrides.insert("enabled".to_string(), Value::from(true)); 672 let cfg: RuntimeCfg = load_required_file_with_env_and_overrides( 673 path.clone(), 674 Some("RADROOTS_RUNTIME_TEST"), 675 Some(overrides), 676 ) 677 .expect("load config with overrides"); 678 679 assert!(cfg.enabled); 680 assert_eq!(cfg.logs_dir, "logs"); 681 } 682 683 #[test] 684 fn load_required_file_with_env_and_overrides_handles_none_overrides() { 685 let (_dir, path) = write_config( 686 r#" 687 logs_dir = "logs" 688 enabled = true 689 "#, 690 ); 691 692 let cfg: RuntimeCfg = load_required_file_with_env_and_overrides(path.clone(), None, None) 693 .expect("load config without overrides"); 694 assert_eq!(cfg.logs_dir, "logs"); 695 assert!(cfg.enabled); 696 } 697 698 #[test] 699 fn load_required_file_with_env_and_overrides_reports_override_error() { 700 let (_dir, path) = write_config( 701 r#" 702 logs_dir = "logs" 703 enabled = false 704 "#, 705 ); 706 707 let mut overrides = Map::new(); 708 overrides.insert(String::new(), Value::from(true)); 709 let err = load_required_file_with_env_and_overrides::<RuntimeCfg>( 710 path.clone(), 711 None, 712 Some(overrides), 713 ) 714 .expect_err("invalid override should fail"); 715 716 match err { 717 RuntimeConfigError::Load { path: p, .. } => assert_eq!(p, path), 718 } 719 } 720 721 #[test] 722 fn load_required_file_with_env_and_overrides_reports_build_error() { 723 let path = std::path::PathBuf::from( 724 "/tmp/radroots_runtime-config-does-not-exist-with-overrides.toml", 725 ); 726 let err = load_required_file_with_env_and_overrides::<RuntimeCfg>(path.clone(), None, None) 727 .expect_err("missing config should fail"); 728 729 match err { 730 RuntimeConfigError::Load { path: p, .. } => assert_eq!(p, path), 731 } 732 } 733 734 #[test] 735 fn load_required_file_with_env_and_overrides_reports_runtime_cfg_deserialize_error() { 736 let (_dir, path) = write_config( 737 r#" 738 logs_dir = "logs" 739 enabled = "invalid" 740 "#, 741 ); 742 743 let err = load_required_file_with_env_and_overrides::<RuntimeCfg>(path.clone(), None, None) 744 .expect_err("deserialize should fail"); 745 match err { 746 RuntimeConfigError::Load { path: p, .. } => assert_eq!(p, path), 747 } 748 } 749 }