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