1use std::path::{Path, PathBuf};
13
14use anyhow::{Context, Result, anyhow};
15
16use super::presets::{LlmPreset, PresetBackend};
17use crate::llm::quirks::Quirks;
18
19#[derive(Debug, Clone)]
21enum ChoiceBackend {
22 Http {
23 endpoint: String,
24 key_in_store: bool,
25 quirks: Quirks,
26 },
27 Codex,
28}
29
30#[derive(Debug, Clone)]
36pub struct Choice {
37 pub preset: &'static LlmPreset,
40 pub model: String,
42 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
109pub 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
165fn 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 if !key_in_store && let Some(env) = http.api_key_env {
206 body.push_str(&format!("api_key = \"${{{env}}}\"\n"));
207 }
208
209 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 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 match quirks.temperature {
241 Some(temperature) => {
242 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
268pub 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
281fn 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 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
313pub 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 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 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 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}