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 = req_str(obj, "start_hash").map_err(|e| missing.push(e)).ok();
105 let end_line = req_line(obj, "end_line").map_err(|e| missing.push(e)).ok();
106 let end_hash = req_str(obj, "end_hash").map_err(|e| missing.push(e)).ok();
107 let new_text = req_new_text(obj).map_err(|e| missing.push(e)).ok();
108 if !missing.is_empty() {
109 return Err(format!(
110 "replace_lines requires start_line, start_hash, end_line, end_hash, new_text — {}",
111 missing.join("; ")
112 ));
113 }
114 Ok(AnchorOp::ReplaceLines {
115 start_line: start_line.unwrap(),
116 start_hash: start_hash.unwrap(),
117 end_line: end_line.unwrap(),
118 end_hash: end_hash.unwrap(),
119 new_text: new_text.unwrap(),
120 })
121 }
122 "insert_after" => {
123 let line = req_line_allow_zero(obj, "line")?;
125 let hash = if line == 0 {
126 None
127 } else {
128 Some(req_str(obj, "hash")?)
129 };
130 Ok(AnchorOp::InsertAfter {
131 line,
132 hash,
133 new_text: req_new_text(obj)?,
134 })
135 }
136 "delete" => {
137 if obj.contains_key("start_line") || obj.contains_key("end_line") {
139 {
140 let mut missing = Vec::new();
141 let sl = req_line(obj, "start_line")
142 .map_err(|e| missing.push(e))
143 .ok();
144 let sh = req_str(obj, "start_hash").map_err(|e| missing.push(e)).ok();
145 let el = req_line(obj, "end_line").map_err(|e| missing.push(e)).ok();
146 let eh = req_str(obj, "end_hash").map_err(|e| missing.push(e)).ok();
147 if !missing.is_empty() {
148 return Err(format!(
149 "delete (range) requires start_line, start_hash, end_line, end_hash — {}",
150 missing.join("; ")
151 ));
152 }
153 Ok(AnchorOp::Delete {
154 start_line: sl.unwrap(),
155 start_hash: sh.unwrap(),
156 end_line: el.unwrap(),
157 end_hash: eh.unwrap(),
158 })
159 }
160 } else {
161 let line = req_line(obj, "line")?;
162 let hash = req_str(obj, "hash")?;
163 Ok(AnchorOp::Delete {
164 start_line: line,
165 start_hash: hash.clone(),
166 end_line: line,
167 end_hash: hash,
168 })
169 }
170 }
171 "create" => Ok(AnchorOp::Create {
172 new_text: req_new_text_create(obj)?,
173 }),
174 "replace_symbol" => Err(
175 "replace_symbol cannot be batched in ops[] — it is a different (symbol \
176 resolution) write path; send it as a single top-level op"
177 .to_string(),
178 ),
179 other => Err(format!(
180 "unknown op '{other}' (one of: set_line, replace_lines, insert_after, delete, create, replace_symbol, replace_all)"
181 )),
182 }
183}
184
185fn get_str(obj: &Map<String, Value>, key: &str) -> Option<String> {
186 obj.get(key).and_then(|v| v.as_str()).map(String::from)
187}
188
189fn req_str(obj: &Map<String, Value>, key: &str) -> Result<String, String> {
190 get_str(obj, key).ok_or_else(|| format!("missing '{key}'"))
191}
192
193fn req_new_text(obj: &Map<String, Value>) -> Result<String, String> {
195 obj.get("new_text")
196 .and_then(|v| v.as_str())
197 .map(String::from)
198 .ok_or_else(|| "missing 'new_text' (use \"\" to delete)".to_string())
199}
200
201fn req_new_text_create(obj: &Map<String, Value>) -> Result<String, String> {
203 obj.get("new_text")
204 .and_then(|v| v.as_str())
205 .map(String::from)
206 .ok_or_else(|| "create requires 'new_text' (the full file content)".to_string())
207}
208
209fn req_line(obj: &Map<String, Value>, key: &str) -> Result<usize, String> {
211 let n = req_line_allow_zero(obj, key)?;
212 if n == 0 {
213 return Err(format!("'{key}' must be ≥ 1 (lines are 1-based)"));
214 }
215 Ok(n)
216}
217
218fn req_line_allow_zero(obj: &Map<String, Value>, key: &str) -> Result<usize, String> {
220 let v = obj
221 .get(key)
222 .ok_or_else(|| format!("missing '{key}'"))?
223 .as_u64()
224 .ok_or_else(|| format!("'{key}' must be a non-negative integer"))?;
225 usize::try_from(v).map_err(|_| format!("'{key}' is out of range"))
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use serde_json::json;
232
233 fn obj(v: Value) -> Map<String, Value> {
234 match v {
235 Value::Object(m) => m,
236 _ => panic!("expected a JSON object"),
237 }
238 }
239
240 #[test]
241 fn parses_single_set_line() {
242 let ops = parse_ops(&obj(
243 json!({"op": "set_line", "line": 3, "hash": "ab12", "new_text": "x"}),
244 ))
245 .unwrap();
246 assert_eq!(
247 ops,
248 vec![AnchorOp::SetLine {
249 line: 3,
250 hash: "ab12".into(),
251 new_text: "x".into()
252 }]
253 );
254 }
255
256 #[test]
257 fn empty_new_text_is_allowed_for_delete() {
258 let ops = parse_ops(&obj(
259 json!({"op": "set_line", "line": 1, "hash": "aa", "new_text": ""}),
260 ))
261 .unwrap();
262 assert!(matches!(&ops[0], AnchorOp::SetLine { new_text, .. } if new_text.is_empty()));
263 }
264
265 #[test]
266 fn insert_after_top_needs_no_hash() {
267 let ops = parse_ops(&obj(
268 json!({"op": "insert_after", "line": 0, "new_text": "// header"}),
269 ))
270 .unwrap();
271 assert_eq!(
272 ops,
273 vec![AnchorOp::InsertAfter {
274 line: 0,
275 hash: None,
276 new_text: "// header".into()
277 }]
278 );
279 }
280
281 #[test]
282 fn insert_after_nonzero_requires_hash() {
283 let err = parse_ops(&obj(
284 json!({"op": "insert_after", "line": 5, "new_text": "x"}),
285 ))
286 .unwrap_err();
287 assert!(err.contains("hash"), "got: {err}");
288 }
289
290 #[test]
291 fn delete_single_and_range() {
292 let single = parse_ops(&obj(json!({"op": "delete", "line": 4, "hash": "cc"}))).unwrap();
293 assert_eq!(
294 single[0],
295 AnchorOp::Delete {
296 start_line: 4,
297 start_hash: "cc".into(),
298 end_line: 4,
299 end_hash: "cc".into()
300 }
301 );
302 let range = parse_ops(&obj(json!({
303 "op": "delete", "start_line": 2, "start_hash": "aa", "end_line": 5, "end_hash": "bb"
304 })))
305 .unwrap();
306 assert_eq!(
307 range[0],
308 AnchorOp::Delete {
309 start_line: 2,
310 start_hash: "aa".into(),
311 end_line: 5,
312 end_hash: "bb".into()
313 }
314 );
315 }
316
317 #[test]
318 fn parses_batch_ops() {
319 let ops = parse_ops(&obj(json!({
320 "ops": [
321 {"op": "set_line", "line": 1, "hash": "aa", "new_text": "A"},
322 {"op": "insert_after", "line": 3, "hash": "bb", "new_text": "B"}
323 ]
324 })))
325 .unwrap();
326 assert_eq!(ops.len(), 2);
327 }
328
329 #[test]
330 fn empty_ops_array_is_rejected() {
331 let err = parse_ops(&obj(json!({"ops": []}))).unwrap_err();
332 assert!(err.contains("empty"), "got: {err}");
333 }
334
335 #[test]
336 fn line_zero_rejected_for_set_line() {
337 let err = parse_ops(&obj(
338 json!({"op": "set_line", "line": 0, "hash": "aa", "new_text": "x"}),
339 ))
340 .unwrap_err();
341 assert!(err.contains("1-based"), "got: {err}");
342 }
343
344 #[test]
345 fn unknown_op_is_rejected() {
346 let err = parse_ops(&obj(json!({"op": "frobnicate", "line": 1}))).unwrap_err();
347 assert!(err.contains("unknown op"), "got: {err}");
348 }
349
350 #[test]
351 fn parses_create_with_content() {
352 let ops = parse_ops(&obj(json!({"op": "create", "new_text": "fn main() {}\n"}))).unwrap();
353 assert_eq!(
354 ops,
355 vec![AnchorOp::Create {
356 new_text: "fn main() {}\n".into()
357 }]
358 );
359 }
360
361 #[test]
362 fn create_requires_new_text() {
363 let err = parse_ops(&obj(json!({"op": "create"}))).unwrap_err();
364 assert!(err.contains("new_text"), "got: {err}");
365 }
366
367 #[test]
368 fn create_allows_empty_new_text() {
369 let ops = parse_ops(&obj(json!({"op": "create", "new_text": ""}))).unwrap();
371 assert!(matches!(&ops[0], AnchorOp::Create { new_text } if new_text.is_empty()));
372 }
373
374 #[test]
375 fn missing_op_is_rejected() {
376 let err = parse_ops(&obj(json!({"line": 1, "hash": "aa", "new_text": "x"}))).unwrap_err();
377 assert!(err.contains("missing 'op'"), "got: {err}");
378 }
379
380 #[test]
381 fn replace_lines_reports_all_missing_fields_at_once() {
382 let err = parse_ops(&obj(json!({"op": "replace_lines"}))).unwrap_err();
383 assert!(err.contains("start_line"), "must mention start_line: {err}");
384 assert!(err.contains("start_hash"), "must mention start_hash: {err}");
385 assert!(err.contains("end_line"), "must mention end_line: {err}");
386 assert!(err.contains("end_hash"), "must mention end_hash: {err}");
387 assert!(err.contains("new_text"), "must mention new_text: {err}");
388 }
389
390 #[test]
391 fn delete_range_reports_all_missing_fields_at_once() {
392 let err = parse_ops(&obj(json!({"op": "delete", "start_line": 1}))).unwrap_err();
393 assert!(err.contains("start_hash"), "must mention start_hash: {err}");
394 assert!(err.contains("end_line"), "must mention end_line: {err}");
395 assert!(err.contains("end_hash"), "must mention end_hash: {err}");
396 }
397}