use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::thread;
use indicatif::ProgressDrawTarget;
use crate::cli::{ColorChoice, LintArgs, OutputFormat};
use crate::commands::ignores;
use crate::commands::progress::{LiveStatus, ProgressView};
use crate::domain::config::{validate, Config, RelevanceMode, Rule};
use crate::domain::ignore::Suppressions;
use crate::domain::plan::{self, JudgeRun, PlanContext, SkipReason};
use crate::domain::report::Report;
use crate::domain::template::{self};
use crate::domain::verdict::{Outcome, RuleOutcome, RuleVerdict};
use crate::domain::{applicability, attribution, diffmodel, ignore, schema, vote};
use crate::errors::{io_err, Error, Result};
use crate::io::configfs::RuleScope;
use crate::io::{assets, configfs, diff, env, files, history, oneharness};
const DEFAULT_BATCH_SIZE: usize = 20;
const DEFAULT_TIMEOUT: u64 = 600;
const DEFAULT_MAX_PARALLEL: usize = 8;
const PROMPT_TRIGGER: &str =
"Evaluate each rule against the target files and respond with the structured verdict object.";
const MAX_REWORKS: usize = 1;
pub fn run(args: LintArgs) -> Result<i32> {
let cwd = resolve_cwd(&args.cwd)?;
let loaded = configfs::load_with_targets(&args.config, &cwd, &args.files)?;
run_loaded(loaded, cwd, args, "lint")
}
pub(crate) fn resolve_cwd(arg: &Option<PathBuf>) -> Result<PathBuf> {
match arg {
Some(d) => Ok(d.clone()),
None => std::env::current_dir().map_err(|e| Error::Io(e.to_string())),
}
}
pub(crate) fn run_loaded(
loaded: configfs::Loaded,
cwd: PathBuf,
args: LintArgs,
command: &str,
) -> Result<i32> {
let mut scopes = loaded.scopes;
let sources = loaded.sources;
let mut config = loaded.config;
validate(&config)?;
validate_filters(&config, &args)?;
let pre_files = config.files.clone();
env::apply_overrides(&mut config)?;
apply_cli_overrides(&mut config, &args)?;
if config.files != pre_files {
ignores::retarget_session_scopes(&mut scopes, &cwd, &config.files);
}
let session_rationales = config.rationales_default();
let cli_files = files::from_cli(&cwd, &args.files);
check_cli_files(&cwd, &cli_files, &args, &config)?;
let selected = select_rules(&config, &args);
if selected.is_empty() {
let report = Report::new(Vec::new(), Vec::new());
return Ok(finish(&report, &args, &cwd, &sources, command, &config));
}
let master_template = config
.prompt_template
.clone()
.unwrap_or_else(|| assets::DEFAULT_TEMPLATE.to_string());
let mut resolved = Vec::new();
let mut not_relevant: Vec<String> = Vec::new();
for rule in &selected {
let relevance = match rule.relevance_mode() {
RelevanceMode::Never => {
not_relevant.push(rule.name.clone());
continue;
}
RelevanceMode::Always => None,
RelevanceMode::Conditional(cond) => Some(cond),
};
let agent_name = rule.agent.clone().unwrap_or_else(|| "default".to_string());
let fallback;
let scope = match scopes.get(&rule.name) {
Some(s) => s,
None => {
fallback = RuleScope {
dir: cwd.clone(),
files: config.files.clone(),
};
&fallback
}
};
let target = ignores::resolve_files(&cwd, rule, &cli_files, scope, &config.files.exclude)?;
resolved.push(plan::ResolvedRule {
name: rule.name.clone(),
description: rule.description.clone(),
judges: rule.judges(),
agent: agent_name,
files: target,
rationale: rule.wants_rationale(session_rationales),
relevance,
require_line_attribution: rule.requires_line_attribution(),
});
}
let diffs: BTreeMap<PathBuf, String> = match args.diff {
Some(backend) => {
let all: Vec<PathBuf> = resolved
.iter()
.flat_map(|r| r.files.iter().cloned())
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
diff::provider(backend, config.diff_base.clone()).diffs(&cwd, &all)?
}
None => BTreeMap::new(),
};
let mut diff_excluded: Vec<String> = Vec::new();
if args.diff.is_some() {
let before: BTreeSet<PathBuf> = resolved
.iter()
.flat_map(|r| r.files.iter().cloned())
.collect();
restrict_to_changed(&mut resolved, &diffs, &cwd);
let after: BTreeSet<PathBuf> = resolved
.iter()
.flat_map(|r| r.files.iter().cloned())
.collect();
diff_excluded = before
.difference(&after)
.map(|p| files::to_slash(p))
.collect();
}
let targets: BTreeSet<PathBuf> = resolved
.iter()
.flat_map(|r| r.files.iter().cloned())
.collect();
let known = ignores::known_rules(&config);
if !args.no_ignore_check {
ignores::check(&cwd, &targets, &known)?;
}
let mut suppressions: BTreeMap<String, Suppressions> = BTreeMap::new();
let mut file_tokens: BTreeMap<String, usize> = BTreeMap::new();
for rel in &targets {
if let Some(text) = files::read_text(&cwd, rel)? {
let slash = files::to_slash(rel);
let s = ignore::suppressions(&text, &known);
if !s.is_empty() {
suppressions.insert(slash.clone(), s);
}
file_tokens.insert(slash, (text.len() / 4).max(1));
}
}
let rationale_off: HashSet<String> = resolved
.iter()
.filter(|r| !r.rationale)
.map(|r| r.name.clone())
.collect();
let require_attribution: BTreeSet<String> = resolved
.iter()
.filter(|r| r.require_line_attribution)
.map(|r| r.name.clone())
.collect();
let plan_ctx = PlanContext::new(&suppressions).with_weights(&file_tokens);
let mut the_plan = plan::build(
&config,
&master_template,
DEFAULT_BATCH_SIZE,
resolved,
&plan_ctx,
);
the_plan.explanation.diff_excluded_files = diff_excluded;
if args.plan_only {
print!("{}", the_plan.explanation.to_human());
return Ok(0);
}
let bin = args
.oneharness_bin
.clone()
.or_else(|| config.oneharness.bin.clone());
let client = oneharness::Client::new(bin.as_deref());
client.check_min_version()?;
if args.verbose >= 1 && args.format == OutputFormat::Human && !the_plan.explanation.is_empty() {
print!("{}", the_plan.explanation.to_human());
println!();
let _ = std::io::stdout().flush();
}
let timeout = args
.timeout
.or(config.oneharness.timeout)
.unwrap_or(DEFAULT_TIMEOUT);
let oh_config = resolve_oneharness_config(&args, &config);
let oh_config_ref = oh_config.as_deref();
let global_model = config.oneharness.model.as_deref();
let max_parallel = args.max_parallel.unwrap_or(DEFAULT_MAX_PARALLEL).max(1);
let mut verdicts: BTreeMap<String, Vec<RuleVerdict>> = BTreeMap::new();
let mut run_errors: Vec<String> = Vec::new();
let want_trace = args.verbose >= 1;
let mut traces: Vec<(String, oneharness::RunTrace)> = Vec::new();
let ctx = crate::io::terminal::detect();
let show_progress = args.format == OutputFormat::Human
&& args
.progress
.resolve(ctx.stderr_tty, ctx.is_ci, ctx.is_agent, ctx.color_ok());
let mut view_rules: BTreeSet<String> = BTreeSet::new();
let mut expected: BTreeMap<String, usize> = BTreeMap::new();
for run in &the_plan.runs {
for rs in &run.rules {
view_rules.insert(rs.name.clone());
*expected.entry(rs.name.clone()).or_default() += 1;
}
}
view_rules.extend(the_plan.skipped.iter().map(|s| s.rule.clone()));
view_rules.extend(not_relevant.iter().cloned());
let view_rules: Vec<String> = view_rules.into_iter().collect();
let view = ProgressView::new(
progress_target(show_progress),
&view_rules,
the_plan.runs.len(),
show_progress,
);
for skip in &the_plan.skipped {
let status = match skip.reason {
SkipReason::NoFiles => LiveStatus::Skipped,
SkipReason::AllFilesIgnored => LiveStatus::Ignored,
};
view.finish_rule(&skip.rule, status);
}
for name in ¬_relevant {
view.finish_rule(name, LiveStatus::NotRelevant);
}
let client_ref = &client;
let cwd_ref = cwd.as_path();
let diffs_ref = &diffs;
let suppressions_ref = &suppressions;
for wave in the_plan.runs.chunks(max_parallel) {
for run in wave {
for rs in &run.rules {
view.set_running(&rs.name);
}
}
thread::scope(|s| {
let handles: Vec<_> = wave
.iter()
.map(|run| {
s.spawn(move || {
execute(
client_ref,
run,
cwd_ref,
timeout,
oh_config_ref,
global_model,
want_trace,
diffs_ref,
suppressions_ref,
)
})
})
.collect();
for (run, handle) in wave.iter().zip(handles) {
let (trace, result) = handle.join().expect("judge thread panicked");
if let Some(trace) = trace {
traces.push((judge_label(run), trace));
}
view.tick_run();
match result {
Ok(map) => {
for (name, verdict) in map {
verdicts.entry(name).or_default().push(verdict);
}
}
Err(e) => run_errors.push(format!("{}: {}", judge_label(run), e)),
}
for rs in &run.rules {
let remaining = expected.get_mut(rs.name.as_str());
if let Some(remaining) = remaining {
*remaining = remaining.saturating_sub(1);
if *remaining == 0 {
let status = match verdicts.get(rs.name.as_str()) {
Some(vs) if !vs.is_empty() => {
live_status(&vote::tally(&rs.name, vs))
}
_ => LiveStatus::Error,
};
view.finish_rule(&rs.name, status);
}
}
}
}
});
}
view.finish();
if want_trace {
print_traces(&traces);
}
let mut outcomes: Vec<RuleOutcome> = verdicts
.iter()
.map(|(name, vs)| vote::tally(name, vs))
.collect();
for o in &mut outcomes {
if rationale_off.contains(&o.name) {
o.rationale = None;
for j in &mut o.judges {
j.rationale = None;
}
}
}
for skip in &the_plan.skipped {
outcomes.push(match skip.reason {
SkipReason::NoFiles => RuleOutcome::skipped(&skip.rule),
SkipReason::AllFilesIgnored => RuleOutcome::ignored(&skip.rule),
});
}
for name in ¬_relevant {
outcomes.push(RuleOutcome::not_relevant(name));
}
if !require_attribution.is_empty() {
run_errors.extend(attribution::unlocalized_errors(
&outcomes,
&require_attribution,
));
}
let report = Report::new(outcomes, run_errors).with_plan(the_plan.explanation.clone());
Ok(finish(&report, &args, &cwd, &sources, command, &config))
}
fn finish(
report: &Report,
args: &LintArgs,
cwd: &Path,
sources: &[String],
command: &str,
config: &Config,
) -> i32 {
emit(report, args.format, args.verbose, args.color);
let code = report.exit_code();
log_history(report, args, cwd, sources, command, config, code);
code
}
fn log_history(
report: &Report,
args: &LintArgs,
cwd: &Path,
sources: &[String],
command: &str,
config: &Config,
exit_code: i32,
) {
let settings = history::resolve(config, args.no_history);
if !settings.enabled {
return;
}
let Some(dir) = &settings.dir else {
eprintln!(
"llmlint: warning: results logging is on but no history directory could be \
determined (set history.dir or LLMLINT_HISTORY_DIR)"
);
return;
};
let now = std::time::SystemTime::now();
let id = history::generate_id(now);
let timestamp = history::format_timestamp(now);
let record = history::build_record(&id, ×tamp, command, cwd, exit_code, sources, report);
match history::write_record(dir, &id, &record, settings.max_runs) {
Ok(_) => {
if args.format == OutputFormat::Human {
eprintln!("See full results with `llmlint history {id}`");
}
}
Err(e) => eprintln!("llmlint: warning: could not log run results: {e}"),
}
}
fn validate_filters(config: &Config, args: &LintArgs) -> Result<()> {
let mut problems: Vec<String> = Vec::new();
if !args.rule.is_empty() {
let known: HashSet<&str> = config.rules.iter().map(|r| r.name.as_str()).collect();
let mut unknown: Vec<&str> = args
.rule
.iter()
.map(String::as_str)
.filter(|n| !known.contains(n))
.collect();
if !unknown.is_empty() {
unknown.sort_unstable();
unknown.dedup();
let mut available: Vec<&str> = known.into_iter().collect();
available.sort_unstable();
problems.push(format!(
"no rule named {}; available rules: {}",
unknown.join(", "),
join_or_none(&available)
));
}
}
if let Some(agent) = &args.agent {
if agent != "default" && !config.agents.contains_key(agent) {
let mut available: Vec<&str> = config.agents.keys().map(String::as_str).collect();
if !available.contains(&"default") {
available.push("default");
}
available.sort_unstable();
problems.push(format!(
"no agent named {}; available agents: {}",
agent,
join_or_none(&available)
));
}
}
if problems.is_empty() {
Ok(())
} else {
Err(Error::UnknownFilter(problems.join("; ")))
}
}
fn join_or_none(names: &[&str]) -> String {
if names.is_empty() {
"(none)".to_string()
} else {
names.join(", ")
}
}
fn check_cli_files(
cwd: &Path,
cli_files: &[PathBuf],
args: &LintArgs,
config: &Config,
) -> Result<()> {
let mut unresolved = files::unresolved(cwd, cli_files);
if unresolved.is_empty() {
return Ok(());
}
if let Some(backend) = args.diff {
let absent: Vec<PathBuf> = unresolved
.iter()
.filter(|u| u.missing)
.map(|u| u.path.clone())
.collect();
if !absent.is_empty() {
let changed = diff::provider(backend, config.diff_base.clone()).diffs(cwd, &absent)?;
unresolved.retain(|u| !(u.missing && changed.contains_key(&u.path)));
}
}
if unresolved.is_empty() {
return Ok(());
}
Err(Error::Io(
unresolved
.into_iter()
.map(|u| u.message)
.collect::<Vec<_>>()
.join("\n"),
))
}
fn select_rules<'a>(config: &'a Config, args: &LintArgs) -> Vec<&'a Rule> {
let rule_filter: Option<HashSet<&str>> = if args.rule.is_empty() {
None
} else {
Some(args.rule.iter().map(String::as_str).collect())
};
config
.rules
.iter()
.filter(|r| {
let agent_ok = args.agent.as_deref().is_none_or(|a| {
r.agent.as_deref() == Some(a) || (a == "default" && r.agent.is_none())
});
let name_ok = rule_filter
.as_ref()
.is_none_or(|set| set.contains(r.name.as_str()));
agent_ok && name_ok
})
.collect()
}
fn apply_cli_overrides(config: &mut Config, args: &LintArgs) -> Result<()> {
if let Some(path) = &args.prompt_template {
let text = std::fs::read_to_string(path)
.map_err(|e| io_err(format!("reading prompt template {}", path.display()), e))?;
config.prompt_template = Some(text);
}
if let Some(model) = &args.model {
config.oneharness.model = Some(model.clone());
}
if let Some(n) = args.schema_max_retries {
config.oneharness.schema_max_retries = Some(n);
}
if let Some(b) = args.rationales() {
config.rationales = Some(b);
}
if args.diff_base.is_some() {
config.diff_base = args.diff_base.clone();
}
config.files.exclude.extend(args.exclude.iter().cloned());
Ok(())
}
fn restrict_to_changed(
resolved: &mut [plan::ResolvedRule],
diffs: &BTreeMap<PathBuf, String>,
cwd: &Path,
) {
for rule in resolved.iter_mut() {
rule.files
.retain(|f| diffs.contains_key(f) && cwd.join(f).exists());
}
}
fn resolve_oneharness_config(args: &LintArgs, config: &Config) -> Option<PathBuf> {
let mut all: Vec<PathBuf> = args.oneharness_config.clone();
all.extend(config.oneharness.config.iter().map(PathBuf::from));
if all.len() > 1 {
eprintln!(
"llmlint: warning: oneharness `--config` takes a single file; using {} and ignoring \
{} other(s)",
all[0].display(),
all.len() - 1
);
}
all.into_iter().next()
}
fn progress_target(show: bool) -> ProgressDrawTarget {
if show {
ProgressDrawTarget::stderr()
} else {
ProgressDrawTarget::hidden()
}
}
fn live_status(o: &RuleOutcome) -> LiveStatus {
match o.outcome {
Outcome::Pass => LiveStatus::Pass,
Outcome::Fail => LiveStatus::Fail,
Outcome::Skipped => LiveStatus::Skipped,
Outcome::Ignored => LiveStatus::Ignored,
Outcome::NotRelevant => LiveStatus::NotRelevant,
}
}
fn judge_label(run: &JudgeRun) -> String {
format!(
"agent {} judge {} [{}]",
run.agent,
run.judge_index,
run.rules
.iter()
.map(|r| r.name.as_str())
.collect::<Vec<_>>()
.join(", ")
)
}
fn print_traces(traces: &[(String, oneharness::RunTrace)]) {
for (label, trace) in traces {
eprintln!("\n# oneharness: {label}");
eprintln!("$ {}", trace.command);
if let Some(code) = trace.exit_code {
eprintln!("exit: {code}");
}
let stdout = trace.stdout.trim();
if !stdout.is_empty() {
eprintln!("result:\n{stdout}");
}
let stderr = trace.stderr.trim();
if !stderr.is_empty() {
eprintln!("stderr:\n{stderr}");
}
}
}
#[allow(clippy::too_many_arguments)]
fn execute(
client: &oneharness::Client,
run: &JudgeRun,
cwd: &Path,
timeout: u64,
oh_config: Option<&Path>,
global_model: Option<&str>,
want_trace: bool,
diffs: &BTreeMap<PathBuf, String>,
suppressions: &BTreeMap<String, Suppressions>,
) -> (
Option<oneharness::RunTrace>,
Result<BTreeMap<String, RuleVerdict>>,
) {
let files_str: Vec<String> = run.files.iter().map(|p| files::to_slash(p)).collect();
let file_diffs: Vec<template::FileDiff> = run
.files
.iter()
.filter_map(|p| {
let diff = diffs.get(p)?;
let slash = files::to_slash(p);
let applying: Vec<&str> = run
.rules
.iter()
.filter(|r| r.files.iter().any(|f| f == &slash))
.map(|r| r.name.as_str())
.collect();
let supp = suppressions.get(&slash);
let model = diffmodel::FileDiff::parse(diff);
let omit = |cr: &diffmodel::ChangeRun| {
if cr.added_lines.is_empty() || applying.is_empty() {
return false;
}
let Some(supp) = supp else { return false };
applying.iter().all(|rule| {
cr.added_lines
.iter()
.all(|&ln| supp.covers(rule, Some(ln as u64)))
})
};
Some(template::FileDiff {
file: slash,
diff: model.render_filtered(omit),
})
})
.collect();
let want_rationale = run.rules.iter().any(|r| r.rationale);
let want_relevance = run.rules.iter().any(|r| r.relevance.is_some());
let want_line_attribution = run.rules.iter().any(|r| r.require_line_attribution);
let system = match template::render(
&run.template,
&run.rules,
&files_str,
&file_diffs,
want_rationale,
want_relevance,
want_line_attribution,
) {
Ok(s) => s,
Err(e) => return (None, Err(e)),
};
let specs: Vec<schema::SchemaRule> = run
.rules
.iter()
.map(|r| schema::SchemaRule {
name: r.name.as_str(),
rationale: r.rationale,
relevance: r.relevance.is_some(),
require_line_attribution: r.require_line_attribution,
})
.collect();
let schema = schema::build(&specs);
let pairs: Vec<(String, Vec<String>)> = run
.rules
.iter()
.map(|r| (r.name.clone(), r.files.clone()))
.collect();
let scope = applicability::scope_map(&pairs);
let file_rules = applicability::per_file(&pairs, &files_str);
let mut prompt = PROMPT_TRIGGER.to_string();
let mut last_trace;
let mut verdicts;
let mut attempt = 0;
loop {
let req = oneharness::RunRequest {
harness: run.harness.as_deref(),
model: run.model.as_deref().or(global_model),
system: &system,
prompt: &prompt,
schema: &schema,
schema_max_retries: run.schema_max_retries,
cwd,
timeout_secs: timeout,
oneharness_config: oh_config,
no_config: false,
};
let (trace, result) = if want_trace {
let (t, r) = client.run_with_trace(&req);
(Some(t), r)
} else {
(None, client.run(&req))
};
last_trace = trace;
verdicts = match result {
Ok(v) => v,
Err(e) => return (last_trace, Err(e)),
};
let problems = applicability::scope_problems(&scope, &verdicts);
if problems.is_empty() || attempt >= MAX_REWORKS {
break;
}
prompt = applicability::rework_prompt(&problems, &file_rules);
attempt += 1;
}
let empty_scope = BTreeSet::new();
for (name, verdict) in verdicts.iter_mut() {
let rule_scope = scope.get(name.as_str()).unwrap_or(&empty_scope);
applicability::clean_verdict(verdict, rule_scope, |file, line| {
suppressions.get(file).is_some_and(|s| s.covers(name, line))
});
}
(last_trace, Ok(verdicts))
}
fn emit(report: &Report, format: OutputFormat, verbosity: u8, color: ColorChoice) {
match format {
OutputFormat::Human => {
let ctx = crate::io::terminal::detect();
let on = color.resolve(std::io::stdout().is_terminal(), ctx.no_color, ctx.is_agent);
let choice = if on {
anstream::ColorChoice::Always
} else {
anstream::ColorChoice::Never
};
let mut out = anstream::AutoStream::new(std::io::stdout().lock(), choice);
let _ = write!(out, "{}", report.to_human(verbosity, on));
let _ = out.flush();
}
OutputFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&report.to_json()).unwrap_or_default()
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::config::Agent;
fn rule(name: &str, agent: Option<&str>) -> Rule {
Rule {
name: name.into(),
description: "true when ok; false otherwise.".into(),
r#override: false,
agent: agent.map(Into::into),
judges: None,
files: None,
rationale: None,
relevance: None,
require_line_attribution: None,
}
}
fn config_with(rules: Vec<Rule>, agents: &[&str]) -> Config {
let mut c = Config {
rules,
..Default::default()
};
for a in agents {
c.agents.insert((*a).into(), Agent::default());
}
c
}
fn args(rules: &[&str], agent: Option<&str>) -> LintArgs {
LintArgs {
rule: rules.iter().map(|s| (*s).to_string()).collect(),
agent: agent.map(Into::into),
..Default::default()
}
}
#[test]
fn no_filters_is_ok() {
let cfg = config_with(vec![rule("a_rule", None)], &[]);
assert!(validate_filters(&cfg, &args(&[], None)).is_ok());
}
#[test]
fn progress_target_hidden_when_not_showing() {
assert!(progress_target(false).is_hidden());
let _ = progress_target(true);
}
#[test]
fn live_status_maps_every_outcome() {
let outcome = |o: Outcome| RuleOutcome {
name: "r".into(),
rationale: None,
outcome: o,
votes_total: 1,
votes_hold: 0,
judges: vec![],
violations: vec![],
};
assert!(matches!(
live_status(&outcome(Outcome::Pass)),
LiveStatus::Pass
));
assert!(matches!(
live_status(&outcome(Outcome::Fail)),
LiveStatus::Fail
));
assert!(matches!(
live_status(&outcome(Outcome::Skipped)),
LiveStatus::Skipped
));
assert!(matches!(
live_status(&outcome(Outcome::NotRelevant)),
LiveStatus::NotRelevant
));
}
#[test]
fn known_rule_and_agent_are_ok() {
let cfg = config_with(vec![rule("a_rule", Some("special"))], &["special"]);
assert!(validate_filters(&cfg, &args(&["a_rule"], Some("special"))).is_ok());
}
#[test]
fn default_agent_is_always_valid() {
let cfg = config_with(vec![rule("a_rule", None)], &[]);
assert!(validate_filters(&cfg, &args(&[], Some("default"))).is_ok());
}
#[test]
fn unknown_rule_lists_available_sorted() {
let cfg = config_with(vec![rule("beta", None), rule("alpha", None)], &[]);
let err = validate_filters(&cfg, &args(&["typo", "alpha"], None)).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("no rule named typo"), "got: {msg}");
assert!(msg.contains("available rules: alpha, beta"), "got: {msg}");
}
#[test]
fn unknown_agent_lists_available_with_default() {
let cfg = config_with(vec![rule("a_rule", Some("special"))], &["special"]);
let err = validate_filters(&cfg, &args(&[], Some("ghost"))).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("no agent named ghost"), "got: {msg}");
assert!(
msg.contains("available agents: default, special"),
"got: {msg}"
);
}
#[test]
fn unknown_rule_with_no_rules_says_none() {
let cfg = config_with(vec![], &[]);
let err = validate_filters(&cfg, &args(&["x"], None)).unwrap_err();
assert!(err.to_string().contains("available rules: (none)"));
}
#[test]
fn cli_overrides_win_over_config() {
let mut cfg = Config {
rationales: Some(true),
..Default::default()
};
cfg.oneharness.model = Some("config-model".into());
let args = LintArgs {
model: Some("cli-model".into()),
schema_max_retries: Some(5),
no_rationales: true,
..Default::default()
};
apply_cli_overrides(&mut cfg, &args).unwrap();
assert_eq!(cfg.oneharness.model.as_deref(), Some("cli-model"));
assert_eq!(cfg.oneharness.schema_max_retries, Some(5));
assert_eq!(cfg.rationales, Some(false));
assert!(!cfg.rationales_default());
}
#[test]
fn no_rationale_flags_leaves_config_untouched() {
let mut cfg = Config {
rationales: Some(false),
..Default::default()
};
apply_cli_overrides(&mut cfg, &LintArgs::default()).unwrap();
assert_eq!(cfg.rationales, Some(false));
}
#[test]
fn restrict_to_changed_keeps_only_changed_existing_files() {
use tempfile::tempdir;
let dir = tempdir().unwrap();
let cwd = dir.path();
std::fs::write(cwd.join("a.rs"), "fn a() {}\n").unwrap();
std::fs::write(cwd.join("b.rs"), "fn b() {}\n").unwrap();
let mut resolved = vec![plan::ResolvedRule {
name: "r".into(),
description: "d".into(),
judges: 1,
agent: "default".into(),
files: vec![
PathBuf::from("a.rs"),
PathBuf::from("b.rs"),
PathBuf::from("gone.rs"),
],
rationale: true,
relevance: None,
require_line_attribution: false,
}];
let mut diffs: BTreeMap<PathBuf, String> = BTreeMap::new();
diffs.insert(PathBuf::from("a.rs"), "diff a".into());
diffs.insert(PathBuf::from("gone.rs"), "diff gone".into());
restrict_to_changed(&mut resolved, &diffs, cwd);
assert_eq!(resolved[0].files, vec![PathBuf::from("a.rs")]);
}
#[test]
fn restrict_to_changed_empties_a_rule_with_no_changed_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.rs"), "fn a() {}\n").unwrap();
let mut resolved = vec![plan::ResolvedRule {
name: "r".into(),
description: "d".into(),
judges: 1,
agent: "default".into(),
files: vec![PathBuf::from("a.rs")],
rationale: true,
relevance: None,
require_line_attribution: false,
}];
restrict_to_changed(&mut resolved, &BTreeMap::new(), dir.path());
assert!(resolved[0].files.is_empty());
}
#[test]
fn both_unknown_rule_and_agent_are_reported() {
let cfg = config_with(vec![rule("a_rule", None)], &[]);
let err = validate_filters(&cfg, &args(&["nope"], Some("ghost"))).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("no rule named nope"), "got: {msg}");
assert!(msg.contains("no agent named ghost"), "got: {msg}");
}
}