| use std::path::PathBuf; |
| use std::sync::LazyLock; |
|
|
| use config::ConfigBuilder; |
| use config::builder::DefaultState; |
|
|
| use crate::ForgeConfig; |
| use crate::legacy::LegacyConfig; |
|
|
| |
| |
| |
| static LOAD_DOT_ENV: LazyLock<()> = LazyLock::new(|| { |
| let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); |
| let mut paths = vec![]; |
| let mut current = PathBuf::new(); |
|
|
| for component in cwd.components() { |
| current.push(component); |
| paths.push(current.clone()); |
| } |
|
|
| paths.reverse(); |
|
|
| for path in paths { |
| let env_file = path.join(".env"); |
| if env_file.is_file() { |
| dotenvy::from_path(&env_file).ok(); |
| } |
| } |
| }); |
|
|
| |
| static BASE_PATH: LazyLock<PathBuf> = LazyLock::new(ConfigReader::resolve_base_path); |
|
|
| |
| #[derive(Default)] |
| pub struct ConfigReader { |
| builder: ConfigBuilder<DefaultState>, |
| } |
|
|
| impl ConfigReader { |
| |
| |
| pub fn config_legacy_path() -> PathBuf { |
| Self::base_path().join(".config.json") |
| } |
|
|
| |
| |
| pub fn config_path() -> PathBuf { |
| Self::base_path().join(".forge.toml") |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| pub fn base_path() -> PathBuf { |
| BASE_PATH.clone() |
| } |
|
|
| fn resolve_base_path() -> PathBuf { |
| if let Ok(path) = std::env::var("FORGE_CONFIG") { |
| return PathBuf::from(path); |
| } |
|
|
| let base = dirs::home_dir().unwrap_or(PathBuf::from(".")); |
| let path = base.join("forge"); |
|
|
| |
| |
| if path.exists() { |
| tracing::info!("Using legacy path"); |
| return path; |
| } |
|
|
| tracing::info!("Using new path"); |
| base.join(".forge") |
| } |
|
|
| |
| |
| pub fn read_toml(mut self, contents: &str) -> Self { |
| self.builder = self |
| .builder |
| .add_source(config::File::from_str(contents, config::FileFormat::Toml)); |
|
|
| self |
| } |
|
|
| |
| pub fn read_defaults(self) -> Self { |
| let defaults = include_str!("../.forge.toml"); |
|
|
| self.read_toml(defaults) |
| } |
|
|
| |
| pub fn read_env(mut self) -> Self { |
| self.builder = self.builder.add_source( |
| config::Environment::with_prefix("FORGE") |
| .prefix_separator("_") |
| .separator("__") |
| .try_parsing(true) |
| .list_separator(",") |
| .with_list_parse_key("retry.status_codes") |
| .with_list_parse_key("http.root_cert_paths"), |
| ); |
|
|
| self |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| pub fn build(self) -> crate::Result<ForgeConfig> { |
| *LOAD_DOT_ENV; |
| let config = self.builder.build()?; |
| Ok(config.try_deserialize::<ForgeConfig>()?) |
| } |
|
|
| |
| |
| pub fn read_global(mut self) -> Self { |
| let path = Self::config_path(); |
| self.builder = self |
| .builder |
| .add_source(config::File::from(path).required(false)); |
| self |
| } |
|
|
| |
| |
| pub fn read_legacy(self) -> Self { |
| let content = LegacyConfig::read(&Self::config_legacy_path()); |
| if let Ok(content) = content { |
| self.read_toml(&content) |
| } else { |
| self |
| } |
| } |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use std::sync::{Mutex, MutexGuard}; |
|
|
| use pretty_assertions::assert_eq; |
|
|
| use super::*; |
| use crate::ModelConfig; |
|
|
| |
| static ENV_MUTEX: Mutex<()> = Mutex::new(()); |
|
|
| |
| |
| struct EnvGuard { |
| keys: Vec<&'static str>, |
| _lock: MutexGuard<'static, ()>, |
| } |
|
|
| impl EnvGuard { |
| |
| |
| |
| #[must_use] |
| fn set(pairs: &[(&'static str, &str)]) -> Self { |
| Self::set_and_remove(pairs, &[]) |
| } |
|
|
| |
| #[must_use] |
| fn set_and_remove(pairs: &[(&'static str, &str)], remove: &[&'static str]) -> Self { |
| let lock = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); |
| let keys = pairs.iter().map(|(k, _)| *k).collect(); |
| for key in remove { |
| unsafe { std::env::remove_var(key) }; |
| } |
| for (key, value) in pairs { |
| unsafe { std::env::set_var(key, value) }; |
| } |
| Self { keys, _lock: lock } |
| } |
| } |
|
|
| impl Drop for EnvGuard { |
| fn drop(&mut self) { |
| for key in &self.keys { |
| unsafe { std::env::remove_var(key) }; |
| } |
| } |
| } |
|
|
| #[test] |
| fn test_base_path_uses_forge_config_env_var() { |
| let _guard = EnvGuard::set(&[("FORGE_CONFIG", "/custom/forge/dir")]); |
| let actual = ConfigReader::resolve_base_path(); |
| let expected = PathBuf::from("/custom/forge/dir"); |
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_base_path_falls_back_to_home_dir_when_env_var_absent() { |
| |
| |
| let _guard = EnvGuard::set_and_remove(&[], &["FORGE_CONFIG"]); |
|
|
| let actual = ConfigReader::resolve_base_path(); |
| |
| |
| let name = actual.file_name().unwrap(); |
| assert!( |
| name == "forge" || name == ".forge", |
| "Expected base_path to end with 'forge' or '.forge', got: {:?}", |
| name |
| ); |
| } |
|
|
| #[test] |
| fn test_read_parses_without_error() { |
| let actual = ConfigReader::default().read_defaults().build(); |
| assert!(actual.is_ok(), "read() failed: {:?}", actual.err()); |
| } |
|
|
| #[test] |
| fn test_legacy_layer_does_not_overwrite_defaults() { |
| |
| |
| |
| let legacy = ForgeConfig { |
| session: Some(ModelConfig { |
| provider_id: "anthropic".to_string(), |
| model_id: "claude-3".to_string(), |
| }), |
| ..Default::default() |
| }; |
| let legacy_toml = toml_edit::ser::to_string_pretty(&legacy).unwrap(); |
|
|
| let actual = ConfigReader::default() |
| |
| .read_toml(&legacy_toml) |
| .read_defaults() |
| .build() |
| .unwrap(); |
|
|
| |
| assert_eq!( |
| actual.session, |
| Some(ModelConfig { |
| provider_id: "anthropic".to_string(), |
| model_id: "claude-3".to_string(), |
| }) |
| ); |
|
|
| |
| assert_eq!(actual.max_parallel_file_reads, 64); |
| assert_eq!(actual.max_read_lines, 2000); |
| assert_eq!(actual.tool_timeout_secs, 300); |
| assert_eq!(actual.max_search_lines, 1000); |
| assert_eq!(actual.tool_supported, true); |
| } |
|
|
| #[test] |
| fn test_read_session_from_env_vars() { |
| let _guard = EnvGuard::set(&[ |
| ("FORGE_SESSION__PROVIDER_ID", "fake-provider"), |
| ("FORGE_SESSION__MODEL_ID", "fake-model"), |
| ]); |
|
|
| let actual = ConfigReader::default() |
| .read_defaults() |
| .read_env() |
| .build() |
| .unwrap(); |
|
|
| let expected = Some(ModelConfig { |
| provider_id: "fake-provider".to_string(), |
| model_id: "fake-model".to_string(), |
| }); |
| assert_eq!(actual.session, expected); |
| } |
|
|
| #[test] |
| fn test_use_forge_committer_defaults_to_true() { |
| let actual = ConfigReader::default().read_defaults().build().unwrap(); |
|
|
| assert_eq!(actual.use_forge_committer, true); |
| } |
|
|
| #[test] |
| fn test_use_forge_committer_can_be_disabled() { |
| let toml = "use_forge_committer = false\n"; |
|
|
| let actual = ConfigReader::default() |
| .read_defaults() |
| .read_toml(toml) |
| .build() |
| .unwrap(); |
|
|
| assert_eq!(actual.use_forge_committer, false); |
| } |
| } |
|
|