use anyhow::Result;
use figment::{
providers::{Env, Format, Toml},
Figment,
};
#[derive(Debug, Clone)]
pub struct GenConfig {
pub base_url: String,
pub api_key: String,
pub model: String,
pub overwrite: bool,
pub timeout_secs: u64,
pub search_provider: String,
pub search_api_key: String,
pub max_tool_turns: u32,
pub tools_enabled: bool,
pub instructions: String,
pub render_formats: bool,
}
impl Default for GenConfig {
fn default() -> Self {
Self {
base_url: "https://api.openai.com/v1".into(),
api_key: String::new(),
model: "gpt-4o-mini".into(),
overwrite: false,
timeout_secs: 300,
search_provider: "brave".into(),
search_api_key: String::new(),
max_tool_turns: 6,
tools_enabled: true,
instructions: String::new(),
render_formats: true,
}
}
}
pub struct CliOverrides {
pub base_url: Option<String>,
pub api_key: Option<String>,
pub model: Option<String>,
pub overwrite: bool,
pub no_tools: bool,
pub instructions: Option<String>,
pub no_render_formats: bool,
pub max_tool_turns: Option<u32>,
}
#[derive(Debug, serde::Deserialize, Default)]
struct SearchFileConfig {
provider: Option<String>,
api_key: Option<String>,
}
#[derive(Debug, serde::Deserialize, Default)]
struct FileConfig {
base_url: Option<String>,
model: Option<String>,
api_key: Option<String>,
timeout_secs: Option<u64>,
max_tool_turns: Option<u32>,
tools_enabled: Option<bool>,
instructions: Option<String>,
render_formats: Option<bool>,
search: Option<SearchFileConfig>,
search_provider: Option<String>,
search_api_key: Option<String>,
}
impl GenConfig {
pub fn load(cli: CliOverrides) -> Result<Self> {
let config_path = dirs::home_dir().unwrap_or_default().join(".dkp/gen.toml");
let defaults = GenConfig::default();
let file: FileConfig = Figment::new()
.merge(Toml::file(&config_path))
.merge(Env::prefixed("DKP_GEN_"))
.extract()
.unwrap_or_default();
let search_provider = file
.search_provider
.or_else(|| file.search.as_ref().and_then(|s| s.provider.clone()))
.unwrap_or(defaults.search_provider);
let search_api_key = file
.search_api_key
.or_else(|| file.search.as_ref().and_then(|s| s.api_key.clone()))
.unwrap_or_default();
Ok(GenConfig {
base_url: cli.base_url.or(file.base_url).unwrap_or(defaults.base_url),
api_key: cli.api_key.or(file.api_key).unwrap_or_default(),
model: cli.model.or(file.model).unwrap_or(defaults.model),
overwrite: cli.overwrite,
timeout_secs: file.timeout_secs.unwrap_or(defaults.timeout_secs),
search_provider,
search_api_key,
max_tool_turns: cli
.max_tool_turns
.or(file.max_tool_turns)
.unwrap_or(defaults.max_tool_turns),
tools_enabled: if cli.no_tools {
false
} else {
file.tools_enabled.unwrap_or(defaults.tools_enabled)
},
instructions: cli.instructions.or(file.instructions).unwrap_or_default(),
render_formats: if cli.no_render_formats {
false
} else {
file.render_formats.unwrap_or(defaults.render_formats)
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use figment::Jail;
fn no_overrides() -> CliOverrides {
CliOverrides {
base_url: None,
api_key: None,
model: None,
overwrite: false,
no_tools: false,
instructions: None,
no_render_formats: false,
max_tool_turns: None,
}
}
#[test]
fn defaults_have_tools_enabled_and_brave_provider() {
Jail::expect_with(|jail| {
let home = jail.directory().display().to_string();
jail.set_env("HOME", &home);
let config = GenConfig::load(no_overrides()).unwrap();
assert!(config.tools_enabled);
assert_eq!(config.search_provider, "brave");
assert_eq!(config.max_tool_turns, 6);
assert!(config.search_api_key.is_empty());
Ok(())
});
}
#[test]
fn no_tools_cli_flag_overrides_file_default() {
Jail::expect_with(|jail| {
let home = jail.directory().display().to_string();
jail.set_env("HOME", &home);
let config = GenConfig::load(CliOverrides {
no_tools: true,
..no_overrides()
})
.unwrap();
assert!(!config.tools_enabled);
Ok(())
});
}
#[test]
fn nested_search_table_in_gen_toml_is_read() {
Jail::expect_with(|jail| {
let home = jail.directory().display().to_string();
jail.set_env("HOME", &home);
jail.create_dir(".dkp")?;
jail.create_file(
".dkp/gen.toml",
r#"
[search]
provider = "brave"
api_key = "table-key"
"#,
)?;
let config = GenConfig::load(no_overrides()).unwrap();
assert_eq!(config.search_api_key, "table-key");
Ok(())
});
}
#[test]
fn env_var_tools_enabled_false_is_respected() {
Jail::expect_with(|jail| {
let home = jail.directory().display().to_string();
jail.set_env("HOME", &home);
jail.set_env("DKP_GEN_TOOLS_ENABLED", "false");
let config = GenConfig::load(no_overrides()).unwrap();
assert!(!config.tools_enabled);
Ok(())
});
}
#[test]
fn env_var_search_api_key_is_read_as_flat_key() {
Jail::expect_with(|jail| {
let home = jail.directory().display().to_string();
jail.set_env("HOME", &home);
jail.set_env("DKP_GEN_SEARCH_API_KEY", "env-key");
let config = GenConfig::load(no_overrides()).unwrap();
assert_eq!(config.search_api_key, "env-key");
Ok(())
});
}
#[test]
fn cli_instructions_override_file_instructions() {
Jail::expect_with(|jail| {
let home = jail.directory().display().to_string();
jail.set_env("HOME", &home);
jail.create_dir(".dkp")?;
jail.create_file(".dkp/gen.toml", r#"instructions = "from file""#)?;
let config = GenConfig::load(CliOverrides {
instructions: Some("from cli".into()),
..no_overrides()
})
.unwrap();
assert_eq!(config.instructions, "from cli");
Ok(())
});
}
#[test]
fn file_instructions_used_when_no_cli_override() {
Jail::expect_with(|jail| {
let home = jail.directory().display().to_string();
jail.set_env("HOME", &home);
jail.create_dir(".dkp")?;
jail.create_file(".dkp/gen.toml", r#"instructions = "from file""#)?;
let config = GenConfig::load(no_overrides()).unwrap();
assert_eq!(config.instructions, "from file");
Ok(())
});
}
#[test]
fn cli_max_tool_turns_overrides_file_default() {
Jail::expect_with(|jail| {
let home = jail.directory().display().to_string();
jail.set_env("HOME", &home);
let config = GenConfig::load(CliOverrides {
max_tool_turns: Some(20),
..no_overrides()
})
.unwrap();
assert_eq!(config.max_tool_turns, 20);
Ok(())
});
}
#[test]
fn unknown_search_provider_config_value_is_still_loaded_verbatim() {
Jail::expect_with(|jail| {
let home = jail.directory().display().to_string();
jail.set_env("HOME", &home);
jail.set_env("DKP_GEN_SEARCH_PROVIDER", "not-a-real-provider");
let config = GenConfig::load(no_overrides()).unwrap();
assert_eq!(config.search_provider, "not-a-real-provider");
Ok(())
});
}
}