lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

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 }