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