use crate::domain::config::{Config, Provenance};
use crate::errors::{io_err, Error, Result};
pub const ENV_SETTINGS: &[(&str, &str)] = &[
("files.include", "LLMLINT_FILES_INCLUDE"),
("files.exclude", "LLMLINT_FILES_EXCLUDE"),
("oneharness.config", "LLMLINT_ONEHARNESS_CONFIG"),
("oneharness.bin", "LLMLINT_ONEHARNESS_BIN"),
("oneharness.model", "LLMLINT_ONEHARNESS_MODEL"),
("oneharness.timeout", "LLMLINT_ONEHARNESS_TIMEOUT"),
(
"oneharness.schema_max_retries",
"LLMLINT_ONEHARNESS_SCHEMA_MAX_RETRIES",
),
("prompt_template", "LLMLINT_PROMPT_TEMPLATE"),
("rationales", "LLMLINT_RATIONALES"),
("diff_base", "LLMLINT_DIFF_BASE"),
("history.enabled", "LLMLINT_HISTORY_ENABLED"),
("history.max_runs", "LLMLINT_HISTORY_MAX_RUNS"),
("history.dir", "LLMLINT_HISTORY_DIR"),
];
#[cfg(test)]
const CONFIG_ONLY_SETTINGS: &[&str] = &["version"];
#[cfg(windows)]
const LIST_SEP: char = ';';
#[cfg(not(windows))]
const LIST_SEP: char = ':';
pub fn apply_overrides(config: &mut Config) -> Result<()> {
let mut prov = Provenance::default();
apply_overrides_prov(config, &mut prov)
}
pub fn apply_overrides_prov(config: &mut Config, prov: &mut Provenance) -> Result<()> {
apply_from(config, prov, non_empty_var)
}
fn apply_from(
config: &mut Config,
prov: &mut Provenance,
get: impl Fn(&str) -> Option<String>,
) -> Result<()> {
if let Some(v) = get("LLMLINT_FILES_INCLUDE") {
let globs = parse_list("LLMLINT_FILES_INCLUDE", &v)?;
config.files.include = globs;
note(prov, "files.include", "LLMLINT_FILES_INCLUDE");
}
if let Some(v) = get("LLMLINT_FILES_EXCLUDE") {
let globs = parse_list("LLMLINT_FILES_EXCLUDE", &v)?;
config.files.exclude.extend(globs);
note(prov, "files.exclude", "LLMLINT_FILES_EXCLUDE");
}
if let Some(v) = get("LLMLINT_ONEHARNESS_CONFIG") {
config.oneharness.config = vec![v];
note(prov, "oneharness.config", "LLMLINT_ONEHARNESS_CONFIG");
}
if let Some(v) = get("LLMLINT_ONEHARNESS_BIN") {
config.oneharness.bin = Some(v);
note(prov, "oneharness.bin", "LLMLINT_ONEHARNESS_BIN");
}
if let Some(v) = get("LLMLINT_ONEHARNESS_MODEL") {
config.oneharness.model = Some(v);
note(prov, "oneharness.model", "LLMLINT_ONEHARNESS_MODEL");
}
if let Some(v) = get("LLMLINT_ONEHARNESS_TIMEOUT") {
config.oneharness.timeout = Some(parse_int("LLMLINT_ONEHARNESS_TIMEOUT", &v, 1)?);
note(prov, "oneharness.timeout", "LLMLINT_ONEHARNESS_TIMEOUT");
}
if let Some(v) = get("LLMLINT_ONEHARNESS_SCHEMA_MAX_RETRIES") {
config.oneharness.schema_max_retries =
Some(parse_int("LLMLINT_ONEHARNESS_SCHEMA_MAX_RETRIES", &v, 0)? as u32);
note(
prov,
"oneharness.schema_max_retries",
"LLMLINT_ONEHARNESS_SCHEMA_MAX_RETRIES",
);
}
if let Some(v) = get("LLMLINT_PROMPT_TEMPLATE") {
let text = std::fs::read_to_string(&v).map_err(|e| Error::Env {
var: "LLMLINT_PROMPT_TEMPLATE".to_string(),
message: io_err(format!("reading prompt template {v}"), e).to_string(),
})?;
config.prompt_template = Some(text);
note(prov, "prompt_template", "LLMLINT_PROMPT_TEMPLATE");
}
if let Some(v) = get("LLMLINT_RATIONALES") {
config.rationales = Some(parse_bool("LLMLINT_RATIONALES", &v)?);
note(prov, "rationales", "LLMLINT_RATIONALES");
}
if let Some(v) = get("LLMLINT_DIFF_BASE") {
config.diff_base = Some(v);
note(prov, "diff_base", "LLMLINT_DIFF_BASE");
}
if let Some(v) = get("LLMLINT_HISTORY_ENABLED") {
config.history.enabled = Some(parse_bool("LLMLINT_HISTORY_ENABLED", &v)?);
note(prov, "history.enabled", "LLMLINT_HISTORY_ENABLED");
}
if let Some(v) = get("LLMLINT_HISTORY_MAX_RUNS") {
config.history.max_runs = Some(parse_int("LLMLINT_HISTORY_MAX_RUNS", &v, 1)? as usize);
note(prov, "history.max_runs", "LLMLINT_HISTORY_MAX_RUNS");
}
if let Some(v) = get("LLMLINT_HISTORY_DIR") {
config.history.dir = Some(v);
note(prov, "history.dir", "LLMLINT_HISTORY_DIR");
}
Ok(())
}
fn note(prov: &mut Provenance, key: &str, var: &str) {
prov.settings.insert(key.to_string(), format!("env:{var}"));
}
fn non_empty_var(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|s| !s.is_empty())
}
fn parse_int(var: &str, val: &str, min: u64) -> Result<u64> {
let n: u64 = val.trim().parse().map_err(|_| Error::Env {
var: var.to_string(),
message: format!("expected a whole number, got {val:?}"),
})?;
if n < min {
return Err(Error::Env {
var: var.to_string(),
message: format!("must be >= {min}, got {n}"),
});
}
Ok(n)
}
fn parse_list(var: &str, val: &str) -> Result<Vec<String>> {
let globs: Vec<String> = val
.split(LIST_SEP)
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
if globs.is_empty() {
return Err(Error::Env {
var: var.to_string(),
message: format!("expected one or more {LIST_SEP:?}-separated globs, got {val:?}"),
});
}
Ok(globs)
}
fn parse_bool(var: &str, val: &str) -> Result<bool> {
match val.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" => Ok(true),
"0" | "false" | "no" => Ok(false),
_ => Err(Error::Env {
var: var.to_string(),
message: format!("expected a boolean (1/true/yes or 0/false/no), got {val:?}"),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::config::SETTING_KEYS;
use std::collections::HashMap;
fn lookup(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let map: HashMap<String, String> = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
move |name: &str| map.get(name).cloned()
}
fn apply(config: &mut Config, prov: &mut Provenance, pairs: &[(&str, &str)]) -> Result<()> {
apply_from(config, prov, lookup(pairs))
}
#[test]
fn covers_every_overridable_setting() {
use std::collections::BTreeSet;
let env: BTreeSet<&str> = ENV_SETTINGS.iter().map(|(k, _)| *k).collect();
let config_only: BTreeSet<&str> = CONFIG_ONLY_SETTINGS.iter().copied().collect();
let all: BTreeSet<&str> = SETTING_KEYS.iter().copied().collect();
assert!(env.is_disjoint(&config_only), "a setting can't be both");
let union: BTreeSet<&str> = env.union(&config_only).copied().collect();
assert_eq!(
union, all,
"every setting must be env-overridable or config-only"
);
}
#[test]
fn env_var_names_match_the_setting_path() {
for (key, var) in ENV_SETTINGS {
let expected = format!("LLMLINT_{}", key.to_uppercase().replace('.', "_"));
assert_eq!(*var, expected, "env var for {key:?}");
}
}
#[test]
fn folds_scalar_settings_and_records_env_provenance() {
let mut config = Config::default();
let mut prov = Provenance::default();
apply(
&mut config,
&mut prov,
&[
("LLMLINT_ONEHARNESS_MODEL", "env-model"),
("LLMLINT_ONEHARNESS_TIMEOUT", "42"),
("LLMLINT_ONEHARNESS_SCHEMA_MAX_RETRIES", "0"),
("LLMLINT_ONEHARNESS_CONFIG", "oh.toml"),
("LLMLINT_ONEHARNESS_BIN", "/opt/oneharness"),
("LLMLINT_RATIONALES", "no"),
("LLMLINT_DIFF_BASE", "main"),
("LLMLINT_HISTORY_ENABLED", "false"),
("LLMLINT_HISTORY_MAX_RUNS", "7"),
("LLMLINT_HISTORY_DIR", "/tmp/h"),
],
)
.unwrap();
assert_eq!(config.oneharness.model.as_deref(), Some("env-model"));
assert_eq!(config.oneharness.timeout, Some(42));
assert_eq!(config.oneharness.schema_max_retries, Some(0));
assert_eq!(config.oneharness.config, vec!["oh.toml".to_string()]);
assert_eq!(config.oneharness.bin.as_deref(), Some("/opt/oneharness"));
assert_eq!(config.rationales, Some(false));
assert_eq!(config.diff_base.as_deref(), Some("main"));
assert_eq!(config.history.enabled, Some(false));
assert_eq!(config.history.max_runs, Some(7));
assert_eq!(config.history.dir.as_deref(), Some("/tmp/h"));
assert_eq!(
prov.settings["oneharness.model"],
"env:LLMLINT_ONEHARNESS_MODEL"
);
assert_eq!(prov.settings["rationales"], "env:LLMLINT_RATIONALES");
assert_eq!(
prov.settings["history.max_runs"],
"env:LLMLINT_HISTORY_MAX_RUNS"
);
}
#[test]
fn files_include_replaces_and_exclude_unions() {
let sep = LIST_SEP;
let mut config = Config {
files: crate::domain::config::FileFilter {
include: vec!["config/**".to_string()],
exclude: vec!["**/config-ex.rs".to_string()],
},
..Config::default()
};
let mut prov = Provenance::default();
apply(
&mut config,
&mut prov,
&[
("LLMLINT_FILES_INCLUDE", &format!("a/**{sep}b/**")),
("LLMLINT_FILES_EXCLUDE", "**/env-ex.rs"),
],
)
.unwrap();
assert_eq!(config.files.include, vec!["a/**", "b/**"]);
assert_eq!(
config.files.exclude,
vec!["**/config-ex.rs", "**/env-ex.rs"]
);
assert_eq!(prov.settings["files.include"], "env:LLMLINT_FILES_INCLUDE");
assert_eq!(prov.settings["files.exclude"], "env:LLMLINT_FILES_EXCLUDE");
}
#[test]
fn empty_files_list_is_a_boundary_error() {
let mut config = Config::default();
let mut prov = Provenance::default();
let err = apply(&mut config, &mut prov, &[("LLMLINT_FILES_INCLUDE", " ")]).unwrap_err();
match err {
Error::Env { var, .. } => assert_eq!(var, "LLMLINT_FILES_INCLUDE"),
other => panic!("expected Error::Env, got {other:?}"),
}
}
#[test]
fn env_overrides_a_config_value_and_replaces_its_provenance() {
let mut config = Config::default();
config.oneharness.model = Some("config-model".to_string());
let mut prov = Provenance::default();
prov.settings
.insert("oneharness.model".to_string(), "llmlint.yml".to_string());
apply(
&mut config,
&mut prov,
&[("LLMLINT_ONEHARNESS_MODEL", "env-model")],
)
.unwrap();
assert_eq!(config.oneharness.model.as_deref(), Some("env-model"));
assert_eq!(
prov.settings["oneharness.model"],
"env:LLMLINT_ONEHARNESS_MODEL"
);
}
#[test]
fn unset_env_leaves_config_and_provenance_untouched() {
let mut config = Config {
diff_base: Some("develop".to_string()),
..Config::default()
};
let mut prov = Provenance::default();
prov.settings
.insert("diff_base".to_string(), "llmlint.yml".to_string());
apply(&mut config, &mut prov, &[]).unwrap();
assert_eq!(config.diff_base.as_deref(), Some("develop"));
assert_eq!(prov.settings["diff_base"], "llmlint.yml");
}
#[test]
fn bool_grammar_accepts_documented_spellings() {
for on in ["1", "true", "TRUE", "Yes", " yes "] {
assert!(parse_bool("V", on).unwrap(), "{on:?} should be true");
}
for off in ["0", "false", "No", " NO "] {
assert!(!parse_bool("V", off).unwrap(), "{off:?} should be false");
}
}
#[test]
fn malformed_bool_is_located_to_the_variable() {
let mut config = Config::default();
let mut prov = Provenance::default();
let err = apply(&mut config, &mut prov, &[("LLMLINT_RATIONALES", "maybe")]).unwrap_err();
match err {
Error::Env { var, message } => {
assert_eq!(var, "LLMLINT_RATIONALES");
assert!(message.contains("boolean"), "got: {message}");
}
other => panic!("expected Error::Env, got {other:?}"),
}
}
#[test]
fn non_numeric_and_below_minimum_are_errors() {
let mut config = Config::default();
let mut prov = Provenance::default();
assert!(matches!(
apply(
&mut config,
&mut prov,
&[("LLMLINT_ONEHARNESS_TIMEOUT", "abc")]
),
Err(Error::Env { .. })
));
let err = apply(&mut config, &mut prov, &[("LLMLINT_HISTORY_MAX_RUNS", "0")]).unwrap_err();
match err {
Error::Env { var, message } => {
assert_eq!(var, "LLMLINT_HISTORY_MAX_RUNS");
assert!(message.contains(">= 1"), "got: {message}");
}
other => panic!("expected Error::Env, got {other:?}"),
}
}
#[test]
fn prompt_template_reads_the_file_contents() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tmpl.txt");
std::fs::write(&path, "custom template body").unwrap();
let mut config = Config::default();
let mut prov = Provenance::default();
apply(
&mut config,
&mut prov,
&[("LLMLINT_PROMPT_TEMPLATE", path.to_str().unwrap())],
)
.unwrap();
assert_eq!(
config.prompt_template.as_deref(),
Some("custom template body")
);
assert_eq!(
prov.settings["prompt_template"],
"env:LLMLINT_PROMPT_TEMPLATE"
);
}
#[test]
fn prompt_template_missing_file_is_located_to_the_variable() {
let mut config = Config::default();
let mut prov = Provenance::default();
let err = apply(
&mut config,
&mut prov,
&[("LLMLINT_PROMPT_TEMPLATE", "/no/such/template.txt")],
)
.unwrap_err();
match err {
Error::Env { var, .. } => assert_eq!(var, "LLMLINT_PROMPT_TEMPLATE"),
other => panic!("expected Error::Env, got {other:?}"),
}
}
}