Skip to main content

drep/cli/init/
config_file.rs

1//! Render and write `drep.toml`.
2//!
3//! Every entry has its documented position, comments explain *why* a field
4//! exists (or does not), and the file parses cleanly through
5//! [`crate::config::load`] once the referenced environment variables are set.
6//!
7//! Escaping happens here rather than at a higher level, so `render` is the
8//! single place a caller's value could fail to escape. See `escape` for what
9//! TOML actually requires - it is more than the two characters this originally
10//! handled.
11
12use std::path::{Path, PathBuf};
13
14use anyhow::{Context, Result, anyhow};
15
16use super::presets::{LlmPreset, PresetBackend};
17use crate::llm::quirks::Quirks;
18
19/// Fields used by exactly one execution backend.
20#[derive(Debug, Clone)]
21enum ChoiceBackend {
22    Http {
23        endpoint: String,
24        key_in_store: bool,
25        quirks: Quirks,
26    },
27    Codex,
28}
29
30/// One provider the user chose, ready to render.
31///
32/// `endpoint` and `model` are resolved rather than optional: the wizard and the
33/// flag path both fall back to the preset's defaults before building this, so
34/// the renderer never has to decide what "no model" means.
35#[derive(Debug, Clone)]
36pub struct Choice {
37    /// The preset this came from - it supplies the protocol, the timeout and
38    /// the environment variable name.
39    pub preset: &'static LlmPreset,
40    /// The model to ask for.
41    pub model: String,
42    /// Backend-specific values, already resolved by the wizard or flag path.
43    backend: ChoiceBackend,
44}
45
46impl Choice {
47    pub fn http(
48        preset: &'static LlmPreset,
49        model: String,
50        endpoint: String,
51        key_in_store: bool,
52        quirks: Quirks,
53    ) -> Self {
54        assert!(
55            matches!(preset.backend, PresetBackend::Http(_)),
56            "HTTP choice requires an HTTP preset"
57        );
58        Self {
59            preset,
60            model,
61            backend: ChoiceBackend::Http {
62                endpoint,
63                key_in_store,
64                quirks,
65            },
66        }
67    }
68
69    pub fn codex(preset: &'static LlmPreset, model: String) -> Self {
70        assert!(
71            matches!(preset.backend, PresetBackend::Codex(_)),
72            "Codex choice requires a Codex preset"
73        );
74        Self {
75            preset,
76            model,
77            backend: ChoiceBackend::Codex,
78        }
79    }
80
81    pub fn is_http(&self) -> bool {
82        matches!(self.backend, ChoiceBackend::Http { .. })
83    }
84
85    #[cfg(test)]
86    pub fn endpoint(&self) -> Option<&str> {
87        match &self.backend {
88            ChoiceBackend::Http { endpoint, .. } => Some(endpoint),
89            ChoiceBackend::Codex => None,
90        }
91    }
92
93    pub fn key_in_store(&self) -> bool {
94        match &self.backend {
95            ChoiceBackend::Http { key_in_store, .. } => *key_in_store,
96            ChoiceBackend::Codex => false,
97        }
98    }
99
100    #[cfg(test)]
101    pub fn quirks(&self) -> Quirks {
102        match &self.backend {
103            ChoiceBackend::Http { quirks, .. } => *quirks,
104            ChoiceBackend::Codex => self.preset.quirks(),
105        }
106    }
107}
108
109/// Render the whole `drep.toml`, comments included, for a failover chain.
110///
111/// Produces exactly the shape [`crate::config::load`] accepts: one `[[llm]]`
112/// table per choice in order, the keys in the order they appear in the file (the
113/// TOML document parser preserves order; serde reads them positionally), and
114/// each optional line only when the choice calls for it.
115///
116/// The order is the chain: entry one is tried first and each later entry is a
117/// fallback for the one before it.
118pub fn render_chain(choices: &[Choice]) -> String {
119    let mut body = String::new();
120    body.push_str("# drep configuration, written by `drep init`.\n");
121    body.push_str("#\n");
122    body.push_str("# Providers are declared as `[[llm]]`, an ordered array of tables: a\n");
123    body.push_str("# preference order. Each one is tried in turn, and a transport failure -\n");
124    body.push_str("# unreachable, timed out, rate limited, 5xx, or an empty answer - falls\n");
125    body.push_str("# through to the next. A 401 or 403 does not: that is a broken key, and\n");
126    body.push_str("# failing over would hide it. Add a fallback by adding another block:\n");
127    body.push_str("#\n");
128    body.push_str("#     [[llm]]\n");
129    body.push_str("#     endpoint = \"https://openrouter.ai/api/v1\"\n");
130    body.push_str("#     model = \"deepseek/deepseek-v4-pro-0813\"\n");
131    body.push_str("#\n");
132    body.push_str("# Set `enabled = false` on a block to park it without deleting it.\n");
133    body.push_str("#\n");
134    if choices.iter().any(Choice::is_http) {
135        body.push_str(
136            "# API keys are NOT in this file. `drep init` stores them per machine, keyed\n",
137        );
138        body.push_str("# by endpoint, so this file carries only the provider choice and can be\n");
139        body.push_str("# committed. `drep auth list` shows what is stored. To pin a key to a\n");
140        body.push_str(
141            "# variable instead - which is what CI wants - add `api_key = \"${VAR}\"` to\n",
142        );
143        body.push_str("# a block; an explicit value always wins over the stored one.\n");
144    }
145    if choices.iter().any(|choice| !choice.is_http()) {
146        body.push_str("# Codex owns ChatGPT subscription login and token refresh.\n");
147        body.push_str("# Run `codex login`; drep never reads or stores those credentials.\n");
148    }
149    body.push_str("#\n");
150    body.push_str("# Fresh semantic reviews are bounded per remediation cycle. Cached verdicts\n");
151    body.push_str("# and deterministic tools remain available after the limit is reached.\n");
152    body.push_str(&format!(
153        "max_review_rounds = {}\n",
154        crate::config::DEFAULT_MAX_REVIEW_ROUNDS
155    ));
156
157    for choice in choices {
158        body.push('\n');
159        render_one(&mut body, choice);
160    }
161
162    body
163}
164
165/// Render one `[[llm]]` block into `body`.
166fn render_one(body: &mut String, choice: &Choice) {
167    let preset = choice.preset;
168
169    body.push_str("[[llm]]\n");
170    body.push_str("enabled = true\n");
171    if let (PresetBackend::Codex(codex), ChoiceBackend::Codex) = (&preset.backend, &choice.backend)
172    {
173        body.push_str("backend = \"codex\"\n");
174        body.push_str(&format!("model = \"{}\"\n", escape(&choice.model)));
175        if let Some(effort) = &codex.reasoning_effort {
176            body.push_str(&format!("reasoning_effort = \"{}\"\n", effort.as_str()));
177        }
178        if let Some(timeout) = preset.timeout_secs {
179            body.push_str(&format!("timeout_secs = {timeout}\n"));
180        }
181        body.push_str(&format!("max_concurrent = {}\n", codex.max_concurrent));
182        body.push_str(
183            "# Reviews consume ChatGPT/Codex subscription allowance, not OpenAI API billing.\n",
184        );
185        return;
186    }
187
188    let (http, endpoint, key_in_store, quirks) = match (&preset.backend, &choice.backend) {
189        (
190            PresetBackend::Http(http),
191            ChoiceBackend::Http {
192                endpoint,
193                key_in_store,
194                quirks,
195            },
196        ) => (http, endpoint.as_str(), *key_in_store, quirks),
197        _ => panic!("choice backend does not match preset `{}`", preset.key),
198    };
199    body.push_str(&format!("endpoint = \"{}\"\n", escape(endpoint)));
200    body.push_str(&format!("model = \"{}\"\n", escape(&choice.model)));
201
202    // Omitted entirely when the key is in the store: an explicit `api_key` wins
203    // over a stored one, so writing `${VAR}` here would override the key
204    // `drep init` just saved with a variable nobody set.
205    if !key_in_store && let Some(env) = http.api_key_env {
206        body.push_str(&format!("api_key = \"${{{env}}}\"\n"));
207    }
208
209    // Written only when the preset names one, so an OpenAI-compatible block
210    // stays minimal.
211    if let Some(protocol) = http.protocol {
212        body.push_str("# This endpoint speaks Anthropic's messages API, not chat completions.\n");
213        body.push_str(&format!("protocol = \"{}\"\n", escape(protocol)));
214    }
215
216    // Written only for an endpoint that refuses a request without it. Everywhere
217    // else an unset cap is what stops a reasoning model being truncated mid-thought.
218    //
219    // The second comment line says where the number came from, because "this is
220    // the model's own limit" is a claim in a file the user commits, and it is
221    // false whenever the registry could not name the model.
222    if let Some(max_tokens) = quirks.max_tokens {
223        body.push_str("# Required by this endpoint: it refuses a request that omits the field.\n");
224        if quirks.max_tokens_from_registry {
225            body.push_str(
226                "# This is the model's own published output limit, not a cap drep chose.\n",
227            );
228        } else {
229            body.push_str(
230                "# This model's own limit is not known here, so it is the provider's fallback:\n\
231                 # set well above any review-sized response.\n",
232            );
233        }
234        body.push_str(&format!("max_tokens = {max_tokens}\n"));
235    }
236
237    // Absent means the parameter is omitted from the request entirely, which is what
238    // a model that rejects it requires. That is a property of the model, so the
239    // chosen model decides rather than the file inheriting a default.
240    match quirks.temperature {
241        Some(temperature) => {
242            // `{:?}` rather than `{}`: Display renders `1.0` as `1`, which TOML
243            // reads as an *integer* and `config::load` then refuses with a type
244            // error - from a file `drep init` had just reported writing.
245            body.push_str(&format!("temperature = {temperature:?}\n"));
246        }
247        None => {
248            body.push_str("# `temperature` is deliberately absent: this model rejects the\n");
249            body.push_str("# parameter, and the resulting 400 neither fails over nor retries.\n");
250        }
251    }
252
253    if let Some(timeout) = preset.timeout_secs {
254        body.push_str(
255            "# A reasoning model can spend minutes on one file; the wall clock has to match.\n",
256        );
257        body.push_str(&format!("timeout_secs = {timeout}\n"));
258    }
259
260    if quirks.max_tokens.is_none() {
261        body.push_str(
262            "# max_tokens is deliberately unset: with no completion cap, a reasoning model\n",
263        );
264        body.push_str("# is never truncated mid-thought. Set it only to cap spend.\n");
265    }
266}
267
268/// The refusal to overwrite an existing config.
269///
270/// Shared with `init::existing_config`, which has to make the same decision
271/// *before* the wizard asks anything - the wizard stores a pasted key on its
272/// way through, so refusing at the write half-applies the run. Two copies of
273/// the message meant two places to keep the `--force` instruction in step.
274pub fn already_exists(path: &Path) -> anyhow::Error {
275    anyhow!(
276        "{} already exists. Re-run with --force to replace it.",
277        path.display()
278    )
279}
280
281/// Escape a string for a TOML basic string.
282///
283/// `\` and `"` are the obvious two, and were once the only two handled here -
284/// which was wrong: TOML forbids the literal control characters U+0000-U+0008,
285/// U+000A-U+001F and U+007F inside a basic string. A model or endpoint
286/// carrying a stray `\r` (a URL pasted from a CRLF file is the realistic way
287/// in) produced a `drep.toml` that `config::load` then refused to parse - so
288/// `drep init` reported success and left behind a config nothing could read,
289/// and `write` would not replace it without `--force`.
290///
291/// The characters with short escapes get them (tab included - legal literally,
292/// but clearer escaped in a file a human reads); anything else in the control
293/// range becomes `\uXXXX`.
294fn escape(s: &str) -> String {
295    let mut out = String::with_capacity(s.len());
296    for ch in s.chars() {
297        match ch {
298            '\\' => out.push_str("\\\\"),
299            '"' => out.push_str("\\\""),
300            '\n' => out.push_str("\\n"),
301            '\r' => out.push_str("\\r"),
302            '\t' => out.push_str("\\t"),
303            '\u{8}' => out.push_str("\\b"),
304            '\u{c}' => out.push_str("\\f"),
305            // The rest of the control range has no short form.
306            c if c.is_control() => out.push_str(&format!("\\u{:04X}", c as u32)),
307            other => out.push(other),
308        }
309    }
310    out
311}
312
313/// Write `drep.toml` under `root`.
314///
315/// Refuses to overwrite an existing file unless `force`. The error names the
316/// path and points at `--force` so the user does not have to read code to
317/// learn how to recover.
318pub fn write(root: &Path, body: &str, force: bool) -> Result<PathBuf> {
319    use std::io::Write;
320
321    let path = root.join(crate::config::default_config_path());
322    let mut temporary = tempfile::NamedTempFile::new_in(root)
323        .with_context(|| format!("could not write {}", path.display()))?;
324    temporary
325        .write_all(body.as_bytes())
326        .with_context(|| format!("could not write {}", path.display()))?;
327    temporary
328        .as_file()
329        .sync_all()
330        .with_context(|| format!("could not write {}", path.display()))?;
331
332    // Publish only a complete file. `persist_noclobber` keeps the no-force
333    // existence check atomic; `persist` replaces the directory entry itself,
334    // so `--force` cannot follow a symlink and overwrite its target.
335    let published = if force {
336        temporary.persist(&path)
337    } else {
338        temporary.persist_noclobber(&path)
339    };
340    match published {
341        Ok(_) => Ok(path),
342        Err(err) => match (force, err.error.kind()) {
343            (false, std::io::ErrorKind::AlreadyExists) => Err(already_exists(&path)),
344            _ => Err(anyhow::Error::new(err.error)
345                .context(format!("could not write {}", path.display()))),
346        },
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn a_write_that_fails_for_another_reason_is_not_reported_as_already_existing() {
356        // `already_exists` names `--force` as the way out, so reporting it for
357        // a missing directory or a permission error sends the user to a flag
358        // that cannot help - and `--force` would then fail the same way with
359        // the same message.
360        let dir = tempfile::tempdir().expect("tempdir");
361        let missing = dir.path().join("no-such-directory");
362
363        let err = write(&missing, "model = \"x\"\n", false)
364            .expect_err("there is no directory to write into");
365
366        assert!(
367            !err.to_string().contains("already exists"),
368            "nothing exists here; got {err}"
369        );
370        assert!(err.to_string().contains("could not write"), "got {err}");
371    }
372
373    #[cfg(unix)]
374    #[test]
375    fn a_dangling_symlink_named_drep_toml_is_refused_rather_than_followed() {
376        // `Path::exists` reports false for a symlink whose target is missing,
377        // while `fs::write` creates the target - so the guard said "nothing is
378        // there" and the write went somewhere nobody named.
379        let dir = tempfile::tempdir().expect("tempdir");
380        let elsewhere = dir.path().join("elsewhere.toml");
381        std::os::unix::fs::symlink(&elsewhere, dir.path().join("drep.toml")).expect("symlink");
382
383        let err = write(dir.path(), "model = \"x\"\n", false)
384            .expect_err("a dangling symlink is still something being there");
385
386        assert!(err.to_string().contains("drep.toml"), "got {err}");
387        assert!(
388            !elsewhere.exists(),
389            "and nothing was written through it to {}",
390            elsewhere.display()
391        );
392    }
393
394    #[test]
395    fn escape_handles_backslash_and_quote() {
396        assert_eq!(escape("plain"), "plain");
397        assert_eq!(escape(r#"a"b"#), r#"a\"b"#);
398        assert_eq!(escape(r"a\b"), r"a\\b");
399        assert_eq!(escape(r#"a"b\c"#), r#"a\"b\\c"#);
400    }
401}