lean_ctx/core/
pre_edit.rs1use std::path::Path;
16
17#[derive(Debug, Clone, Copy)]
19pub struct PreEditInput<'a> {
20 pub path: &'a Path,
22 pub before: Option<&'a str>,
23 pub after: &'a str,
24 pub project_root: Option<&'a str>,
27 pub agent: Option<&'a str>,
29}
30
31impl<'a> PreEditInput<'a> {
32 pub fn new(path: &'a Path, before: Option<&'a str>, after: &'a str) -> Self {
34 Self {
35 path,
36 before,
37 after,
38 project_root: None,
39 agent: None,
40 }
41 }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
46pub enum Severity {
47 Note,
49 Hint,
51 Refuse,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct PreEditFinding {
58 pub source: &'static str,
60 pub id: String,
62 pub severity: Severity,
63 pub text: String,
65}
66
67pub type Checker = fn(&PreEditInput<'_>) -> Vec<PreEditFinding>;
71
72const CHECKERS: &[Checker] = &[
74 claims_findings,
75 crate::core::decisions::check::pre_edit_findings,
76];
77
78fn claims_findings(input: &PreEditInput<'_>) -> Vec<PreEditFinding> {
81 use crate::core::unit_claims::{self, Gate};
82 let (Some(root), Some(agent)) = (input.project_root, input.agent) else {
83 return Vec::new();
84 };
85 let gate = unit_claims::gate_with(
86 unit_claims::mode(),
87 root,
88 agent,
89 input.path,
90 input.before.unwrap_or_default(),
91 input.after,
92 );
93 let (severity, text) = match gate {
94 Gate::Clear => return Vec::new(),
95 Gate::Warn(text) => (Severity::Hint, text),
96 Gate::Refuse(text) => (Severity::Refuse, text),
97 };
98 vec![PreEditFinding {
99 source: "claim",
100 id: input.path.to_string_lossy().into_owned(),
101 severity,
102 text: text.trim().trim_start_matches("ERROR:").trim().to_string(),
103 }]
104}
105
106pub fn evaluate(input: &PreEditInput<'_>) -> Vec<PreEditFinding> {
108 evaluate_with(input, CHECKERS)
109}
110
111pub fn evaluate_with(input: &PreEditInput<'_>, checkers: &[Checker]) -> Vec<PreEditFinding> {
113 let mut findings: Vec<PreEditFinding> = checkers.iter().flat_map(|c| c(input)).collect();
114 findings.sort_by(|a, b| {
115 (b.severity, a.source, &a.id, &a.text).cmp(&(a.severity, b.source, &b.id, &b.text))
116 });
117 findings.dedup();
118 findings
119}
120
121pub const HINT_HEADER: &str = "[PRE-EDIT]";
123
124pub fn refusal(findings: &[PreEditFinding]) -> Option<String> {
126 let refused: Vec<&PreEditFinding> = findings
127 .iter()
128 .filter(|f| f.severity == Severity::Refuse)
129 .collect();
130 if refused.is_empty() {
131 return None;
132 }
133 let mut out = String::from(
134 "ERROR: pre-edit check refused this edit (nothing was written). \
135 Change the edit (or settle the claim / supersede the decision):",
136 );
137 for f in refused {
138 out.push_str("\n- ");
139 out.push_str(&f.text);
140 }
141 Some(out)
142}
143
144pub fn render(findings: &[PreEditFinding], min: Severity) -> Option<String> {
146 let shown: Vec<&PreEditFinding> = findings.iter().filter(|f| f.severity >= min).collect();
147 if shown.is_empty() {
148 return None;
149 }
150 let mut out = String::from(HINT_HEADER);
151 for f in shown {
152 out.push_str("\n- ");
153 out.push_str(&f.text);
154 }
155 Some(out)
156}