drep/config.rs
1//! TOML configuration.
2//!
3//! One file per repository, conventionally `drep.toml` at the root. The shape
4//! is deliberate: every field has a documented default, so a partial file
5//! works. Missing keys are not an error - the section just inherits the
6//! default. Providers are declared as `[[llm]]`, an ordered array of tables:
7//! a preference order, tried head first, each enabled entry a fallback for the
8//! one before it.
9//!
10//! The two things this module owns that are not obvious from the field list:
11//!
12//! - **`${VAR}` expansion.** An api key (or any string value) can name an
13//! environment variable instead of holding the secret. The file gets
14//! committed; the secret does not. An unset variable is an error rather
15//! than an empty string, because a silent empty credential produces a
16//! confusing 401 instead of a clear "API_KEY is not set".
17//! - **`max_tokens` defaults to None**, meaning no cap is sent to the model.
18//! Modern reasoning models ship 256k-1M context, and inventing a ceiling
19//! truncates them mid-thought. The option stays available for capping
20//! spend.
21
22use std::env;
23use std::path::{Path, PathBuf};
24
25use open_agent::ApiProtocol;
26use serde::Deserialize;
27use thiserror::Error;
28use toml::Value;
29
30mod backend;
31pub use backend::{BackendKind, LlmConfig, ReasoningEffort};
32
33pub const DEFAULT_MAX_REVIEW_ROUNDS: u32 = 3;
34
35/// The whole configuration tree, rooted at the file.
36///
37/// `llm` is an **array of tables** (`[[llm]]`), not a single `[llm]` section,
38/// and the list is a *preference order*: [`Self::providers`] is the failover
39/// chain.
40///
41/// `#[serde(default)]` means a file with an empty body deserializes
42/// successfully; `validate` is what then rejects it, because a config
43/// declaring no provider cannot run the mandatory LLM layer.
44#[derive(Debug, Deserialize)]
45#[serde(default)]
46pub struct Config {
47 pub max_review_rounds: u32,
48 pub llm: Vec<LlmConfig>,
49}
50
51impl Default for Config {
52 fn default() -> Self {
53 Self {
54 max_review_rounds: DEFAULT_MAX_REVIEW_ROUNDS,
55 llm: Vec::new(),
56 }
57 }
58}
59
60impl Config {
61 /// The failover chain: every enabled provider, in file order.
62 ///
63 /// The single definition of "which providers are in play". `enabled` is
64 /// an opt-*out*, so a disabled entry is skipped wherever it sits - a
65 /// disabled head falls through to the entry below it rather than
66 /// producing `NotConfigured`, which is what parking the local model was
67 /// always meant to do.
68 ///
69 /// A `Vec` rather than an iterator because every caller wants a length or
70 /// an index (the chain numbers its providers in error messages) and the
71 /// list is at most a handful of entries.
72 pub fn providers(&self) -> Vec<&LlmConfig> {
73 self.llm.iter().filter(|p| p.enabled).collect()
74 }
75}
76
77/// What went wrong reading or validating the configuration.
78#[derive(Debug, Error)]
79pub enum ConfigError {
80 #[error("could not read {0}: {1}")]
81 Io(PathBuf, std::io::Error),
82
83 #[error("could not parse {0}: {1}")]
84 Parse(PathBuf, String),
85
86 #[error("environment variable `{0}` is not set (referenced by `{1}`)")]
87 EnvVarUnset(String, String),
88
89 #[error("environment variable `{0}` is not valid UTF-8 (referenced by `{1}`)")]
90 EnvVarNotUnicode(String, String),
91
92 /// The index is the zero-based position in the file; the message renders
93 /// it one-based, and says "in file order" because the *chain* numbers only
94 /// the enabled entries - with a disabled head the two differ, and
95 /// `[[llm]] #1` meaning different tables in the same file is worse than
96 /// either convention alone.
97 #[error(
98 "[[llm]] #{} in file order: temperature {temperature} is outside the allowed range 0.0..=2.0",
99 index + 1
100 )]
101 Temperature { index: usize, temperature: f32 },
102
103 /// `max_concurrent = 0` builds a semaphore with no permits, so every
104 /// request waits for one forever. Rejected at load rather than clamped: a
105 /// silent bump to 1 would run a gate at a concurrency the user did not ask
106 /// for, and the alternative - the documented "callers are expected to set a
107 /// positive value" - is a hang with no message at all.
108 #[error("[[llm]] #{} in file order: max_concurrent must be at least 1", index + 1)]
109 ZeroConcurrency { index: usize },
110
111 #[error("[[llm]] #{} in file order: timeout_secs must be at least 1", index + 1)]
112 ZeroTimeout { index: usize },
113
114 #[error("[[llm]] #{} in file order: max_tokens must be at least 1 when set", index + 1)]
115 ZeroMaxTokens { index: usize },
116
117 #[error("max_review_rounds must be at least 1")]
118 ZeroReviewRounds,
119
120 /// Rejected rather than defaulted: falling back to `openai` would post
121 /// chat-completions bytes to a `/messages` endpoint, and the resulting 404
122 /// reads as "the provider is down" rather than "this line has a typo".
123 #[error(
124 "[[llm]] #{} in file order: unknown protocol `{value}`; expected `openai` or `anthropic`",
125 index + 1
126 )]
127 UnknownProtocol { index: usize, value: String },
128
129 #[error(
130 "[[llm]] #{} in file order: unknown backend `{value}`; expected `http` or `codex`",
131 index + 1
132 )]
133 UnknownBackend { index: usize, value: String },
134
135 #[error(
136 "[[llm]] #{} in file order: unknown reasoning_effort `{value}`; expected `minimal`, `low`, `medium`, `high`, or `xhigh`",
137 index + 1
138 )]
139 UnknownReasoningEffort { index: usize, value: String },
140
141 #[error(
142 "[[llm]] #{} in file order: backend `{backend}` does not support `{field}`",
143 index + 1
144 )]
145 BackendField {
146 index: usize,
147 backend: &'static str,
148 field: &'static str,
149 },
150
151 #[error(
152 "[[llm]] #{} in file order: backend `{backend}` requires `{field}`",
153 index + 1
154 )]
155 BackendMissingField {
156 index: usize,
157 backend: &'static str,
158 field: &'static str,
159 },
160
161 #[error(
162 "{0} declares no `[[llm]]` provider; drep 2.x has no deterministic-only mode. \
163 Run `drep init` to write one."
164 )]
165 NoProviders(PathBuf),
166
167 #[error(
168 "every `[[llm]]` provider in {0} has `enabled = false`; drep 2.x has no \
169 deterministic-only mode. Re-enable one, or run `drep init` to write another."
170 )]
171 NoEnabledProviders(PathBuf),
172}
173
174/// The conventional config file location: `drep.toml` in the current directory.
175///
176/// Hardcoded to "drep.toml" in cwd by design: `drep init` writes this exact
177/// path, so changing it here would break the contract with the init command.
178pub fn default_config_path() -> PathBuf {
179 PathBuf::from("drep.toml")
180}
181
182/// Parses a `protocol =` value, or `None` when it names nothing the SDK speaks.
183///
184/// The single definition of what a protocol name means, and it owns none of the
185/// names: [`ApiProtocol::from_wire`] is the SDK's own parser, so drep cannot
186/// come to disagree with the layer that acts on the answer. An absent value is
187/// the default protocol rather than an error, which is what keeps every config
188/// written before 0.9.0 valid.
189pub fn parse_protocol(raw: Option<&str>) -> Option<ApiProtocol> {
190 match raw {
191 None => Some(ApiProtocol::default()),
192 Some(name) => ApiProtocol::from_wire(name),
193 }
194}
195
196/// Load and validate `path`.
197///
198/// A missing file is an error: the caller decides whether that is fatal
199/// (the binary should bail) or expected (a first-run where `drep init` has
200/// not been run yet). Inventing defaults for a file that does not exist
201/// would silently mask a broken install.
202///
203/// `${VAR}` expansion happens before validation, so an unset variable is
204/// reported with the variable's name rather than as a downstream parse
205/// failure inside the substituted text.
206pub fn load(path: &Path) -> Result<Config, ConfigError> {
207 let content =
208 std::fs::read_to_string(path).map_err(|err| ConfigError::Io(path.to_path_buf(), err))?;
209
210 // `toml::from_str::<Value>` and `<Value as FromStr>::from_str` are not
211 // interchangeable despite producing the same type. The former runs the
212 // document parser; the latter runs `ValueDeserializer`, which
213 // parses a single TOML *value* (`42`, `"text"`) and rejects a whole document
214 // with "unexpected content, expected nothing".
215 let mut tree: Value = toml::from_str(&content).map_err(|err: toml::de::Error| {
216 ConfigError::Parse(path.to_path_buf(), err.message().to_owned())
217 })?;
218
219 // Disabled providers are pruned from expansion, not from the tree: a
220 // parked entry is inert, so an unset `${OPENROUTER_API_KEY}` in the cloud
221 // block a user just switched off must not refuse to load the file. It stays
222 // in `Config.llm` with its `${VAR}` unexpanded, which nothing reads - only
223 // `providers()` is consulted, and it filters the entry out.
224 let disabled = disabled_provider_indices(&tree);
225 expand_env_except(&mut tree, path, &disabled)?;
226 let explicit_fields = backend::explicit_fields(&tree);
227
228 let config: Config = tree.try_into().map_err(|err: toml::de::Error| {
229 ConfigError::Parse(path.to_path_buf(), err.message().to_owned())
230 })?;
231
232 validate(&config, path, &explicit_fields)?;
233 Ok(config)
234}
235
236/// Validate what serde cannot enforce from the type alone.
237///
238/// An empty provider list is rejected here rather than tolerated and caught
239/// later at the LLM boundary: the LLM layer is mandatory in 2.x, so a config
240/// naming no provider is a file that can never produce a passing run, and the
241/// earliest place to say so is the place that read the file.
242///
243/// The index is carried into the temperature error because with several
244/// providers "temperature 3.0 is out of range" does not say *which* one.
245fn validate(
246 config: &Config,
247 path: &Path,
248 explicit_fields: &[backend::ExplicitFields],
249) -> Result<(), ConfigError> {
250 if config.max_review_rounds == 0 {
251 return Err(ConfigError::ZeroReviewRounds);
252 }
253 if config.llm.is_empty() {
254 return Err(ConfigError::NoProviders(path.to_path_buf()));
255 }
256 // Distinct from `NoProviders` because the fix is different: one needs a
257 // provider written, the other needs one re-enabled. Both are caught here
258 // rather than at the LLM boundary so the message can name the file.
259 if config.providers().is_empty() {
260 return Err(ConfigError::NoEnabledProviders(path.to_path_buf()));
261 }
262 // Disabled entries are skipped. `enabled = false` means "this entry is
263 // inert", and refusing to load the file because a *parked* provider names
264 // an out-of-range temperature contradicts that in the one place a user
265 // would notice: they parked it precisely to stop it mattering.
266 for (index, llm) in config.llm.iter().enumerate().filter(|(_, l)| l.enabled) {
267 backend::validate(
268 llm,
269 explicit_fields.get(index).copied().unwrap_or_default(),
270 index,
271 )?;
272
273 if llm.max_concurrent == 0 {
274 return Err(ConfigError::ZeroConcurrency { index });
275 }
276 if llm.timeout_secs == 0 {
277 return Err(ConfigError::ZeroTimeout { index });
278 }
279 if llm.max_tokens == Some(0) {
280 return Err(ConfigError::ZeroMaxTokens { index });
281 }
282
283 if llm.backend != BackendKind::Http {
284 continue;
285 }
286 if let Some(t) = llm.temperature
287 && !(0.0..=2.0).contains(&t)
288 {
289 return Err(ConfigError::Temperature {
290 index,
291 temperature: t,
292 });
293 }
294 // A misspelled protocol is rejected here rather than defaulted, because
295 // silently falling back to `openai` would send chat-completions bytes to a
296 // `/messages` endpoint and report the 404 as the endpoint being down.
297 if let Some(raw) = llm.protocol.as_deref()
298 && parse_protocol(Some(raw)).is_none()
299 {
300 return Err(ConfigError::UnknownProtocol {
301 index,
302 value: raw.to_owned(),
303 });
304 }
305 }
306 Ok(())
307}
308
309/// The positions of the `[[llm]]` tables that carry `enabled = false`.
310///
311/// Read from the raw tree because expansion runs before deserialization - and
312/// it has to, since an unset variable must be reported with the variable's name
313/// rather than as a downstream parse failure inside the substituted text. The
314/// default comes from `LlmConfig::default()` so this cannot disagree with serde
315/// about what an absent `enabled` key means.
316fn disabled_provider_indices(tree: &Value) -> std::collections::BTreeSet<usize> {
317 let default_enabled = LlmConfig::default().enabled;
318 tree.get("llm")
319 .and_then(Value::as_array)
320 .map(|entries| {
321 entries
322 .iter()
323 .enumerate()
324 .filter(|(_, entry)| {
325 !entry
326 .get("enabled")
327 .and_then(Value::as_bool)
328 .unwrap_or(default_enabled)
329 })
330 .map(|(index, _)| index)
331 .collect()
332 })
333 .unwrap_or_default()
334}
335
336/// [`expand_env_in`] over the whole tree except the named `[[llm]]` entries.
337fn expand_env_except(
338 tree: &mut Value,
339 source: &Path,
340 skip: &std::collections::BTreeSet<usize>,
341) -> Result<(), ConfigError> {
342 if skip.is_empty() {
343 return expand_env_in(tree, source);
344 }
345 let Some(table) = tree.as_table_mut() else {
346 return expand_env_in(tree, source);
347 };
348 for (key, value) in table.iter_mut() {
349 if key != "llm" {
350 expand_env_in(value, source)?;
351 continue;
352 }
353 let Some(entries) = value.as_array_mut() else {
354 expand_env_in(value, source)?;
355 continue;
356 };
357 for (index, entry) in entries.iter_mut().enumerate() {
358 if !skip.contains(&index) {
359 expand_env_in(entry, source)?;
360 }
361 }
362 }
363 Ok(())
364}
365
366/// Walk every string in the parsed TOML tree and expand `${VAR}` references.
367///
368/// Applied to the whole tree rather than per-field so a future field added
369/// to `LlmConfig` inherits the behaviour without remembering to opt in. The
370/// reference is the path that contained it, so an unset variable's error
371/// message points at the file rather than the variable alone.
372fn expand_env_in(value: &mut Value, source: &Path) -> Result<(), ConfigError> {
373 match value {
374 Value::String(s) => {
375 *s = expand_string(s, source)?;
376 }
377 Value::Table(table) => {
378 for (_, inner) in table.iter_mut() {
379 expand_env_in(inner, source)?;
380 }
381 }
382 Value::Array(items) => {
383 for inner in items.iter_mut() {
384 expand_env_in(inner, source)?;
385 }
386 }
387 _ => {}
388 }
389 Ok(())
390}
391
392/// Every `${NAME}` reference in `s`, in the order they appear.
393///
394/// The single statement of what counts as a variable reference, so a consumer
395/// cannot disagree with the substituter about it. `drep doctor` had its own
396/// regex, `\$\{([A-Z_][A-Z0-9_]*)\}`, which is *narrower* than this: a config
397/// naming `${openrouter_key}` produced no warning from doctor, while
398/// `expand_string` below still failed on it — and doctor suppressed that error
399/// believing it had already reported it. The user was told the config was fine
400/// and `drep check` then refused to load it.
401///
402/// An unterminated `${` yields nothing here; `expand_string` is what reports
403/// it, because only the substituter knows it is an error rather than literal
404/// text.
405pub fn env_var_refs(s: &str) -> Vec<String> {
406 let mut refs = Vec::new();
407 let mut rest = s;
408 // Written with `split_once`/`strip_prefix` rather than `find` plus index
409 // arithmetic. The arithmetic version was correct, but `start + 2` and
410 // `end + 1` are two magic offsets whose only justification is the length
411 // of the delimiters they skip - and the delimiters are right there in the
412 // pattern, so letting the standard library consume them says the same
413 // thing without the chance of an off-by-one.
414 while let Some((_, after_open)) = rest.split_once("${") {
415 let Some((name, after_close)) = after_open.split_once('}') else {
416 // An unterminated `${`. Not this function's error to report:
417 // `expand_string` is what knows whether the text is a reference or
418 // a literal, and it rejects the file.
419 break;
420 };
421 refs.push(name.to_owned());
422 rest = after_close;
423 }
424 refs
425}
426
427/// Every `${NAME}` reference that [`load`] will actually try to substitute.
428///
429/// The same tree as [`env_var_refs_in`], minus the `[[llm]]` entries that
430/// carry `enabled = false` — because `load` skips expanding those, so a
431/// variable named only by a parked provider is not required and reporting it
432/// as missing is a false alarm. This is the shared definition, so `doctor`
433/// cannot warn about a variable `check` does not need; a narrower scanner in
434/// `doctor` is what once made it call a config fine that `check` refused to
435/// load.
436pub fn required_env_var_refs(value: &Value) -> Vec<String> {
437 let disabled = disabled_provider_indices(value);
438 if disabled.is_empty() {
439 return env_var_refs_in(value);
440 }
441 let mut seen = std::collections::BTreeSet::new();
442 let mut out = Vec::new();
443 let Some(table) = value.as_table() else {
444 return env_var_refs_in(value);
445 };
446 for (key, inner) in table {
447 if key != "llm" {
448 collect_env_refs(inner, &mut seen, &mut out);
449 continue;
450 }
451 let Some(entries) = inner.as_array() else {
452 collect_env_refs(inner, &mut seen, &mut out);
453 continue;
454 };
455 for (index, entry) in entries.iter().enumerate() {
456 if !disabled.contains(&index) {
457 collect_env_refs(entry, &mut seen, &mut out);
458 }
459 }
460 }
461 out
462}
463
464/// Every `${NAME}` reference in any string value of a parsed TOML tree.
465///
466/// Deliberately over the *parsed* tree rather than the file text: a `${VAR}`
467/// inside a comment is documentation, not a reference, and reporting it as an
468/// unset variable is a false alarm in the one command whose job is to be
469/// believed. Deduplicated, first-seen order preserved.
470pub fn env_var_refs_in(value: &Value) -> Vec<String> {
471 let mut seen = std::collections::BTreeSet::new();
472 let mut out = Vec::new();
473 collect_env_refs(value, &mut seen, &mut out);
474 out
475}
476
477fn collect_env_refs(
478 value: &Value,
479 seen: &mut std::collections::BTreeSet<String>,
480 out: &mut Vec<String>,
481) {
482 match value {
483 Value::String(s) => {
484 for name in env_var_refs(s) {
485 if seen.insert(name.clone()) {
486 out.push(name);
487 }
488 }
489 }
490 Value::Table(table) => {
491 for (_, inner) in table {
492 collect_env_refs(inner, seen, out);
493 }
494 }
495 Value::Array(items) => {
496 for inner in items {
497 collect_env_refs(inner, seen, out);
498 }
499 }
500 _ => {}
501 }
502}
503
504/// Substitute every `${NAME}` in `s` with that environment variable's value.
505///
506/// A literal `$` that is not followed by `{` is preserved. An unterminated
507/// `${` (no closing `}`) is also an error, because the alternative - silently
508/// dropping it - leaves the file's contract unstated.
509fn expand_string(s: &str, source: &Path) -> Result<String, ConfigError> {
510 let mut out = String::with_capacity(s.len());
511 let mut chars = s.chars().peekable();
512 while let Some(c) = chars.next() {
513 if c != '$' {
514 out.push(c);
515 continue;
516 }
517 if chars.peek() != Some(&'{') {
518 // Literal `$` not followed by `{`. Preserved verbatim so a path
519 // like `$HOME/x` survives rather than vanishing the `$`.
520 out.push(c);
521 continue;
522 }
523 chars.next();
524 let mut name = String::new();
525 let mut closed = false;
526 for next in chars.by_ref() {
527 if next == '}' {
528 closed = true;
529 break;
530 }
531 name.push(next);
532 }
533 if !closed {
534 return Err(ConfigError::Parse(
535 source.to_path_buf(),
536 format!("unterminated `${{` in `{s}`"),
537 ));
538 }
539 if name.is_empty() {
540 return Err(ConfigError::Parse(
541 source.to_path_buf(),
542 format!("empty environment variable reference in `{s}`"),
543 ));
544 }
545 let value = match env::var(&name) {
546 Ok(value) => value,
547 Err(env::VarError::NotPresent) => {
548 return Err(ConfigError::EnvVarUnset(name, source.display().to_string()));
549 }
550 Err(env::VarError::NotUnicode(_)) => {
551 return Err(ConfigError::EnvVarNotUnicode(
552 name,
553 source.display().to_string(),
554 ));
555 }
556 };
557 out.push_str(&value);
558 }
559 Ok(out)
560}
561
562#[cfg(test)]
563mod tests;