use std::collections::{BTreeMap, BTreeSet};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::domain::version::Version;
use crate::errors::{Error, Result};
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct FileFilter {
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub exclude: Vec<String>,
}
impl FileFilter {
pub fn is_empty(&self) -> bool {
self.include.is_empty() && self.exclude.is_empty()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct OneharnessCfg {
#[serde(default)]
pub config: Vec<String>,
#[serde(default)]
pub bin: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
#[schemars(range(min = 1))]
pub timeout: Option<u64>,
#[serde(default)]
pub schema_max_retries: Option<u32>,
}
impl OneharnessCfg {
pub fn merge_under(&mut self, other: OneharnessCfg) {
if self.config.is_empty() {
self.config = other.config;
}
self.bin = self.bin.take().or(other.bin);
self.model = self.model.take().or(other.model);
self.timeout = self.timeout.or(other.timeout);
self.schema_max_retries = self.schema_max_retries.or(other.schema_max_retries);
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Agent {
#[serde(default)]
pub harness: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
#[schemars(range(min = 1))]
pub batch_size: Option<usize>,
#[serde(default)]
pub prompt_template: Option<String>,
#[serde(default)]
pub files: Option<FileFilter>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(untagged)]
pub enum Relevance {
Always(bool),
When(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RelevanceMode {
Always,
Never,
Conditional(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Rule {
#[schemars(regex(pattern = r"^[A-Za-z][A-Za-z0-9_]*$"))]
pub name: String,
#[schemars(length(min = 1))]
pub description: String,
#[serde(default)]
pub agent: Option<String>,
#[serde(default)]
#[schemars(range(min = 1))]
pub judges: Option<u32>,
#[serde(default)]
pub files: Option<FileFilter>,
#[serde(default)]
pub rationale: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relevance: Option<Relevance>,
}
impl Rule {
pub fn judges(&self) -> u32 {
self.judges.unwrap_or(1)
}
pub fn wants_rationale(&self, session_default: bool) -> bool {
self.rationale.unwrap_or(session_default)
}
pub fn relevance_mode(&self) -> RelevanceMode {
match &self.relevance {
None | Some(Relevance::Always(true)) => RelevanceMode::Always,
Some(Relevance::Always(false)) => RelevanceMode::Never,
Some(Relevance::When(cond)) => RelevanceMode::Conditional(cond.clone()),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[schemars(
title = "llmlint configuration",
description = "Configuration for llmlint, an LLM-as-judge linter for code-quality checks \
deterministic linters can't express. Docs: https://github.com/nickderobertis/llmlint"
)]
pub struct Config {
#[serde(default)]
pub version: Option<Version>,
#[serde(default)]
pub prompt_template: Option<String>,
#[serde(default)]
pub files: FileFilter,
#[serde(default)]
pub oneharness: OneharnessCfg,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rationales: Option<bool>,
#[serde(default)]
pub plugins: Vec<String>,
#[serde(default)]
pub agents: BTreeMap<String, Agent>,
#[serde(default)]
pub rules: Vec<Rule>,
}
impl Config {
pub fn merge_plugin(&mut self, other: Config) {
self.version = self.version.take().or(other.version);
self.prompt_template = self.prompt_template.take().or(other.prompt_template);
if self.files.is_empty() {
self.files = other.files;
}
self.oneharness.merge_under(other.oneharness);
self.rationales = self.rationales.or(other.rationales);
for (name, agent) in other.agents {
self.agents.entry(name).or_insert(agent);
}
self.rules.extend(other.rules);
}
pub fn agent_or_default(&self, name: &str) -> Agent {
self.agents.get(name).cloned().unwrap_or_default()
}
pub fn rationales_default(&self) -> bool {
self.rationales.unwrap_or(true)
}
}
pub fn is_valid_rule_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
pub fn validate(config: &Config) -> Result<()> {
let mut problems: Vec<String> = Vec::new();
let mut seen: BTreeSet<&str> = BTreeSet::new();
for rule in &config.rules {
if !is_valid_rule_name(&rule.name) {
problems.push(format!(
"rule name {:?} is not a valid identifier (letters, digits, underscore; \
must start with a letter)",
rule.name
));
}
if !seen.insert(rule.name.as_str()) {
problems.push(format!("duplicate rule name {:?}", rule.name));
}
if rule.description.trim().is_empty() {
problems.push(format!("rule {:?} has an empty description", rule.name));
}
if let Some(Relevance::When(cond)) = &rule.relevance {
if cond.trim().is_empty() {
problems.push(format!(
"rule {:?} has an empty relevance condition (use `true`/`false` for an \
always/never rule, or a non-empty condition)",
rule.name
));
}
}
if rule.judges == Some(0) {
problems.push(format!("rule {:?} has judges: 0 (must be >= 1)", rule.name));
} else if let Some(judges) = rule.judges {
if judges % 2 == 0 {
problems.push(format!(
"rule {:?} has judges: {} (must be odd so the majority verdict can't tie)",
rule.name, judges
));
}
}
if let Some(agent) = &rule.agent {
if !config.agents.contains_key(agent) {
problems.push(format!(
"rule {:?} references unknown agent {:?}",
rule.name, agent
));
}
}
}
for (name, agent) in &config.agents {
if agent.batch_size == Some(0) {
problems.push(format!("agent {:?} has batch_size: 0 (must be >= 1)", name));
}
}
if problems.is_empty() {
Ok(())
} else {
Err(Error::InvalidConfig(problems.join("; ")))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rule(name: &str) -> Rule {
Rule {
name: name.into(),
description: "true when ok; false otherwise.".into(),
agent: None,
judges: None,
files: None,
rationale: None,
relevance: None,
}
}
#[test]
fn valid_config_passes() {
let c = Config {
rules: vec![rule("alpha_rule"), rule("beta_rule")],
..Default::default()
};
assert!(validate(&c).is_ok());
}
#[test]
fn name_validation_rules() {
assert!(is_valid_rule_name("good_name1"));
assert!(!is_valid_rule_name("1leading_digit"));
assert!(!is_valid_rule_name("has-dash"));
assert!(!is_valid_rule_name(""));
assert!(!is_valid_rule_name("with space"));
}
#[test]
fn collects_duplicate_invalid_and_unknown_agent() {
let mut bad = rule("dup");
bad.agent = Some("missing".into());
let c = Config {
rules: vec![
rule("dup"),
bad,
rule("bad-name"),
Rule {
judges: Some(0),
..rule("zero_judges")
},
],
..Default::default()
};
let err = validate(&c).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("duplicate rule name"));
assert!(msg.contains("unknown agent"));
assert!(msg.contains("not a valid identifier"));
assert!(msg.contains("judges: 0"));
}
#[test]
fn empty_description_is_invalid() {
let c = Config {
rules: vec![Rule {
description: " ".into(),
..rule("empty_desc")
}],
..Default::default()
};
assert!(validate(&c).is_err());
}
#[test]
fn merge_keeps_root_agent_and_appends_rules() {
let mut root = Config {
rules: vec![rule("root_rule")],
..Default::default()
};
root.agents.insert(
"shared".into(),
Agent {
harness: Some("claude-code".into()),
..Default::default()
},
);
let mut other = Config {
rules: vec![rule("plugin_rule")],
..Default::default()
};
other.agents.insert(
"shared".into(),
Agent {
harness: Some("codex".into()),
..Default::default()
},
);
root.merge_plugin(other);
assert_eq!(root.rules.len(), 2);
assert_eq!(
root.agents["shared"].harness.as_deref(),
Some("claude-code")
);
}
#[test]
fn merge_top_level_scalars_keep_root_then_fall_back_to_plugin() {
let mut root = Config {
prompt_template: Some("root template".into()),
rationales: Some(false),
..Default::default()
};
root.oneharness.model = Some("opus".into());
let plugin = Config {
prompt_template: Some("plugin template".into()),
rationales: Some(true),
files: FileFilter {
include: vec!["src/**".into()],
exclude: vec![],
},
oneharness: OneharnessCfg {
model: Some("haiku".into()),
timeout: Some(99),
..Default::default()
},
..Default::default()
};
root.merge_plugin(plugin);
assert_eq!(root.prompt_template.as_deref(), Some("root template"));
assert_eq!(root.rationales, Some(false));
assert_eq!(root.oneharness.model.as_deref(), Some("opus"));
assert_eq!(root.files.include, vec!["src/**".to_string()]);
assert_eq!(root.oneharness.timeout, Some(99));
}
#[test]
fn rationales_default_is_true_when_unset() {
assert!(Config::default().rationales_default());
let off = Config {
rationales: Some(false),
..Default::default()
};
assert!(!off.rationales_default());
}
#[test]
fn per_rule_rationale_overrides_session_default() {
let r = rule("r");
assert!(r.wants_rationale(true));
assert!(!r.wants_rationale(false));
let forced_on = Rule {
rationale: Some(true),
..rule("on")
};
let forced_off = Rule {
rationale: Some(false),
..rule("off")
};
assert!(forced_on.wants_rationale(false));
assert!(!forced_off.wants_rationale(true));
}
#[test]
fn even_judges_is_invalid() {
let c = Config {
rules: vec![Rule {
judges: Some(2),
..rule("even_judges")
}],
..Default::default()
};
let err = validate(&c).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("must be odd"), "got: {msg}");
}
#[test]
fn odd_judges_is_valid() {
let c = Config {
rules: vec![Rule {
judges: Some(3),
..rule("odd_judges")
}],
..Default::default()
};
assert!(validate(&c).is_ok());
}
#[test]
fn relevance_mode_resolves_the_default_and_the_three_forms() {
assert_eq!(rule("r").relevance_mode(), RelevanceMode::Always);
let always = Rule {
relevance: Some(Relevance::Always(true)),
..rule("r")
};
assert_eq!(always.relevance_mode(), RelevanceMode::Always);
let never = Rule {
relevance: Some(Relevance::Always(false)),
..rule("r")
};
assert_eq!(never.relevance_mode(), RelevanceMode::Never);
let when = Rule {
relevance: Some(Relevance::When("the change touches SQL".into())),
..rule("r")
};
assert_eq!(
when.relevance_mode(),
RelevanceMode::Conditional("the change touches SQL".into())
);
}
#[test]
fn empty_relevance_condition_is_invalid() {
let c = Config {
rules: vec![Rule {
relevance: Some(Relevance::When(" ".into())),
..rule("blank_relevance")
}],
..Default::default()
};
let err = validate(&c).unwrap_err();
assert!(err.to_string().contains("empty relevance condition"));
}
#[test]
fn agent_batch_size_zero_is_invalid() {
let mut c = Config::default();
c.agents.insert(
"a".into(),
Agent {
batch_size: Some(0),
..Default::default()
},
);
assert!(validate(&c).is_err());
}
}