use std::collections::{BTreeMap, BTreeSet};
use serde::Serialize;
use crate::domain::verdict::RuleVerdict;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Mode {
Include,
Exclude,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct FileRules {
pub file: String,
pub mode: Mode,
pub rules: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScopeProblem {
pub rule: String,
pub file: String,
}
pub fn norm(p: &str) -> String {
p.trim()
.replace('\\', "/")
.trim_start_matches("./")
.to_string()
}
fn joined_len(names: &[String]) -> usize {
if names.is_empty() {
0
} else {
names.iter().map(String::len).sum::<usize>() + 2 * (names.len() - 1)
}
}
pub fn per_file(rules: &[(String, Vec<String>)], files: &[String]) -> Vec<FileRules> {
files
.iter()
.map(|f| {
let target = norm(f);
let mut applies = Vec::new();
let mut excluded = Vec::new();
for (name, rule_files) in rules {
if rule_files.iter().any(|rf| norm(rf) == target) {
applies.push(name.clone());
} else {
excluded.push(name.clone());
}
}
if joined_len(&applies) <= joined_len(&excluded) {
FileRules {
file: f.clone(),
mode: Mode::Include,
rules: applies,
}
} else {
FileRules {
file: f.clone(),
mode: Mode::Exclude,
rules: excluded,
}
}
})
.collect()
}
pub fn scope_map(rules: &[(String, Vec<String>)]) -> BTreeMap<String, BTreeSet<String>> {
rules
.iter()
.map(|(name, files)| (name.clone(), files.iter().map(|f| norm(f)).collect()))
.collect()
}
pub fn scope_problems(
scope: &BTreeMap<String, BTreeSet<String>>,
verdicts: &BTreeMap<String, RuleVerdict>,
) -> Vec<ScopeProblem> {
let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
for (rule, verdict) in verdicts {
let Some(files) = scope.get(rule) else {
continue;
};
for v in &verdict.violations {
if let Some(file) = &v.file {
let nf = norm(file);
if !files.contains(&nf) {
seen.insert((rule.clone(), nf));
}
}
}
}
seen.into_iter()
.map(|(rule, file)| ScopeProblem { rule, file })
.collect()
}
pub fn clean_verdict(
verdict: &mut RuleVerdict,
scope: &BTreeSet<String>,
is_suppressed: impl Fn(&str, Option<u64>) -> bool,
) -> bool {
let had_violations = !verdict.violations.is_empty();
let mut dropped_out_of_scope = false;
let mut dropped_any = false;
verdict.violations.retain(|v| {
let Some(file) = &v.file else {
return true;
};
let nf = norm(file);
let in_scope = scope.contains(&nf);
let suppressed = is_suppressed(&nf, v.line);
let keep = in_scope && !suppressed;
if !keep {
dropped_any = true;
if !in_scope {
dropped_out_of_scope = true;
}
}
keep
});
if !verdict.holds
&& verdict.is_relevant()
&& had_violations
&& verdict.violations.is_empty()
&& dropped_any
{
verdict.holds = true;
}
dropped_out_of_scope
}
pub fn file_rule_line(fr: &FileRules) -> String {
let names = fr.rules.join(", ");
match fr.mode {
Mode::Include if fr.rules.is_empty() => format!("- {} — no rules apply", fr.file),
Mode::Include => format!("- {} — only these rules apply: {names}", fr.file),
Mode::Exclude if fr.rules.is_empty() => format!("- {} — all rules apply", fr.file),
Mode::Exclude => format!("- {} — all rules apply except: {names}", fr.file),
}
}
pub fn rework_prompt(problems: &[ScopeProblem], file_rules: &[FileRules]) -> String {
let mut out = String::new();
out.push_str(
"Your previous verdict reported rule violations in files that those rules do not \
cover. A rule must be evaluated ONLY against the files it applies to.\n\n\
Wrong-file violations to remove:\n",
);
for p in problems {
out.push_str(&format!(
"- rule `{}` reported a violation in `{}`, but `{}` does not apply to `{}`\n",
p.rule, p.file, p.rule, p.file
));
}
out.push_str("\nThe rules that apply to each target file are:\n");
for fr in file_rules {
out.push_str(&file_rule_line(fr));
out.push('\n');
}
out.push_str(
"\nRe-review every file with the correct scope and respond again with the full \
structured verdict object. Do not report a violation for a rule in a file it does \
not apply to. Respond with only the JSON object.",
);
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::verdict::Violation;
fn rules(pairs: &[(&str, &[&str])]) -> Vec<(String, Vec<String>)> {
pairs
.iter()
.map(|(n, fs)| (n.to_string(), fs.iter().map(|s| s.to_string()).collect()))
.collect()
}
fn viol(file: Option<&str>, line: Option<u64>) -> Violation {
Violation {
file: file.map(Into::into),
line,
end_line: None,
message: Some("x".into()),
}
}
#[test]
fn per_file_picks_the_shorter_apply_or_skip_list() {
let rs = rules(&[("a", &["f1", "f2"]), ("b", &["f2"]), ("c", &["f2"])]);
let out = per_file(&rs, &["f1".into(), "f2".into()]);
assert_eq!(out[0].mode, Mode::Include);
assert_eq!(out[0].rules, vec!["a"]);
assert_eq!(out[1].mode, Mode::Exclude);
assert!(out[1].rules.is_empty());
}
#[test]
fn per_file_uses_exclude_when_most_rules_apply() {
let rs = rules(&[("a", &["other"]), ("bbbbb", &["f"]), ("ccccc", &["f"])]);
let out = per_file(&rs, &["f".into()]);
assert_eq!(out[0].mode, Mode::Exclude);
assert_eq!(out[0].rules, vec!["a"]);
}
#[test]
fn per_file_normalizes_paths_for_matching() {
let rs = rules(&[("a", &["src/x.rs"]), ("b", &["other.rs"])]);
let out = per_file(&rs, &["./src/x.rs".into()]);
assert_eq!(out[0].mode, Mode::Include);
assert_eq!(out[0].rules, vec!["a"]);
}
#[test]
fn scope_problems_flag_out_of_scope_file_violations_only() {
let scope = scope_map(&rules(&[("a", &["src/x.rs"]), ("b", &["docs/y.md"])]));
let mut verdicts = BTreeMap::new();
verdicts.insert(
"a".to_string(),
RuleVerdict {
holds: false,
violations: vec![
viol(Some("docs/y.md"), Some(3)),
viol(Some("src/x.rs"), None),
],
..Default::default()
},
);
verdicts.insert(
"b".to_string(),
RuleVerdict {
holds: false,
violations: vec![viol(None, None)],
..Default::default()
},
);
let problems = scope_problems(&scope, &verdicts);
assert_eq!(
problems,
vec![ScopeProblem {
rule: "a".into(),
file: "docs/y.md".into()
}]
);
}
#[test]
fn clean_drops_out_of_scope_and_flips_a_fail_with_no_remaining_basis() {
let scope: BTreeSet<String> = ["src/x.rs".to_string()].into_iter().collect();
let mut v = RuleVerdict {
holds: false,
violations: vec![viol(Some("elsewhere.rs"), Some(2))],
..Default::default()
};
let dropped = clean_verdict(&mut v, &scope, |_, _| false);
assert!(dropped);
assert!(v.violations.is_empty());
assert!(
v.holds,
"a fail whose only basis was out of scope flips to pass"
);
}
#[test]
fn clean_keeps_a_fail_with_an_in_scope_violation() {
let scope: BTreeSet<String> = ["src/x.rs".to_string()].into_iter().collect();
let mut v = RuleVerdict {
holds: false,
violations: vec![
viol(Some("src/x.rs"), Some(2)),
viol(Some("oops.rs"), Some(9)),
],
..Default::default()
};
let dropped = clean_verdict(&mut v, &scope, |_, _| false);
assert!(dropped);
assert_eq!(v.violations.len(), 1);
assert!(!v.holds, "an in-scope violation keeps the fail");
}
#[test]
fn clean_drops_a_suppressed_violation_without_calling_it_out_of_scope() {
let scope: BTreeSet<String> = ["src/x.rs".to_string()].into_iter().collect();
let mut v = RuleVerdict {
holds: false,
violations: vec![viol(Some("src/x.rs"), Some(7))],
..Default::default()
};
let dropped = clean_verdict(&mut v, &scope, |f, l| f == "src/x.rs" && l == Some(7));
assert!(
!dropped,
"a suppressed in-scope drop is not an out-of-scope problem"
);
assert!(v.violations.is_empty());
assert!(
v.holds,
"suppressing the only violation flips the fail to pass"
);
}
#[test]
fn clean_does_not_flip_a_pass_or_a_fail_with_a_fileless_violation() {
let scope: BTreeSet<String> = ["src/x.rs".to_string()].into_iter().collect();
let mut v = RuleVerdict {
holds: false,
violations: vec![viol(None, None), viol(Some("oops.rs"), Some(1))],
..Default::default()
};
clean_verdict(&mut v, &scope, |_, _| false);
assert_eq!(v.violations.len(), 1);
assert!(!v.holds);
}
#[test]
fn rework_prompt_names_the_problems_and_the_scope() {
let fr = per_file(
&rules(&[("a", &["src/x.rs"]), ("b", &["other.rs"])]),
&["src/x.rs".into()],
);
let problems = vec![ScopeProblem {
rule: "a".into(),
file: "docs/y.md".into(),
}];
let p = rework_prompt(&problems, &fr);
assert!(p.contains("`a` reported a violation in `docs/y.md`"));
assert!(
p.contains("src/x.rs — only these rules apply: a"),
"prompt:\n{p}"
);
assert!(p.contains("only the JSON object"));
}
}