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 NEW file with `new_text` as its content. No anchors — the file
45    /// must not exist yet (strict, unlike `ctx_edit create=true` which
46    /// overwrites). Handled before the preimage read; cannot be mixed with
47    /// anchored ops in one call (a batch shares a single existing preimage).
48    Create { new_text: String },
49}
50
51/// A stale anchor: the line the model referenced no longer hashes to the value
52/// it was given (the file drifted, or the model copied the wrong anchor).
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub(crate) struct AnchorMiss {
55    /// 1-based line the anchor pointed at.
56    pub line: usize,
57    /// The hash the model supplied.
58    pub expected: String,
59    /// The hash of the line currently on disk (`<eof>` when out of range).
60    pub actual: String,
61}
62
63/// Parse the tool arguments into one or more [`AnchorOp`]s.
64///
65/// Two shapes are accepted: a batch `ops:[{…}, …]`, or a single op described by
66/// the top-level fields. Every op must name its `op` explicitly so error
67/// messages and model steering stay unambiguous (the "two tools" pitfall).
68pub(crate) fn parse_ops(args: &Map<String, Value>) -> Result<Vec<AnchorOp>, String> {
69    if let Some(ops) = args.get("ops") {
70        let arr = ops
71            .as_array()
72            .ok_or_else(|| "ops must be an array of edit objects".to_string())?;
73        if arr.is_empty() {
74            return Err("ops[] is empty — provide at least one edit".to_string());
75        }
76        return arr
77            .iter()
78            .enumerate()
79            .map(|(i, v)| {
80                let obj = v
81                    .as_object()
82                    .ok_or_else(|| format!("ops[{i}] must be an object"))?;
83                parse_one(obj).map_err(|e| format!("ops[{i}]: {e}"))
84            })
85            .collect();
86    }
87    Ok(vec![parse_one(args)?])
88}
89
90fn parse_one(obj: &Map<String, Value>) -> Result<AnchorOp, String> {
91    let op = get_str(obj, "op").ok_or_else(|| {
92        "missing 'op' (one of: set_line, replace_lines, insert_after, delete, create)".to_string()
93    })?;
94    match op.as_str() {
95        "set_line" => Ok(AnchorOp::SetLine {
96            line: req_line(obj, "line")?,
97            hash: req_str(obj, "hash")?,
98            new_text: req_new_text(obj)?,
99        }),
100        "replace_lines" => {
101            let mut missing = Vec::new();
102            let start_line = req_line(obj, "start_line")
103                .map_err(|e| missing.push(e))
104                .ok();
105            // #1114: hashes are optional — when omitted, the edit proceeds
106            // without conflict detection. This makes replace_lines usable
107            // from ctx_read(mode="lines:N-M") output which lacks anchors.
108            let start_hash = get_str(obj, "start_hash").unwrap_or_default();
109            let end_line = req_line(obj, "end_line").map_err(|e| missing.push(e)).ok();
110            let end_hash = get_str(obj, "end_hash").unwrap_or_default();
111            let new_text = req_new_text(obj).map_err(|e| missing.push(e)).ok();
112            if !missing.is_empty() {
113                return Err(format!(
114                    "replace_lines requires start_line, end_line, new_text                      (start_hash/end_hash optional for conflict detection) — {}",
115                    missing.join("; ")
116                ));
117            }
118            Ok(AnchorOp::ReplaceLines {
119                start_line: start_line.expect("start_line required and validated above"),
120                start_hash,
121                end_line: end_line.expect("end_line required and validated above"),
122                end_hash,
123                new_text: new_text.expect("new_text required and validated above"),
124            })
125        }
126        "insert_after" => {
127            if !obj.contains_key("line")
128                && ["old_text", "old_string", "find"]
129                    .iter()
130                    .any(|key| obj.contains_key(*key))
131            {
132                return Err(
133                    "insert_after takes a line number, not text; to insert after \
134                            a text anchor use op=replace_unique with old_text=<anchor> and \
135                            new_text=<anchor followed by the new lines>"
136                        .to_string(),
137                );
138            }
139            let line = req_line_allow_zero(obj, "line")?;
140            // Line 0 ("top of file") has no preceding line to hash. Elsewhere the
141            // hash is optional, as for replace_lines (#1114): a line number taken
142            // from a `lines:N-M` read inserts without conflict detection. The
143            // benchmark pilots' `insert_after` calls carried a line read from a
144            // plain range and failed on the missing hash, then fell back to a
145            // non-unique replace_unique — two extra model steps per edit.
146            let hash = if line == 0 {
147                None
148            } else {
149                get_str(obj, "hash").filter(|hash| !hash.is_empty())
150            };
151            Ok(AnchorOp::InsertAfter {
152                line,
153                hash,
154                new_text: req_new_text(obj)?,
155            })
156        }
157        "delete" => {
158            // Single-line delete ({line,hash}) or a range ({start,end}).
159            if obj.contains_key("start_line") || obj.contains_key("end_line") {
160                {
161                    let mut missing = Vec::new();
162                    let sl = req_line(obj, "start_line")
163                        .map_err(|e| missing.push(e))
164                        .ok();
165                    let sh = req_anchor_hash(obj, "start_hash")
166                        .map_err(|e| missing.push(e))
167                        .ok();
168                    let el = req_line(obj, "end_line").map_err(|e| missing.push(e)).ok();
169                    let eh = req_anchor_hash(obj, "end_hash")
170                        .map_err(|e| missing.push(e))
171                        .ok();
172                    if !missing.is_empty() {
173                        return Err(format!(
174                            "delete (range) requires start_line, start_hash, end_line, end_hash — {}",
175                            missing.join("; ")
176                        ));
177                    }
178                    Ok(AnchorOp::Delete {
179                        start_line: sl.expect("start_line required and validated above"),
180                        start_hash: sh.expect("start_hash required and validated above"),
181                        end_line: el.expect("end_line required and validated above"),
182                        end_hash: eh.expect("end_hash required and validated above"),
183                    })
184                }
185            } else {
186                let line = req_line(obj, "line")?;
187                let hash = req_anchor_hash(obj, "hash")?;
188                Ok(AnchorOp::Delete {
189                    start_line: line,
190                    start_hash: hash.clone(),
191                    end_line: line,
192                    end_hash: hash,
193                })
194            }
195        }
196        "create" => Ok(AnchorOp::Create {
197            new_text: req_new_text_create(obj)?,
198        }),
199        "replace_unique" => Err(
200            "replace_unique cannot be batched in ops[] — send each replace_unique as a \
201             separate top-level ctx_patch call"
202                .to_string(),
203        ),
204        "replace_symbol" => Err(
205            "replace_symbol cannot be batched in ops[] — it is a different (symbol \
206             resolution) write path; send it as a single top-level op"
207                .to_string(),
208        ),
209        "replace_all" => Err(
210            "replace_all cannot be batched in ops[] — send it as a separate top-level \
211             ctx_patch call"
212                .to_string(),
213        ),
214        other => Err(format!(
215            "unknown op '{other}' (one of: set_line, replace_lines, insert_after, delete, create, replace_symbol, replace_all)"
216        )),
217    }
218}
219
220fn get_str(obj: &Map<String, Value>, key: &str) -> Option<String> {
221    obj.get(key).and_then(|v| v.as_str()).map(String::from)
222}
223
224fn req_str(obj: &Map<String, Value>, key: &str) -> Result<String, String> {
225    get_str(obj, key).ok_or_else(|| format!("missing '{key}'"))
226}
227
228fn req_anchor_hash(obj: &Map<String, Value>, key: &str) -> Result<String, String> {
229    get_str(obj, key).ok_or_else(|| {
230        format!(
231            "missing '{key}': '{key}' is a line-content fingerprint from \
232             ctx_read mode=anchored; run ctx_read mode=anchored first, then copy \
233             the hash shown for the target line"
234        )
235    })
236}
237
238/// `new_text` must be *present* but may be empty (`""` = delete).
239fn req_new_text(obj: &Map<String, Value>) -> Result<String, String> {
240    get_str(obj, "new_text").ok_or_else(|| "missing 'new_text' (use \"\" to delete)".to_string())
241}
242
243/// `new_text` for `create` — must be present; `""` creates an empty file.
244fn req_new_text_create(obj: &Map<String, Value>) -> Result<String, String> {
245    get_str(obj, "new_text")
246        .ok_or_else(|| "create requires 'new_text' (the full file content)".to_string())
247}
248
249/// A 1-based line number ≥ 1.
250fn req_line(obj: &Map<String, Value>, key: &str) -> Result<usize, String> {
251    let n = req_line_allow_zero(obj, key)?;
252    if n == 0 {
253        return Err(format!("'{key}' must be ≥ 1 (lines are 1-based)"));
254    }
255    Ok(n)
256}
257
258/// A line number ≥ 0 (0 only meaningful as `insert_after` "top of file").
259fn req_line_allow_zero(obj: &Map<String, Value>, key: &str) -> Result<usize, String> {
260    let v = obj
261        .get(key)
262        .ok_or_else(|| format!("missing '{key}'"))?
263        .as_u64()
264        .ok_or_else(|| format!("'{key}' must be a non-negative integer"))?;
265    usize::try_from(v).map_err(|_| format!("'{key}' is out of range"))
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use serde_json::json;
272
273    fn obj(v: Value) -> Map<String, Value> {
274        match v {
275            Value::Object(m) => m,
276            _ => panic!("expected a JSON object"),
277        }
278    }
279
280    #[test]
281    fn parses_single_set_line() {
282        let ops = parse_ops(&obj(
283            json!({"op": "set_line", "line": 3, "hash": "ab12", "new_text": "x"}),
284        ))
285        .unwrap();
286        assert_eq!(
287            ops,
288            vec![AnchorOp::SetLine {
289                line: 3,
290                hash: "ab12".into(),
291                new_text: "x".into()
292            }]
293        );
294    }
295
296    #[test]
297    fn empty_new_text_is_allowed_for_delete() {
298        let ops = parse_ops(&obj(
299            json!({"op": "set_line", "line": 1, "hash": "aa", "new_text": ""}),
300        ))
301        .unwrap();
302        assert!(matches!(&ops[0], AnchorOp::SetLine { new_text, .. } if new_text.is_empty()));
303    }
304
305    #[test]
306    fn insert_after_top_needs_no_hash() {
307        let ops = parse_ops(&obj(
308            json!({"op": "insert_after", "line": 0, "new_text": "// header"}),
309        ))
310        .unwrap();
311        assert_eq!(
312            ops,
313            vec![AnchorOp::InsertAfter {
314                line: 0,
315                hash: None,
316                new_text: "// header".into()
317            }]
318        );
319    }
320
321    /// Benchmark replay (fix-snooze-expired-filter): `insert_after line=702`
322    /// from a `lines:630-709` read, no hash. It used to fail with "missing
323    /// 'hash'"; like replace_lines (#1114) it now inserts unchecked.
324    #[test]
325    fn insert_after_without_hash_inserts_unchecked() {
326        let ops = parse_ops(&obj(
327            json!({"op": "insert_after", "line": 702, "new_text": "x"}),
328        ))
329        .unwrap();
330        assert_eq!(
331            ops,
332            vec![AnchorOp::InsertAfter {
333                line: 702,
334                hash: None,
335                new_text: "x".into()
336            }]
337        );
338        let err = parse_ops(&obj(
339            json!({"op": "insert_after", "find": "anchor", "new_text": "x"}),
340        ))
341        .unwrap_err();
342        assert!(err.contains("replace_unique"), "got: {err}");
343    }
344
345    #[test]
346    fn delete_single_and_range() {
347        let single = parse_ops(&obj(json!({"op": "delete", "line": 4, "hash": "cc"}))).unwrap();
348        assert_eq!(
349            single[0],
350            AnchorOp::Delete {
351                start_line: 4,
352                start_hash: "cc".into(),
353                end_line: 4,
354                end_hash: "cc".into()
355            }
356        );
357        let range = parse_ops(&obj(json!({
358            "op": "delete", "start_line": 2, "start_hash": "aa", "end_line": 5, "end_hash": "bb"
359        })))
360        .unwrap();
361        assert_eq!(
362            range[0],
363            AnchorOp::Delete {
364                start_line: 2,
365                start_hash: "aa".into(),
366                end_line: 5,
367                end_hash: "bb".into()
368            }
369        );
370    }
371
372    #[test]
373    fn delete_missing_hash_explains_how_to_get_anchor() {
374        let err = parse_ops(&obj(json!({"op": "delete", "line": 4}))).unwrap_err();
375
376        assert!(err.contains("missing 'hash'"), "got: {err}");
377        assert!(err.contains("line-content fingerprint"), "got: {err}");
378        assert!(err.contains("ctx_read mode=anchored"), "got: {err}");
379    }
380
381    #[test]
382    fn parses_batch_ops() {
383        let ops = parse_ops(&obj(json!({
384            "ops": [
385                {"op": "set_line", "line": 1, "hash": "aa", "new_text": "A"},
386                {"op": "insert_after", "line": 3, "hash": "bb", "new_text": "B"}
387            ]
388        })))
389        .unwrap();
390        assert_eq!(ops.len(), 2);
391    }
392
393    #[test]
394    fn empty_ops_array_is_rejected() {
395        let err = parse_ops(&obj(json!({"ops": []}))).unwrap_err();
396        assert!(err.contains("empty"), "got: {err}");
397    }
398
399    #[test]
400    fn line_zero_rejected_for_set_line() {
401        let err = parse_ops(&obj(
402            json!({"op": "set_line", "line": 0, "hash": "aa", "new_text": "x"}),
403        ))
404        .unwrap_err();
405        assert!(err.contains("1-based"), "got: {err}");
406    }
407
408    #[test]
409    fn unknown_op_is_rejected() {
410        let err = parse_ops(&obj(json!({"op": "frobnicate", "line": 1}))).unwrap_err();
411        assert!(err.contains("unknown op"), "got: {err}");
412    }
413
414    #[test]
415    fn parses_create_with_content() {
416        let ops = parse_ops(&obj(json!({"op": "create", "new_text": "fn main() {}\n"}))).unwrap();
417        assert_eq!(
418            ops,
419            vec![AnchorOp::Create {
420                new_text: "fn main() {}\n".into()
421            }]
422        );
423    }
424
425    #[test]
426    fn create_requires_new_text() {
427        let err = parse_ops(&obj(json!({"op": "create"}))).unwrap_err();
428        assert!(err.contains("new_text"), "got: {err}");
429    }
430
431    #[test]
432    fn create_allows_empty_new_text() {
433        // "" is a valid empty file — unlike anchored ops where "" means delete.
434        let ops = parse_ops(&obj(json!({"op": "create", "new_text": ""}))).unwrap();
435        assert!(matches!(&ops[0], AnchorOp::Create { new_text } if new_text.is_empty()));
436    }
437
438    #[test]
439    fn missing_op_is_rejected() {
440        let err = parse_ops(&obj(json!({"line": 1, "hash": "aa", "new_text": "x"}))).unwrap_err();
441        assert!(err.contains("missing 'op'"), "got: {err}");
442    }
443
444    #[test]
445    fn replace_lines_reports_all_missing_fields_at_once() {
446        let err = parse_ops(&obj(json!({"op": "replace_lines"}))).unwrap_err();
447        assert!(err.contains("start_line"), "must mention start_line: {err}");
448        assert!(err.contains("start_hash"), "must mention start_hash: {err}");
449        assert!(err.contains("end_line"), "must mention end_line: {err}");
450        assert!(err.contains("end_hash"), "must mention end_hash: {err}");
451        assert!(err.contains("new_text"), "must mention new_text: {err}");
452    }
453
454    #[test]
455    fn delete_range_reports_all_missing_fields_at_once() {
456        let err = parse_ops(&obj(json!({"op": "delete", "start_line": 1}))).unwrap_err();
457        assert!(err.contains("start_hash"), "must mention start_hash: {err}");
458        assert!(err.contains("end_line"), "must mention end_line: {err}");
459        assert!(err.contains("end_hash"), "must mention end_hash: {err}");
460        assert!(err.contains("ctx_read mode=anchored"), "got: {err}");
461    }
462
463    #[test]
464    fn new_body_is_not_accepted_new_text_is_the_only_key() {
465        // #1020: new_body was fully retired in favour of new_text (no fallback).
466        // A stray new_body must fail with the canonical new_text error, not apply.
467        let err = parse_ops(&obj(
468            json!({"op": "set_line", "line": 3, "hash": "ab12", "new_body": "x"}),
469        ))
470        .unwrap_err();
471        assert!(err.contains("new_text"), "got: {err}");
472        assert!(
473            !err.contains("new_body"),
474            "error must steer to new_text: {err}"
475        );
476
477        let err = parse_ops(&obj(json!({"op": "create", "new_body": "content"}))).unwrap_err();
478        assert!(err.contains("new_text"), "got: {err}");
479    }
480}