Skip to main content

lean_ctx/tools/ctx_patch/
anchors.rs

1//! Anchored-edit operations: the `AnchorOp` vocabulary, JSON parsing and the
2//! per-anchor staleness check (epic #1008).
3//!
4//! An *anchor* is `(line, hash)` where `hash` is [`crate::core::anchor::line_hash`]
5//! of the line the model was shown by `ctx_read(mode="anchored")`. The edit side
6//! re-derives the hash from the *current* file and rejects the op if it drifted —
7//! so the model never has to reproduce the old text, only reference it.
8
9use serde_json::{Map, Value};
10
11/// A single anchored edit. `new_text=""` deletes (readseek convention); a
12/// multi-line `new_text` expands one anchor into several lines.
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub enum AnchorOp {
15    /// Replace (or delete, if `new_text==""`) a single line.
16    SetLine {
17        line: usize,
18        hash: String,
19        new_text: String,
20    },
21    /// Replace (or delete) the inclusive line range `start..=end`.
22    ReplaceLines {
23        start_line: usize,
24        start_hash: String,
25        end_line: usize,
26        end_hash: String,
27        new_text: String,
28    },
29    /// Insert after `line` (line 0 = top of file, needs no hash).
30    InsertAfter {
31        line: usize,
32        hash: Option<String>,
33        new_text: String,
34    },
35    /// Delete the inclusive line range `start..=end` (sugar for an empty
36    /// `ReplaceLines`; a single-line delete uses `start==end`).
37    Delete {
38        start_line: usize,
39        start_hash: String,
40        end_line: usize,
41        end_hash: String,
42    },
43    /// Create a NEW file with `new_text` as its content. No anchors — the file
44    /// must not exist yet (strict, unlike `ctx_edit create=true` which
45    /// overwrites). Handled before the preimage read; cannot be mixed with
46    /// anchored ops in one call (a batch shares a single existing preimage).
47    Create { new_text: String },
48}
49
50/// A stale anchor: the line the model referenced no longer hashes to the value
51/// it was given (the file drifted, or the model copied the wrong anchor).
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub(crate) struct AnchorMiss {
54    /// 1-based line the anchor pointed at.
55    pub line: usize,
56    /// The hash the model supplied.
57    pub expected: String,
58    /// The hash of the line currently on disk (`<eof>` when out of range).
59    pub actual: String,
60}
61
62/// Parse the tool arguments into one or more [`AnchorOp`]s.
63///
64/// Two shapes are accepted: a batch `ops:[{…}, …]`, or a single op described by
65/// the top-level fields. Every op must name its `op` explicitly so error
66/// messages and model steering stay unambiguous (the "two tools" pitfall).
67pub(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            // #1114: hashes are optional — when omitted, the edit proceeds
105            // without conflict detection. This makes replace_lines usable
106            // from ctx_read(mode="lines:N-M") output which lacks anchors.
107            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            // Line 0 means "insert at the top"; it has no preceding line to hash.
127            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            // Single-line delete ({line,hash}) or a range ({start,end}).
141            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
220/// `new_text` must be *present* but may be empty (`""` = delete).
221fn 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
225/// `new_text` for `create` — must be present; `""` creates an empty file.
226fn 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
231/// A 1-based line number ≥ 1.
232fn 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
240/// A line number ≥ 0 (0 only meaningful as `insert_after` "top of file").
241fn 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        // "" is a valid empty file — unlike anchored ops where "" means delete.
402        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        // #1020: new_body was fully retired in favour of new_text (no fallback).
434        // A stray new_body must fail with the canonical new_text error, not apply.
435        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}