1use serde_json::{Map, Value};
11
12#[derive(Clone, Debug, PartialEq, Eq)]
15pub enum AnchorOp {
16 SetLine {
18 line: usize,
19 hash: String,
20 new_text: String,
21 },
22 ReplaceLines {
24 start_line: usize,
25 start_hash: String,
26 end_line: usize,
27 end_hash: String,
28 new_text: String,
29 },
30 InsertAfter {
32 line: usize,
33 hash: Option<String>,
34 new_text: String,
35 },
36 Delete {
39 start_line: usize,
40 start_hash: String,
41 end_line: usize,
42 end_hash: String,
43 },
44 Create { new_text: String },
49}
50
51#[derive(Clone, Debug, PartialEq, Eq)]
54pub(crate) struct AnchorMiss {
55 pub line: usize,
57 pub expected: String,
59 pub actual: String,
61}
62
63pub(crate) fn parse_ops(args: &Map<String, Value>) -> Result<Vec<AnchorOp>, String> {
69 if let Some(ops) = args.get("ops") {
70 let arr = ops
71 .as_array()
72 .ok_or_else(|| "ops must be an array of edit objects".to_string())?;
73 if arr.is_empty() {
74 return Err("ops[] is empty — provide at least one edit".to_string());
75 }
76 return arr
77 .iter()
78 .enumerate()
79 .map(|(i, v)| {
80 let obj = v
81 .as_object()
82 .ok_or_else(|| format!("ops[{i}] must be an object"))?;
83 parse_one(obj).map_err(|e| format!("ops[{i}]: {e}"))
84 })
85 .collect();
86 }
87 Ok(vec![parse_one(args)?])
88}
89
90fn parse_one(obj: &Map<String, Value>) -> Result<AnchorOp, String> {
91 let op = get_str(obj, "op").ok_or_else(|| {
92 "missing 'op' (one of: set_line, replace_lines, insert_after, delete, create)".to_string()
93 })?;
94 match op.as_str() {
95 "set_line" => Ok(AnchorOp::SetLine {
96 line: req_line(obj, "line")?,
97 hash: req_str(obj, "hash")?,
98 new_text: req_new_text(obj)?,
99 }),
100 "replace_lines" => {
101 let mut missing = Vec::new();
102 let start_line = req_line(obj, "start_line")
103 .map_err(|e| missing.push(e))
104 .ok();
105 let start_hash = get_str(obj, "start_hash").unwrap_or_default();
109 let end_line = req_line(obj, "end_line").map_err(|e| missing.push(e)).ok();
110 let end_hash = get_str(obj, "end_hash").unwrap_or_default();
111 let new_text = req_new_text(obj).map_err(|e| missing.push(e)).ok();
112 if !missing.is_empty() {
113 return Err(format!(
114 "replace_lines requires start_line, end_line, new_text (start_hash/end_hash optional for conflict detection) — {}",
115 missing.join("; ")
116 ));
117 }
118 Ok(AnchorOp::ReplaceLines {
119 start_line: start_line.expect("start_line required and validated above"),
120 start_hash,
121 end_line: end_line.expect("end_line required and validated above"),
122 end_hash,
123 new_text: new_text.expect("new_text required and validated above"),
124 })
125 }
126 "insert_after" => {
127 if !obj.contains_key("line")
128 && ["old_text", "old_string", "find"]
129 .iter()
130 .any(|key| obj.contains_key(*key))
131 {
132 return Err(
133 "insert_after takes a line number, not text; to insert after \
134 a text anchor use op=replace_unique with old_text=<anchor> and \
135 new_text=<anchor followed by the new lines>"
136 .to_string(),
137 );
138 }
139 let line = req_line_allow_zero(obj, "line")?;
140 let hash = if line == 0 {
147 None
148 } else {
149 get_str(obj, "hash").filter(|hash| !hash.is_empty())
150 };
151 Ok(AnchorOp::InsertAfter {
152 line,
153 hash,
154 new_text: req_new_text(obj)?,
155 })
156 }
157 "delete" => {
158 if obj.contains_key("start_line") || obj.contains_key("end_line") {
160 {
161 let mut missing = Vec::new();
162 let sl = req_line(obj, "start_line")
163 .map_err(|e| missing.push(e))
164 .ok();
165 let sh = req_anchor_hash(obj, "start_hash")
166 .map_err(|e| missing.push(e))
167 .ok();
168 let el = req_line(obj, "end_line").map_err(|e| missing.push(e)).ok();
169 let eh = req_anchor_hash(obj, "end_hash")
170 .map_err(|e| missing.push(e))
171 .ok();
172 if !missing.is_empty() {
173 return Err(format!(
174 "delete (range) requires start_line, start_hash, end_line, end_hash — {}",
175 missing.join("; ")
176 ));
177 }
178 Ok(AnchorOp::Delete {
179 start_line: sl.expect("start_line required and validated above"),
180 start_hash: sh.expect("start_hash required and validated above"),
181 end_line: el.expect("end_line required and validated above"),
182 end_hash: eh.expect("end_hash required and validated above"),
183 })
184 }
185 } else {
186 let line = req_line(obj, "line")?;
187 let hash = req_anchor_hash(obj, "hash")?;
188 Ok(AnchorOp::Delete {
189 start_line: line,
190 start_hash: hash.clone(),
191 end_line: line,
192 end_hash: hash,
193 })
194 }
195 }
196 "create" => Ok(AnchorOp::Create {
197 new_text: req_new_text_create(obj)?,
198 }),
199 "replace_unique" => Err(
200 "replace_unique cannot be batched in ops[] — send each replace_unique as a \
201 separate top-level ctx_patch call"
202 .to_string(),
203 ),
204 "replace_symbol" => Err(
205 "replace_symbol cannot be batched in ops[] — it is a different (symbol \
206 resolution) write path; send it as a single top-level op"
207 .to_string(),
208 ),
209 "replace_all" => Err(
210 "replace_all cannot be batched in ops[] — send it as a separate top-level \
211 ctx_patch call"
212 .to_string(),
213 ),
214 other => Err(format!(
215 "unknown op '{other}' (one of: set_line, replace_lines, insert_after, delete, create, replace_symbol, replace_all)"
216 )),
217 }
218}
219
220fn get_str(obj: &Map<String, Value>, key: &str) -> Option<String> {
221 obj.get(key).and_then(|v| v.as_str()).map(String::from)
222}
223
224fn req_str(obj: &Map<String, Value>, key: &str) -> Result<String, String> {
225 get_str(obj, key).ok_or_else(|| format!("missing '{key}'"))
226}
227
228fn req_anchor_hash(obj: &Map<String, Value>, key: &str) -> Result<String, String> {
229 get_str(obj, key).ok_or_else(|| {
230 format!(
231 "missing '{key}': '{key}' is a line-content fingerprint from \
232 ctx_read mode=anchored; run ctx_read mode=anchored first, then copy \
233 the hash shown for the target line"
234 )
235 })
236}
237
238fn req_new_text(obj: &Map<String, Value>) -> Result<String, String> {
240 get_str(obj, "new_text").ok_or_else(|| "missing 'new_text' (use \"\" to delete)".to_string())
241}
242
243fn req_new_text_create(obj: &Map<String, Value>) -> Result<String, String> {
245 get_str(obj, "new_text")
246 .ok_or_else(|| "create requires 'new_text' (the full file content)".to_string())
247}
248
249fn req_line(obj: &Map<String, Value>, key: &str) -> Result<usize, String> {
251 let n = req_line_allow_zero(obj, key)?;
252 if n == 0 {
253 return Err(format!("'{key}' must be ≥ 1 (lines are 1-based)"));
254 }
255 Ok(n)
256}
257
258fn req_line_allow_zero(obj: &Map<String, Value>, key: &str) -> Result<usize, String> {
260 let v = obj
261 .get(key)
262 .ok_or_else(|| format!("missing '{key}'"))?
263 .as_u64()
264 .ok_or_else(|| format!("'{key}' must be a non-negative integer"))?;
265 usize::try_from(v).map_err(|_| format!("'{key}' is out of range"))
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use serde_json::json;
272
273 fn obj(v: Value) -> Map<String, Value> {
274 match v {
275 Value::Object(m) => m,
276 _ => panic!("expected a JSON object"),
277 }
278 }
279
280 #[test]
281 fn parses_single_set_line() {
282 let ops = parse_ops(&obj(
283 json!({"op": "set_line", "line": 3, "hash": "ab12", "new_text": "x"}),
284 ))
285 .unwrap();
286 assert_eq!(
287 ops,
288 vec![AnchorOp::SetLine {
289 line: 3,
290 hash: "ab12".into(),
291 new_text: "x".into()
292 }]
293 );
294 }
295
296 #[test]
297 fn empty_new_text_is_allowed_for_delete() {
298 let ops = parse_ops(&obj(
299 json!({"op": "set_line", "line": 1, "hash": "aa", "new_text": ""}),
300 ))
301 .unwrap();
302 assert!(matches!(&ops[0], AnchorOp::SetLine { new_text, .. } if new_text.is_empty()));
303 }
304
305 #[test]
306 fn insert_after_top_needs_no_hash() {
307 let ops = parse_ops(&obj(
308 json!({"op": "insert_after", "line": 0, "new_text": "// header"}),
309 ))
310 .unwrap();
311 assert_eq!(
312 ops,
313 vec![AnchorOp::InsertAfter {
314 line: 0,
315 hash: None,
316 new_text: "// header".into()
317 }]
318 );
319 }
320
321 #[test]
325 fn insert_after_without_hash_inserts_unchecked() {
326 let ops = parse_ops(&obj(
327 json!({"op": "insert_after", "line": 702, "new_text": "x"}),
328 ))
329 .unwrap();
330 assert_eq!(
331 ops,
332 vec![AnchorOp::InsertAfter {
333 line: 702,
334 hash: None,
335 new_text: "x".into()
336 }]
337 );
338 let err = parse_ops(&obj(
339 json!({"op": "insert_after", "find": "anchor", "new_text": "x"}),
340 ))
341 .unwrap_err();
342 assert!(err.contains("replace_unique"), "got: {err}");
343 }
344
345 #[test]
346 fn delete_single_and_range() {
347 let single = parse_ops(&obj(json!({"op": "delete", "line": 4, "hash": "cc"}))).unwrap();
348 assert_eq!(
349 single[0],
350 AnchorOp::Delete {
351 start_line: 4,
352 start_hash: "cc".into(),
353 end_line: 4,
354 end_hash: "cc".into()
355 }
356 );
357 let range = parse_ops(&obj(json!({
358 "op": "delete", "start_line": 2, "start_hash": "aa", "end_line": 5, "end_hash": "bb"
359 })))
360 .unwrap();
361 assert_eq!(
362 range[0],
363 AnchorOp::Delete {
364 start_line: 2,
365 start_hash: "aa".into(),
366 end_line: 5,
367 end_hash: "bb".into()
368 }
369 );
370 }
371
372 #[test]
373 fn delete_missing_hash_explains_how_to_get_anchor() {
374 let err = parse_ops(&obj(json!({"op": "delete", "line": 4}))).unwrap_err();
375
376 assert!(err.contains("missing 'hash'"), "got: {err}");
377 assert!(err.contains("line-content fingerprint"), "got: {err}");
378 assert!(err.contains("ctx_read mode=anchored"), "got: {err}");
379 }
380
381 #[test]
382 fn parses_batch_ops() {
383 let ops = parse_ops(&obj(json!({
384 "ops": [
385 {"op": "set_line", "line": 1, "hash": "aa", "new_text": "A"},
386 {"op": "insert_after", "line": 3, "hash": "bb", "new_text": "B"}
387 ]
388 })))
389 .unwrap();
390 assert_eq!(ops.len(), 2);
391 }
392
393 #[test]
394 fn empty_ops_array_is_rejected() {
395 let err = parse_ops(&obj(json!({"ops": []}))).unwrap_err();
396 assert!(err.contains("empty"), "got: {err}");
397 }
398
399 #[test]
400 fn line_zero_rejected_for_set_line() {
401 let err = parse_ops(&obj(
402 json!({"op": "set_line", "line": 0, "hash": "aa", "new_text": "x"}),
403 ))
404 .unwrap_err();
405 assert!(err.contains("1-based"), "got: {err}");
406 }
407
408 #[test]
409 fn unknown_op_is_rejected() {
410 let err = parse_ops(&obj(json!({"op": "frobnicate", "line": 1}))).unwrap_err();
411 assert!(err.contains("unknown op"), "got: {err}");
412 }
413
414 #[test]
415 fn parses_create_with_content() {
416 let ops = parse_ops(&obj(json!({"op": "create", "new_text": "fn main() {}\n"}))).unwrap();
417 assert_eq!(
418 ops,
419 vec![AnchorOp::Create {
420 new_text: "fn main() {}\n".into()
421 }]
422 );
423 }
424
425 #[test]
426 fn create_requires_new_text() {
427 let err = parse_ops(&obj(json!({"op": "create"}))).unwrap_err();
428 assert!(err.contains("new_text"), "got: {err}");
429 }
430
431 #[test]
432 fn create_allows_empty_new_text() {
433 let ops = parse_ops(&obj(json!({"op": "create", "new_text": ""}))).unwrap();
435 assert!(matches!(&ops[0], AnchorOp::Create { new_text } if new_text.is_empty()));
436 }
437
438 #[test]
439 fn missing_op_is_rejected() {
440 let err = parse_ops(&obj(json!({"line": 1, "hash": "aa", "new_text": "x"}))).unwrap_err();
441 assert!(err.contains("missing 'op'"), "got: {err}");
442 }
443
444 #[test]
445 fn replace_lines_reports_all_missing_fields_at_once() {
446 let err = parse_ops(&obj(json!({"op": "replace_lines"}))).unwrap_err();
447 assert!(err.contains("start_line"), "must mention start_line: {err}");
448 assert!(err.contains("start_hash"), "must mention start_hash: {err}");
449 assert!(err.contains("end_line"), "must mention end_line: {err}");
450 assert!(err.contains("end_hash"), "must mention end_hash: {err}");
451 assert!(err.contains("new_text"), "must mention new_text: {err}");
452 }
453
454 #[test]
455 fn delete_range_reports_all_missing_fields_at_once() {
456 let err = parse_ops(&obj(json!({"op": "delete", "start_line": 1}))).unwrap_err();
457 assert!(err.contains("start_hash"), "must mention start_hash: {err}");
458 assert!(err.contains("end_line"), "must mention end_line: {err}");
459 assert!(err.contains("end_hash"), "must mention end_hash: {err}");
460 assert!(err.contains("ctx_read mode=anchored"), "got: {err}");
461 }
462
463 #[test]
464 fn new_body_is_not_accepted_new_text_is_the_only_key() {
465 let err = parse_ops(&obj(
468 json!({"op": "set_line", "line": 3, "hash": "ab12", "new_body": "x"}),
469 ))
470 .unwrap_err();
471 assert!(err.contains("new_text"), "got: {err}");
472 assert!(
473 !err.contains("new_body"),
474 "error must steer to new_text: {err}"
475 );
476
477 let err = parse_ops(&obj(json!({"op": "create", "new_body": "content"}))).unwrap_err();
478 assert!(err.contains("new_text"), "got: {err}");
479 }
480}