1use std::path::Path;
21
22use super::{Check, CheckKind, Decision, DecisionSet};
23use crate::core::pre_edit::{PreEditFinding, PreEditInput, Severity};
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Violation {
28 pub decision_id: String,
29 pub title: String,
30 pub statement: String,
31 pub check_index: usize,
32 pub kind: CheckKind,
33 pub path: String,
35 pub message: String,
37 pub line: Option<usize>,
39}
40
41impl Violation {
42 pub fn render(&self) -> String {
44 let at = self
45 .line
46 .map_or_else(|| self.path.clone(), |l| format!("{}:{l}", self.path));
47 format!(
48 "{} ({}) at {at} [{}]: {}",
49 self.decision_id,
50 self.title.trim(),
51 self.kind.as_str(),
52 self.message
53 )
54 }
55}
56
57pub type OtherFiles<'a> = &'a dyn Fn(&Decision, &Check, &str) -> bool;
62
63const MAX_SCAN_FILES: usize = 5_000;
65const MAX_SCAN_BYTES: u64 = 1_000_000;
66
67pub fn worktree_other_files(root: &Path, d: &Decision, check: &Check, exclude: &str) -> bool {
69 let Some(re) = check.regex() else {
70 return true;
71 };
72 let mut seen = 0usize;
73 for entry in ignore::WalkBuilder::new(root)
74 .hidden(false)
75 .build()
76 .flatten()
77 {
78 if !entry.file_type().is_some_and(|t| t.is_file()) {
79 continue;
80 }
81 let Some(rel) = super::relative_path(root, entry.path()) else {
82 continue;
83 };
84 if rel == exclude || rel.starts_with(".git/") || !check.covers(d, &rel) {
85 continue;
86 }
87 seen += 1;
88 if seen > MAX_SCAN_FILES || entry.metadata().is_ok_and(|m| m.len() > MAX_SCAN_BYTES) {
89 return true;
90 }
91 if std::fs::read_to_string(entry.path()).is_ok_and(|t| re.is_match(&t)) {
92 return true;
93 }
94 }
95 false
96}
97
98fn line_of(text: &str, offset: usize) -> usize {
99 text[..offset.min(text.len())].matches('\n').count() + 1
100}
101
102fn first_new_match(re: ®ex::Regex, before: &str, after: &str) -> Option<usize> {
105 let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
106 for m in re.find_iter(before) {
107 *seen.entry(m.as_str()).or_default() += 1;
108 }
109 let mut first = None;
110 for m in re.find_iter(after) {
111 first.get_or_insert(m.start());
112 match seen.get_mut(m.as_str()) {
113 Some(n) if *n > 0 => *n -= 1,
114 _ => return Some(m.start()),
115 }
116 }
117 first
118}
119
120fn violation(d: &Decision, i: usize, rel_path: &str, line: Option<usize>) -> Violation {
121 let check = &d.checks[i];
122 Violation {
123 decision_id: d.id.clone(),
124 title: d.title.clone(),
125 statement: d.one_line(),
126 check_index: i,
127 kind: check.kind,
128 path: rel_path.to_string(),
129 message: check.message.clone().unwrap_or_else(|| d.one_line()),
130 line,
131 }
132}
133
134pub fn evaluate_change(
139 set: &DecisionSet,
140 rel_path: &str,
141 before: Option<&str>,
142 after: Option<&str>,
143) -> Vec<Violation> {
144 let other = |d: &Decision, c: &Check, exclude: &str| {
145 set.root
146 .as_deref()
147 .is_none_or(|root| worktree_other_files(root, d, c, exclude))
148 };
149 evaluate_change_with(set, rel_path, before, after, &other)
150}
151
152pub fn evaluate_change_with(
154 set: &DecisionSet,
155 rel_path: &str,
156 before: Option<&str>,
157 after: Option<&str>,
158 other_files: OtherFiles<'_>,
159) -> Vec<Violation> {
160 let Some(after) = after else {
161 return Vec::new();
162 };
163 if before == Some(after) {
164 return Vec::new();
165 }
166 let mut out = Vec::new();
167 for d in set.active().filter(|d| set.repo_matches(d)) {
168 for (i, check) in d.checks.iter().enumerate() {
169 if !check.covers(d, rel_path) {
170 continue;
171 }
172 match check.kind {
173 CheckKind::ForbidPath => {
174 if before.is_none() {
175 out.push(violation(d, i, rel_path, None));
176 }
177 }
178 CheckKind::ForbidRegex => {
179 let Some(re) = check.regex() else { continue };
180 let before_count = before.map_or(0, |b| re.find_iter(b).count());
181 if re.find_iter(after).count() > before_count {
182 let line = first_new_match(re, before.unwrap_or_default(), after)
183 .map(|off| line_of(after, off));
184 out.push(violation(d, i, rel_path, line));
185 }
186 }
187 CheckKind::RequireRegex => {
188 let Some(re) = check.regex() else { continue };
189 if !re.is_match(after)
190 && before.is_some_and(|b| re.is_match(b))
191 && !other_files(d, check, rel_path)
192 {
193 out.push(violation(d, i, rel_path, None));
194 }
195 }
196 }
197 }
198 }
199 out
200}
201
202pub fn findings_for(
208 set: &DecisionSet,
209 rel_path: &str,
210 input: &PreEditInput<'_>,
211 enforce: &str,
212) -> Vec<PreEditFinding> {
213 if enforce == "off" {
214 return Vec::new();
215 }
216 let severity = if enforce == "refuse" {
217 Severity::Refuse
218 } else {
219 Severity::Hint
220 };
221 let violations = evaluate_change(set, rel_path, input.before, Some(input.after));
222 let mut out: Vec<PreEditFinding> = violations
223 .iter()
224 .map(|v| PreEditFinding {
225 source: "decision",
226 id: v.decision_id.clone(),
227 severity,
228 text: format!("{} — decision: {}", v.render(), v.statement),
229 })
230 .collect();
231 let scoped: Vec<&str> = set
232 .for_path(rel_path)
233 .into_iter()
234 .filter(|d| !d.is_repo_wide() && !violations.iter().any(|v| v.decision_id == d.id))
235 .map(|d| d.id.as_str())
236 .collect();
237 if !scoped.is_empty() {
238 out.push(PreEditFinding {
239 source: "decision",
240 id: scoped.join(","),
241 severity: Severity::Note,
242 text: format!(
243 "decisions in scope of {rel_path}: {} (ctx_knowledge action=decisions path={rel_path})",
244 scoped.join(", ")
245 ),
246 });
247 }
248 out
249}
250
251pub fn pre_edit_findings(input: &PreEditInput<'_>) -> Vec<PreEditFinding> {
256 let Some(root) = super::find_root(input.path) else {
257 return Vec::new();
258 };
259 let Some(rel) = super::relative_path(&root, input.path) else {
260 return Vec::new();
261 };
262 let set = super::load(&root);
263 if set.decisions.is_empty() {
264 return Vec::new();
265 }
266 let cfg = crate::core::config::Config::load();
267 findings_for(&set, &rel, input, cfg.decisions.enforce_mode())
268}