use std::path::Path;
use super::{Check, CheckKind, Decision, DecisionSet};
use crate::core::pre_edit::{PreEditFinding, PreEditInput, Severity};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Violation {
pub decision_id: String,
pub title: String,
pub statement: String,
pub check_index: usize,
pub kind: CheckKind,
pub path: String,
pub message: String,
pub line: Option<usize>,
}
impl Violation {
pub fn render(&self) -> String {
let at = self
.line
.map_or_else(|| self.path.clone(), |l| format!("{}:{l}", self.path));
format!(
"{} ({}) at {at} [{}]: {}",
self.decision_id,
self.title.trim(),
self.kind.as_str(),
self.message
)
}
}
pub type OtherFiles<'a> = &'a dyn Fn(&Decision, &Check, &str) -> bool;
const MAX_SCAN_FILES: usize = 5_000;
const MAX_SCAN_BYTES: u64 = 1_000_000;
pub fn worktree_other_files(root: &Path, d: &Decision, check: &Check, exclude: &str) -> bool {
let Some(re) = check.regex() else {
return true;
};
let mut seen = 0usize;
for entry in ignore::WalkBuilder::new(root)
.hidden(false)
.build()
.flatten()
{
if !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let Some(rel) = super::relative_path(root, entry.path()) else {
continue;
};
if rel == exclude || rel.starts_with(".git/") || !check.covers(d, &rel) {
continue;
}
seen += 1;
if seen > MAX_SCAN_FILES || entry.metadata().is_ok_and(|m| m.len() > MAX_SCAN_BYTES) {
return true;
}
if std::fs::read_to_string(entry.path()).is_ok_and(|t| re.is_match(&t)) {
return true;
}
}
false
}
fn line_of(text: &str, offset: usize) -> usize {
text[..offset.min(text.len())].matches('\n').count() + 1
}
fn first_new_match(re: ®ex::Regex, before: &str, after: &str) -> Option<usize> {
let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for m in re.find_iter(before) {
*seen.entry(m.as_str()).or_default() += 1;
}
let mut first = None;
for m in re.find_iter(after) {
first.get_or_insert(m.start());
match seen.get_mut(m.as_str()) {
Some(n) if *n > 0 => *n -= 1,
_ => return Some(m.start()),
}
}
first
}
fn violation(d: &Decision, i: usize, rel_path: &str, line: Option<usize>) -> Violation {
let check = &d.checks[i];
Violation {
decision_id: d.id.clone(),
title: d.title.clone(),
statement: d.one_line(),
check_index: i,
kind: check.kind,
path: rel_path.to_string(),
message: check.message.clone().unwrap_or_else(|| d.one_line()),
line,
}
}
pub fn evaluate_change(
set: &DecisionSet,
rel_path: &str,
before: Option<&str>,
after: Option<&str>,
) -> Vec<Violation> {
let other = |d: &Decision, c: &Check, exclude: &str| {
set.root
.as_deref()
.is_none_or(|root| worktree_other_files(root, d, c, exclude))
};
evaluate_change_with(set, rel_path, before, after, &other)
}
pub fn evaluate_change_with(
set: &DecisionSet,
rel_path: &str,
before: Option<&str>,
after: Option<&str>,
other_files: OtherFiles<'_>,
) -> Vec<Violation> {
let Some(after) = after else {
return Vec::new();
};
if before == Some(after) {
return Vec::new();
}
let mut out = Vec::new();
for d in set.active().filter(|d| set.repo_matches(d)) {
for (i, check) in d.checks.iter().enumerate() {
if !check.covers(d, rel_path) {
continue;
}
match check.kind {
CheckKind::ForbidPath => {
if before.is_none() {
out.push(violation(d, i, rel_path, None));
}
}
CheckKind::ForbidRegex => {
let Some(re) = check.regex() else { continue };
let before_count = before.map_or(0, |b| re.find_iter(b).count());
if re.find_iter(after).count() > before_count {
let line = first_new_match(re, before.unwrap_or_default(), after)
.map(|off| line_of(after, off));
out.push(violation(d, i, rel_path, line));
}
}
CheckKind::RequireRegex => {
let Some(re) = check.regex() else { continue };
if !re.is_match(after)
&& before.is_some_and(|b| re.is_match(b))
&& !other_files(d, check, rel_path)
{
out.push(violation(d, i, rel_path, None));
}
}
}
}
}
out
}
pub fn findings_for(
set: &DecisionSet,
rel_path: &str,
input: &PreEditInput<'_>,
enforce: &str,
) -> Vec<PreEditFinding> {
if enforce == "off" {
return Vec::new();
}
let severity = if enforce == "refuse" {
Severity::Refuse
} else {
Severity::Hint
};
let violations = evaluate_change(set, rel_path, input.before, Some(input.after));
let mut out: Vec<PreEditFinding> = violations
.iter()
.map(|v| PreEditFinding {
source: "decision",
id: v.decision_id.clone(),
severity,
text: format!("{} — decision: {}", v.render(), v.statement),
})
.collect();
let scoped: Vec<&str> = set
.for_path(rel_path)
.into_iter()
.filter(|d| !d.is_repo_wide() && !violations.iter().any(|v| v.decision_id == d.id))
.map(|d| d.id.as_str())
.collect();
if !scoped.is_empty() {
out.push(PreEditFinding {
source: "decision",
id: scoped.join(","),
severity: Severity::Note,
text: format!(
"decisions in scope of {rel_path}: {} (ctx_knowledge action=decisions path={rel_path})",
scoped.join(", ")
),
});
}
out
}
pub fn pre_edit_findings(input: &PreEditInput<'_>) -> Vec<PreEditFinding> {
let Some(root) = super::find_root(input.path) else {
return Vec::new();
};
let Some(rel) = super::relative_path(&root, input.path) else {
return Vec::new();
};
let set = super::load(&root);
if set.decisions.is_empty() {
return Vec::new();
}
let cfg = crate::core::config::Config::load();
findings_for(&set, &rel, input, cfg.decisions.enforce_mode())
}