Skip to main content

lean_ctx_server/tools/ctx_patch/
anchors.rs

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