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