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>,
}
#[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(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>,
}
impl Rule {
pub fn judges(&self) -> u32 {
self.judges.unwrap_or(1)
}
}
#[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)]
pub plugins: Vec<String>,
#[serde(default)]
pub agents: BTreeMap<String, Agent>,
#[serde(default)]
pub rules: Vec<Rule>,
}
impl Config {
pub fn merge_rules_and_agents(&mut self, other: Config) {
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 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 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,
}
}
#[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_rules_and_agents(other);
assert_eq!(root.rules.len(), 2);
assert_eq!(
root.agents["shared"].harness.as_deref(),
Some("claude-code")
);
}
#[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 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());
}
}