use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow};
use super::presets::{LlmPreset, PresetBackend};
use crate::llm::quirks::Quirks;
#[derive(Debug, Clone)]
enum ChoiceBackend {
Http {
endpoint: String,
key_in_store: bool,
quirks: Quirks,
},
Codex,
}
#[derive(Debug, Clone)]
pub struct Choice {
pub preset: &'static LlmPreset,
pub model: String,
backend: ChoiceBackend,
}
impl Choice {
pub fn http(
preset: &'static LlmPreset,
model: String,
endpoint: String,
key_in_store: bool,
quirks: Quirks,
) -> Self {
assert!(
matches!(preset.backend, PresetBackend::Http(_)),
"HTTP choice requires an HTTP preset"
);
Self {
preset,
model,
backend: ChoiceBackend::Http {
endpoint,
key_in_store,
quirks,
},
}
}
pub fn codex(preset: &'static LlmPreset, model: String) -> Self {
assert!(
matches!(preset.backend, PresetBackend::Codex(_)),
"Codex choice requires a Codex preset"
);
Self {
preset,
model,
backend: ChoiceBackend::Codex,
}
}
pub fn is_http(&self) -> bool {
matches!(self.backend, ChoiceBackend::Http { .. })
}
#[cfg(test)]
pub fn endpoint(&self) -> Option<&str> {
match &self.backend {
ChoiceBackend::Http { endpoint, .. } => Some(endpoint),
ChoiceBackend::Codex => None,
}
}
pub fn key_in_store(&self) -> bool {
match &self.backend {
ChoiceBackend::Http { key_in_store, .. } => *key_in_store,
ChoiceBackend::Codex => false,
}
}
#[cfg(test)]
pub fn quirks(&self) -> Quirks {
match &self.backend {
ChoiceBackend::Http { quirks, .. } => *quirks,
ChoiceBackend::Codex => self.preset.quirks(),
}
}
}
pub fn render_chain(choices: &[Choice]) -> String {
let mut body = String::new();
body.push_str("# drep configuration, written by `drep init`.\n");
body.push_str("#\n");
body.push_str("# Providers are declared as `[[llm]]`, an ordered array of tables: a\n");
body.push_str("# preference order. Each one is tried in turn, and a transport failure -\n");
body.push_str("# unreachable, timed out, rate limited, 5xx, or an empty answer - falls\n");
body.push_str("# through to the next. A 401 or 403 does not: that is a broken key, and\n");
body.push_str("# failing over would hide it. Add a fallback by adding another block:\n");
body.push_str("#\n");
body.push_str("# [[llm]]\n");
body.push_str("# endpoint = \"https://openrouter.ai/api/v1\"\n");
body.push_str("# model = \"deepseek/deepseek-v4-pro-0813\"\n");
body.push_str("#\n");
body.push_str("# Set `enabled = false` on a block to park it without deleting it.\n");
body.push_str("#\n");
if choices.iter().any(Choice::is_http) {
body.push_str(
"# API keys are NOT in this file. `drep init` stores them per machine, keyed\n",
);
body.push_str("# by endpoint, so this file carries only the provider choice and can be\n");
body.push_str("# committed. `drep auth list` shows what is stored. To pin a key to a\n");
body.push_str(
"# variable instead - which is what CI wants - add `api_key = \"${VAR}\"` to\n",
);
body.push_str("# a block; an explicit value always wins over the stored one.\n");
}
if choices.iter().any(|choice| !choice.is_http()) {
body.push_str("# Codex owns ChatGPT subscription login and token refresh.\n");
body.push_str("# Run `codex login`; drep never reads or stores those credentials.\n");
}
body.push_str("#\n");
body.push_str("# Fresh semantic reviews are bounded per remediation cycle. Cached verdicts\n");
body.push_str("# and deterministic tools remain available after the limit is reached.\n");
body.push_str(&format!(
"max_review_rounds = {}\n",
crate::config::DEFAULT_MAX_REVIEW_ROUNDS
));
for choice in choices {
body.push('\n');
render_one(&mut body, choice);
}
body
}
fn render_one(body: &mut String, choice: &Choice) {
let preset = choice.preset;
body.push_str("[[llm]]\n");
body.push_str("enabled = true\n");
if let (PresetBackend::Codex(codex), ChoiceBackend::Codex) = (&preset.backend, &choice.backend)
{
body.push_str("backend = \"codex\"\n");
body.push_str(&format!("model = \"{}\"\n", escape(&choice.model)));
if let Some(effort) = &codex.reasoning_effort {
body.push_str(&format!("reasoning_effort = \"{}\"\n", effort.as_str()));
}
if let Some(timeout) = preset.timeout_secs {
body.push_str(&format!("timeout_secs = {timeout}\n"));
}
body.push_str(&format!("max_concurrent = {}\n", codex.max_concurrent));
body.push_str(
"# Reviews consume ChatGPT/Codex subscription allowance, not OpenAI API billing.\n",
);
return;
}
let (http, endpoint, key_in_store, quirks) = match (&preset.backend, &choice.backend) {
(
PresetBackend::Http(http),
ChoiceBackend::Http {
endpoint,
key_in_store,
quirks,
},
) => (http, endpoint.as_str(), *key_in_store, quirks),
_ => panic!("choice backend does not match preset `{}`", preset.key),
};
body.push_str(&format!("endpoint = \"{}\"\n", escape(endpoint)));
body.push_str(&format!("model = \"{}\"\n", escape(&choice.model)));
if !key_in_store && let Some(env) = http.api_key_env {
body.push_str(&format!("api_key = \"${{{env}}}\"\n"));
}
if let Some(protocol) = http.protocol {
body.push_str("# This endpoint speaks Anthropic's messages API, not chat completions.\n");
body.push_str(&format!("protocol = \"{}\"\n", escape(protocol)));
}
if let Some(max_tokens) = quirks.max_tokens {
body.push_str("# Required by this endpoint: it refuses a request that omits the field.\n");
if quirks.max_tokens_from_registry {
body.push_str(
"# This is the model's own published output limit, not a cap drep chose.\n",
);
} else {
body.push_str(
"# This model's own limit is not known here, so it is the provider's fallback:\n\
# set well above any review-sized response.\n",
);
}
body.push_str(&format!("max_tokens = {max_tokens}\n"));
}
match quirks.temperature {
Some(temperature) => {
body.push_str(&format!("temperature = {temperature:?}\n"));
}
None => {
body.push_str("# `temperature` is deliberately absent: this model rejects the\n");
body.push_str("# parameter, and the resulting 400 neither fails over nor retries.\n");
}
}
if let Some(timeout) = preset.timeout_secs {
body.push_str(
"# A reasoning model can spend minutes on one file; the wall clock has to match.\n",
);
body.push_str(&format!("timeout_secs = {timeout}\n"));
}
if quirks.max_tokens.is_none() {
body.push_str(
"# max_tokens is deliberately unset: with no completion cap, a reasoning model\n",
);
body.push_str("# is never truncated mid-thought. Set it only to cap spend.\n");
}
}
pub fn already_exists(path: &Path) -> anyhow::Error {
anyhow!(
"{} already exists. Re-run with --force to replace it.",
path.display()
)
}
fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\u{8}' => out.push_str("\\b"),
'\u{c}' => out.push_str("\\f"),
c if c.is_control() => out.push_str(&format!("\\u{:04X}", c as u32)),
other => out.push(other),
}
}
out
}
pub fn write(root: &Path, body: &str, force: bool) -> Result<PathBuf> {
use std::io::Write;
let path = root.join(crate::config::default_config_path());
let mut temporary = tempfile::NamedTempFile::new_in(root)
.with_context(|| format!("could not write {}", path.display()))?;
temporary
.write_all(body.as_bytes())
.with_context(|| format!("could not write {}", path.display()))?;
temporary
.as_file()
.sync_all()
.with_context(|| format!("could not write {}", path.display()))?;
let published = if force {
temporary.persist(&path)
} else {
temporary.persist_noclobber(&path)
};
match published {
Ok(_) => Ok(path),
Err(err) => match (force, err.error.kind()) {
(false, std::io::ErrorKind::AlreadyExists) => Err(already_exists(&path)),
_ => Err(anyhow::Error::new(err.error)
.context(format!("could not write {}", path.display()))),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_write_that_fails_for_another_reason_is_not_reported_as_already_existing() {
let dir = tempfile::tempdir().expect("tempdir");
let missing = dir.path().join("no-such-directory");
let err = write(&missing, "model = \"x\"\n", false)
.expect_err("there is no directory to write into");
assert!(
!err.to_string().contains("already exists"),
"nothing exists here; got {err}"
);
assert!(err.to_string().contains("could not write"), "got {err}");
}
#[cfg(unix)]
#[test]
fn a_dangling_symlink_named_drep_toml_is_refused_rather_than_followed() {
let dir = tempfile::tempdir().expect("tempdir");
let elsewhere = dir.path().join("elsewhere.toml");
std::os::unix::fs::symlink(&elsewhere, dir.path().join("drep.toml")).expect("symlink");
let err = write(dir.path(), "model = \"x\"\n", false)
.expect_err("a dangling symlink is still something being there");
assert!(err.to_string().contains("drep.toml"), "got {err}");
assert!(
!elsewhere.exists(),
"and nothing was written through it to {}",
elsewhere.display()
);
}
#[test]
fn escape_handles_backslash_and_quote() {
assert_eq!(escape("plain"), "plain");
assert_eq!(escape(r#"a"b"#), r#"a\"b"#);
assert_eq!(escape(r"a\b"), r"a\\b");
assert_eq!(escape(r#"a"b\c"#), r#"a\"b\\c"#);
}
}