1use anyhow::Result;
2use figment::{
3 Figment,
4 providers::{Env, Format, Toml},
5};
6
7#[derive(Debug, Clone)]
10pub struct GenConfig {
11 pub base_url: String,
12 pub api_key: String,
13 pub model: String,
14 pub overwrite: bool,
15 pub timeout_secs: u64,
16 pub search_provider: String,
18 pub search_api_key: String,
19 pub max_tool_turns: u32,
20 pub tools_enabled: bool,
23 pub instructions: String,
26 pub render_formats: bool,
30}
31
32impl Default for GenConfig {
33 fn default() -> Self {
34 Self {
35 base_url: "https://api.openai.com/v1".into(),
36 api_key: String::new(),
37 model: "gpt-4o-mini".into(),
38 overwrite: false,
39 timeout_secs: 300,
40 search_provider: "brave".into(),
41 search_api_key: String::new(),
42 max_tool_turns: 6,
43 tools_enabled: true,
44 instructions: String::new(),
45 render_formats: true,
46 }
47 }
48}
49
50pub struct CliOverrides {
52 pub base_url: Option<String>,
53 pub api_key: Option<String>,
55 pub model: Option<String>,
56 pub overwrite: bool,
57 pub no_tools: bool,
59 pub instructions: Option<String>,
60 pub no_render_formats: bool,
62 pub max_tool_turns: Option<u32>,
63}
64
65#[derive(Debug, serde::Deserialize, Default)]
66struct SearchFileConfig {
67 provider: Option<String>,
68 api_key: Option<String>,
69}
70
71#[derive(Debug, serde::Deserialize, Default)]
72struct FileConfig {
73 base_url: Option<String>,
74 model: Option<String>,
75 api_key: Option<String>,
76 timeout_secs: Option<u64>,
77 max_tool_turns: Option<u32>,
78 tools_enabled: Option<bool>,
79 instructions: Option<String>,
80 render_formats: Option<bool>,
81 search: Option<SearchFileConfig>,
82 search_provider: Option<String>,
86 search_api_key: Option<String>,
87}
88
89impl GenConfig {
90 pub fn load(cli: CliOverrides) -> Result<Self> {
92 let config_path = dirs::home_dir().unwrap_or_default().join(".dkp/gen.toml");
93
94 let defaults = GenConfig::default();
95
96 let file: FileConfig = Figment::new()
98 .merge(Toml::file(&config_path))
99 .merge(Env::prefixed("DKP_GEN_"))
100 .extract()
101 .unwrap_or_default();
102
103 let search_provider = file
104 .search_provider
105 .or_else(|| file.search.as_ref().and_then(|s| s.provider.clone()))
106 .unwrap_or(defaults.search_provider);
107 let search_api_key = file
108 .search_api_key
109 .or_else(|| file.search.as_ref().and_then(|s| s.api_key.clone()))
110 .unwrap_or_default();
111
112 Ok(GenConfig {
113 base_url: cli.base_url.or(file.base_url).unwrap_or(defaults.base_url),
114 api_key: cli.api_key.or(file.api_key).unwrap_or_default(),
115 model: cli.model.or(file.model).unwrap_or(defaults.model),
116 overwrite: cli.overwrite,
117 timeout_secs: file.timeout_secs.unwrap_or(defaults.timeout_secs),
118 search_provider,
119 search_api_key,
120 max_tool_turns: cli
121 .max_tool_turns
122 .or(file.max_tool_turns)
123 .unwrap_or(defaults.max_tool_turns),
124 tools_enabled: if cli.no_tools {
125 false
126 } else {
127 file.tools_enabled.unwrap_or(defaults.tools_enabled)
128 },
129 instructions: cli.instructions.or(file.instructions).unwrap_or_default(),
130 render_formats: if cli.no_render_formats {
131 false
132 } else {
133 file.render_formats.unwrap_or(defaults.render_formats)
134 },
135 })
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use figment::Jail;
143
144 fn no_overrides() -> CliOverrides {
145 CliOverrides {
146 base_url: None,
147 api_key: None,
148 model: None,
149 overwrite: false,
150 no_tools: false,
151 instructions: None,
152 no_render_formats: false,
153 max_tool_turns: None,
154 }
155 }
156
157 #[test]
158 fn defaults_have_tools_enabled_and_brave_provider() {
159 Jail::expect_with(|jail| {
160 let home = jail.directory().display().to_string();
161 jail.set_env("HOME", &home);
162 let config = GenConfig::load(no_overrides()).unwrap();
163 assert!(config.tools_enabled);
164 assert_eq!(config.search_provider, "brave");
165 assert_eq!(config.max_tool_turns, 6);
166 assert!(config.search_api_key.is_empty());
167 Ok(())
168 });
169 }
170
171 #[test]
172 fn no_tools_cli_flag_overrides_file_default() {
173 Jail::expect_with(|jail| {
174 let home = jail.directory().display().to_string();
175 jail.set_env("HOME", &home);
176 let config = GenConfig::load(CliOverrides {
177 no_tools: true,
178 ..no_overrides()
179 })
180 .unwrap();
181 assert!(!config.tools_enabled);
182 Ok(())
183 });
184 }
185
186 #[test]
187 fn nested_search_table_in_gen_toml_is_read() {
188 Jail::expect_with(|jail| {
189 let home = jail.directory().display().to_string();
190 jail.set_env("HOME", &home);
191 jail.create_dir(".dkp")?;
192 jail.create_file(
193 ".dkp/gen.toml",
194 r#"
195 [search]
196 provider = "brave"
197 api_key = "table-key"
198 "#,
199 )?;
200 let config = GenConfig::load(no_overrides()).unwrap();
201 assert_eq!(config.search_api_key, "table-key");
202 Ok(())
203 });
204 }
205
206 #[test]
207 fn env_var_tools_enabled_false_is_respected() {
208 Jail::expect_with(|jail| {
209 let home = jail.directory().display().to_string();
210 jail.set_env("HOME", &home);
211 jail.set_env("DKP_GEN_TOOLS_ENABLED", "false");
212 let config = GenConfig::load(no_overrides()).unwrap();
213 assert!(!config.tools_enabled);
214 Ok(())
215 });
216 }
217
218 #[test]
219 fn env_var_search_api_key_is_read_as_flat_key() {
220 Jail::expect_with(|jail| {
221 let home = jail.directory().display().to_string();
222 jail.set_env("HOME", &home);
223 jail.set_env("DKP_GEN_SEARCH_API_KEY", "env-key");
224 let config = GenConfig::load(no_overrides()).unwrap();
225 assert_eq!(config.search_api_key, "env-key");
226 Ok(())
227 });
228 }
229
230 #[test]
231 fn cli_instructions_override_file_instructions() {
232 Jail::expect_with(|jail| {
233 let home = jail.directory().display().to_string();
234 jail.set_env("HOME", &home);
235 jail.create_dir(".dkp")?;
236 jail.create_file(".dkp/gen.toml", r#"instructions = "from file""#)?;
237 let config = GenConfig::load(CliOverrides {
238 instructions: Some("from cli".into()),
239 ..no_overrides()
240 })
241 .unwrap();
242 assert_eq!(config.instructions, "from cli");
243 Ok(())
244 });
245 }
246
247 #[test]
248 fn file_instructions_used_when_no_cli_override() {
249 Jail::expect_with(|jail| {
250 let home = jail.directory().display().to_string();
251 jail.set_env("HOME", &home);
252 jail.create_dir(".dkp")?;
253 jail.create_file(".dkp/gen.toml", r#"instructions = "from file""#)?;
254 let config = GenConfig::load(no_overrides()).unwrap();
255 assert_eq!(config.instructions, "from file");
256 Ok(())
257 });
258 }
259
260 #[test]
261 fn cli_max_tool_turns_overrides_file_default() {
262 Jail::expect_with(|jail| {
263 let home = jail.directory().display().to_string();
264 jail.set_env("HOME", &home);
265 let config = GenConfig::load(CliOverrides {
266 max_tool_turns: Some(20),
267 ..no_overrides()
268 })
269 .unwrap();
270 assert_eq!(config.max_tool_turns, 20);
271 Ok(())
272 });
273 }
274
275 #[test]
276 fn unknown_search_provider_config_value_is_still_loaded_verbatim() {
277 Jail::expect_with(|jail| {
280 let home = jail.directory().display().to_string();
281 jail.set_env("HOME", &home);
282 jail.set_env("DKP_GEN_SEARCH_PROVIDER", "not-a-real-provider");
283 let config = GenConfig::load(no_overrides()).unwrap();
284 assert_eq!(config.search_provider, "not-a-real-provider");
285 Ok(())
286 });
287 }
288}