| @@ -82,13 +82,28 @@ impl AppConfig { | |||||
| path: path.to_path_buf(), | path: path.to_path_buf(), | ||||
| source, | source, | ||||
| })?; | })?; | ||||
| Self::load_from_str_with_env(&contents, |name| std::env::var(name)).map_err(|error| { | |||||
| match error { | |||||
| ConfigError::Parse { source, .. } => ConfigError::Parse { | |||||
| path: path.to_path_buf(), | |||||
| source, | |||||
| }, | |||||
| error => error, | |||||
| } | |||||
| }) | |||||
| } | |||||
| fn load_from_str_with_env<F, E>(contents: &str, env: F) -> Result<Self, ConfigError> | |||||
| where | |||||
| F: Fn(&str) -> Result<String, E>, | |||||
| { | |||||
| let mut config: Self = | let mut config: Self = | ||||
| serde_yaml::from_str(&contents).map_err(|source| ConfigError::Parse { | |||||
| path: path.to_path_buf(), | |||||
| serde_yaml::from_str(contents).map_err(|source| ConfigError::Parse { | |||||
| path: PathBuf::from("<string>"), | |||||
| source, | source, | ||||
| })?; | })?; | ||||
| config.expand_env_refs()?; | |||||
| config.expand_env_refs_with(env)?; | |||||
| Ok(config) | Ok(config) | ||||
| } | } | ||||
| @@ -109,24 +124,27 @@ impl AppConfig { | |||||
| }) | }) | ||||
| } | } | ||||
| fn expand_env_refs(&mut self) -> Result<(), ConfigError> { | |||||
| fn expand_env_refs_with<F, E>(&mut self, env: F) -> Result<(), ConfigError> | |||||
| where | |||||
| F: Fn(&str) -> Result<String, E>, | |||||
| { | |||||
| if let Some(default_provider) = &mut self.default_provider { | if let Some(default_provider) = &mut self.default_provider { | ||||
| *default_provider = expand_env_refs(default_provider)?; | |||||
| *default_provider = expand_env_refs_with(default_provider, &env)?; | |||||
| } | } | ||||
| for provider in self.providers.values_mut() { | for provider in self.providers.values_mut() { | ||||
| provider.base_url = expand_env_refs(&provider.base_url)?; | |||||
| provider.api_token = expand_env_refs(&provider.api_token)?; | |||||
| provider.default_model = expand_env_refs(&provider.default_model)?; | |||||
| provider.base_url = expand_env_refs_with(&provider.base_url, &env)?; | |||||
| provider.api_token = expand_env_refs_with(&provider.api_token, &env)?; | |||||
| provider.default_model = expand_env_refs_with(&provider.default_model, &env)?; | |||||
| } | } | ||||
| self.benchmarks.data_dir = expand_env_refs(&self.benchmarks.data_dir)?; | |||||
| self.benchmarks.data_dir = expand_env_refs_with(&self.benchmarks.data_dir, &env)?; | |||||
| if let Some(dataset) = &mut self.benchmarks.aime2026 { | if let Some(dataset) = &mut self.benchmarks.aime2026 { | ||||
| dataset.expand_env_refs()?; | |||||
| dataset.expand_env_refs_with(&env)?; | |||||
| } | } | ||||
| if let Some(dataset) = &mut self.benchmarks.gpqa_diamond { | if let Some(dataset) = &mut self.benchmarks.gpqa_diamond { | ||||
| dataset.expand_env_refs()?; | |||||
| dataset.expand_env_refs_with(&env)?; | |||||
| } | } | ||||
| Ok(()) | Ok(()) | ||||
| @@ -134,9 +152,12 @@ impl AppConfig { | |||||
| } | } | ||||
| impl DatasetConfig { | impl DatasetConfig { | ||||
| fn expand_env_refs(&mut self) -> Result<(), ConfigError> { | |||||
| self.source = expand_env_refs(&self.source)?; | |||||
| self.split = expand_env_refs(&self.split)?; | |||||
| fn expand_env_refs_with<F, E>(&mut self, env: F) -> Result<(), ConfigError> | |||||
| where | |||||
| F: Fn(&str) -> Result<String, E>, | |||||
| { | |||||
| self.source = expand_env_refs_with(&self.source, &env)?; | |||||
| self.split = expand_env_refs_with(&self.split, &env)?; | |||||
| Ok(()) | Ok(()) | ||||
| } | } | ||||
| } | } | ||||
| @@ -145,7 +166,10 @@ fn default_benchmark_data_dir() -> String { | |||||
| "data/benchmarks".to_string() | "data/benchmarks".to_string() | ||||
| } | } | ||||
| fn expand_env_refs(value: &str) -> Result<String, ConfigError> { | |||||
| fn expand_env_refs_with<F, E>(value: &str, env: F) -> Result<String, ConfigError> | |||||
| where | |||||
| F: Fn(&str) -> Result<String, E>, | |||||
| { | |||||
| let regex = Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("valid env regex"); | let regex = Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("valid env regex"); | ||||
| let mut expanded = String::with_capacity(value.len()); | let mut expanded = String::with_capacity(value.len()); | ||||
| let mut last_match = 0; | let mut last_match = 0; | ||||
| @@ -153,7 +177,7 @@ fn expand_env_refs(value: &str) -> Result<String, ConfigError> { | |||||
| for captures in regex.captures_iter(value) { | for captures in regex.captures_iter(value) { | ||||
| let full_match = captures.get(0).expect("full match exists"); | let full_match = captures.get(0).expect("full match exists"); | ||||
| let env_name = captures.get(1).expect("env name exists").as_str(); | let env_name = captures.get(1).expect("env name exists").as_str(); | ||||
| let env_value = std::env::var(env_name).map_err(|_| ConfigError::MissingEnv { | |||||
| let env_value = env(env_name).map_err(|_| ConfigError::MissingEnv { | |||||
| name: env_name.to_string(), | name: env_name.to_string(), | ||||
| })?; | })?; | ||||
| @@ -172,14 +196,7 @@ mod tests { | |||||
| #[test] | #[test] | ||||
| fn loads_provider_and_expands_env_token() { | fn loads_provider_and_expands_env_token() { | ||||
| unsafe { | |||||
| std::env::set_var("LQ_TEST_TOKEN", "secret-token"); | |||||
| } | |||||
| let temp_dir = tempfile::tempdir().expect("create temp dir"); | |||||
| let config_path = temp_dir.path().join("config.yaml"); | |||||
| std::fs::write( | |||||
| &config_path, | |||||
| let config = AppConfig::load_from_str_with_env( | |||||
| r#" | r#" | ||||
| default_provider: openai | default_provider: openai | ||||
| providers: | providers: | ||||
| @@ -191,10 +208,13 @@ providers: | |||||
| benchmarks: | benchmarks: | ||||
| data_dir: data/benchmarks | data_dir: data/benchmarks | ||||
| "#, | "#, | ||||
| |name| match name { | |||||
| "LQ_TEST_TOKEN" => Ok("secret-token".to_string()), | |||||
| _ => Err(()), | |||||
| }, | |||||
| ) | ) | ||||
| .expect("write config"); | |||||
| .expect("load config"); | |||||
| let config = AppConfig::load(&config_path).expect("load config"); | |||||
| let provider = config.provider(None).expect("default provider"); | let provider = config.provider(None).expect("default provider"); | ||||
| assert_eq!(provider.api_token, "secret-token"); | assert_eq!(provider.api_token, "secret-token"); | ||||
| @@ -204,14 +224,7 @@ benchmarks: | |||||
| #[test] | #[test] | ||||
| fn rejects_missing_env_token() { | fn rejects_missing_env_token() { | ||||
| unsafe { | |||||
| std::env::remove_var("LQ_MISSING_TOKEN"); | |||||
| } | |||||
| let temp_dir = tempfile::tempdir().expect("create temp dir"); | |||||
| let config_path = temp_dir.path().join("config.yaml"); | |||||
| std::fs::write( | |||||
| &config_path, | |||||
| let error = AppConfig::load_from_str_with_env( | |||||
| r#" | r#" | ||||
| default_provider: openai | default_provider: openai | ||||
| providers: | providers: | ||||
| @@ -222,12 +235,37 @@ providers: | |||||
| default_model: gpt-test | default_model: gpt-test | ||||
| benchmarks: | benchmarks: | ||||
| data_dir: data/benchmarks | data_dir: data/benchmarks | ||||
| "#, | |||||
| |_| Err(()), | |||||
| ) | |||||
| .expect_err("missing env should fail"); | |||||
| assert!(error.to_string().contains("LQ_MISSING_TOKEN")); | |||||
| } | |||||
| #[test] | |||||
| fn load_reads_yaml_from_path_without_env_refs() { | |||||
| let temp_dir = tempfile::tempdir().expect("create temp dir"); | |||||
| let config_path = temp_dir.path().join("config.yaml"); | |||||
| std::fs::write( | |||||
| &config_path, | |||||
| r#" | |||||
| default_provider: anthropic | |||||
| providers: | |||||
| anthropic: | |||||
| protocol: anthropic | |||||
| base_url: https://api.anthropic.test | |||||
| api_token: literal-token | |||||
| default_model: claude-test | |||||
| "#, | "#, | ||||
| ) | ) | ||||
| .expect("write config"); | .expect("write config"); | ||||
| let error = AppConfig::load(&config_path).expect_err("missing env should fail"); | |||||
| let config = AppConfig::load(&config_path).expect("load config"); | |||||
| let provider = config.provider(None).expect("default provider"); | |||||
| assert!(error.to_string().contains("LQ_MISSING_TOKEN")); | |||||
| assert_eq!(provider.api_token, "literal-token"); | |||||
| assert_eq!(provider.protocol, ProtocolKind::Anthropic); | |||||
| assert_eq!(config.benchmarks.data_dir, "data/benchmarks"); | |||||
| } | } | ||||
| } | } | ||||