lean_ctx/tools/ctx_patch/
anchors.rs1use serde_json::{Map, Value};
10
11#[derive(Clone, Debug, PartialEq, Eq)]
14pub enum AnchorOp {
15 SetLine {
17 line: usize,
18 hash: String,
19 new_text: String,
20 },
21 ReplaceLines {
23 start_line: usize,
24 start_hash: String,
25 end_line: usize,
26 end_hash: String,
27 new_text: String,
28 },
29 InsertAfter {
31 line: usize,
32 hash: Option<String>,
33 new_text: String,
34 },
35 Delete {
38 start_line: usize,
39 start_hash: String,
40 end_line: usize,
41 end_hash: String,
42 },
43}
44
45#[derive(Clone, Debug, PartialEq, Eq)]
48pub(crate) struct AnchorMiss {
49 pub line: usize,
51 pub expected: String,
53 pub actual: String,
55}
56
57pub(crate) fn parse_ops(args: &Map<String, Value>) -> Result<Vec<AnchorOp>, String> {
63 if let Some(ops) = args.get("ops") {
64 let arr = ops
65 .as_array()
66 .ok_or_else(|| "ops must be an array of edit objects".to_string())?;
67 if arr.is_empty() {
68 return Err("ops[] is empty — provide at least one edit".to_string());
69 }
70 return arr
71 .iter()
72 .enumerate()
73 .map(|(i, v)| {
74 let obj = v
75 .as_object()
76 .ok_or_else(|| format!("ops[{i}] must be an object"))?;
77 parse_one(obj).map_err(|e| format!("ops[{i}]: {e}"))
78 })
79 .collect();
80 }
81 Ok(vec![parse_one(args)?])
82}
83
84fn parse_one(obj: &Map<String, Value>) -> Result<AnchorOp, String> {
85 let op = get_str(obj, "op").ok_or_else(|| {
86 "missing 'op' (one of: set_line, replace_lines, insert_after, delete)".to_string()
87 })?;
88 match op.as_str() {
89 "set_line" => Ok(AnchorOp::SetLine {
90 line: req_line(obj, "line")?,
91 hash: req_str(obj, "hash")?,
92 new_text: req_new_text(obj)?,
93 }),
94 "replace_lines" => Ok(AnchorOp::ReplaceLines {
95 start_line: req_line(obj, "start_line")?,
96 start_hash: req_str(obj, "start_hash")?,
97 end_line: req_line(obj, "end_line")?,
98 end_hash: req_str(obj, "end_hash")?,
99 new_text: req_new_text(obj)?,
100 }),
101 "insert_after" => {
102 let line = req_line_allow_zero(obj, "line")?;
104 let hash = if line == 0 {
105 None
106 } else {
107 Some(req_str(obj, "hash")?)
108 };
109 Ok(AnchorOp::InsertAfter {
110 line,
111 hash,
112 new_text: req_new_text(obj)?,
113 })
114 }
115 "delete" => {
116 if obj.contains_key("start_line") || obj.contains_key("end_line") {
118 Ok(AnchorOp::Delete {
119 start_line: req_line(obj, "start_line")?,
120 start_hash: req_str(obj, "start_hash")?,
121 end_line: req_line(obj, "end_line")?,
122 end_hash: req_str(obj, "end_hash")?,
123 })
124 } else {
125 let line = req_line(obj, "line")?;
126 let hash = req_str(obj, "hash")?;
127 Ok(AnchorOp::Delete {
128 start_line: line,
129 start_hash: hash.clone(),
130 end_line: line,
131 end_hash: hash,
132 })
133 }
134 }
135 "replace_symbol" => Err(
136 "replace_symbol cannot be batched in ops[] — it is a different (symbol \
137 resolution) write path; send it as a single top-level op"
138 .to_string(),
139 ),
140 other => Err(format!(
141 "unknown op '{other}' (one of: set_line, replace_lines, insert_after, delete, replace_symbol)"
142 )),
143 }
144}
145
146fn get_str(obj: &Map<String, Value>, key: &str) -> Option<String> {
147 obj.get(key).and_then(|v| v.as_str()).map(String::from)
148}
149
150fn req_str(obj: &Map<String, Value>, key: &str) -> Result<String, String> {
151 get_str(obj, key).ok_or_else(|| format!("missing '{key}'"))
152}
153
154fn req_new_text(obj: &Map<String, Value>) -> Result<String, String> {
156 obj.get("new_text")
157 .and_then(|v| v.as_str())
158 .map(String::from)
159 .ok_or_else(|| "missing 'new_text' (use \"\" to delete)".to_string())
160}
161
162fn req_line(obj: &Map<String, Value>, key: &str) -> Result<usize, String> {
164 let n = req_line_allow_zero(obj, key)?;
165 if n == 0 {
166 return Err(format!("'{key}' must be ≥ 1 (lines are 1-based)"));
167 }
168 Ok(n)
169}
170
171fn req_line_allow_zero(obj: &Map<String, Value>, key: &str) -> Result<usize, String> {
173 let v = obj
174 .get(key)
175 .ok_or_else(|| format!("missing '{key}'"))?
176 .as_u64()
177 .ok_or_else(|| format!("'{key}' must be a non-negative integer"))?;
178 usize::try_from(v).map_err(|_| format!("'{key}' is out of range"))
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use serde_json::json;
185
186 fn obj(v: Value) -> Map<String, Value> {
187 match v {
188 Value::Object(m) => m,
189 _ => panic!("expected a JSON object"),
190 }
191 }
192
193 #[test]
194 fn parses_single_set_line() {
195 let ops = parse_ops(&obj(
196 json!({"op": "set_line", "line": 3, "hash": "ab12", "new_text": "x"}),
197 ))
198 .unwrap();
199 assert_eq!(
200 ops,
201 vec![AnchorOp::SetLine {
202 line: 3,
203 hash: "ab12".into(),
204 new_text: "x".into()
205 }]
206 );
207 }
208
209 #[test]
210 fn empty_new_text_is_allowed_for_delete() {
211 let ops = parse_ops(&obj(
212 json!({"op": "set_line", "line": 1, "hash": "aa", "new_text": ""}),
213 ))
214 .unwrap();
215 assert!(matches!(&ops[0], AnchorOp::SetLine { new_text, .. } if new_text.is_empty()));
216 }
217
218 #[test]
219 fn insert_after_top_needs_no_hash() {
220 let ops = parse_ops(&obj(
221 json!({"op": "insert_after", "line": 0, "new_text": "// header"}),
222 ))
223 .unwrap();
224 assert_eq!(
225 ops,
226 vec![AnchorOp::InsertAfter {
227 line: 0,
228 hash: None,
229 new_text: "// header".into()
230 }]
231 );
232 }
233
234 #[test]
235 fn insert_after_nonzero_requires_hash() {
236 let err = parse_ops(&obj(
237 json!({"op": "insert_after", "line": 5, "new_text": "x"}),
238 ))
239 .unwrap_err();
240 assert!(err.contains("hash"), "got: {err}");
241 }
242
243 #[test]
244 fn delete_single_and_range() {
245 let single = parse_ops(&obj(json!({"op": "delete", "line": 4, "hash": "cc"}))).unwrap();
246 assert_eq!(
247 single[0],
248 AnchorOp::Delete {
249 start_line: 4,
250 start_hash: "cc".into(),
251 end_line: 4,
252 end_hash: "cc".into()
253 }
254 );
255 let range = parse_ops(&obj(json!({
256 "op": "delete", "start_line": 2, "start_hash": "aa", "end_line": 5, "end_hash": "bb"
257 })))
258 .unwrap();
259 assert_eq!(
260 range[0],
261 AnchorOp::Delete {
262 start_line: 2,
263 start_hash: "aa".into(),
264 end_line: 5,
265 end_hash: "bb".into()
266 }
267 );
268 }
269
270 #[test]
271 fn parses_batch_ops() {
272 let ops = parse_ops(&obj(json!({
273 "ops": [
274 {"op": "set_line", "line": 1, "hash": "aa", "new_text": "A"},
275 {"op": "insert_after", "line": 3, "hash": "bb", "new_text": "B"}
276 ]
277 })))
278 .unwrap();
279 assert_eq!(ops.len(), 2);
280 }
281
282 #[test]
283 fn empty_ops_array_is_rejected() {
284 let err = parse_ops(&obj(json!({"ops": []}))).unwrap_err();
285 assert!(err.contains("empty"), "got: {err}");
286 }
287
288 #[test]
289 fn line_zero_rejected_for_set_line() {
290 let err = parse_ops(&obj(
291 json!({"op": "set_line", "line": 0, "hash": "aa", "new_text": "x"}),
292 ))
293 .unwrap_err();
294 assert!(err.contains("1-based"), "got: {err}");
295 }
296
297 #[test]
298 fn unknown_op_is_rejected() {
299 let err = parse_ops(&obj(json!({"op": "frobnicate", "line": 1}))).unwrap_err();
300 assert!(err.contains("unknown op"), "got: {err}");
301 }
302
303 #[test]
304 fn missing_op_is_rejected() {
305 let err = parse_ops(&obj(json!({"line": 1, "hash": "aa", "new_text": "x"}))).unwrap_err();
306 assert!(err.contains("missing 'op'"), "got: {err}");
307 }
308}