Skip to main content

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;