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            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            // Line 0 means "insert at the top"; it has no preceding line to hash.
124            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            // Single-line delete ({line,hash}) or a range ({start,end}).
138            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
193/// `new_text` must be *present* but may be empty (`""` = delete).
194fn 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
201/// `new_text` for `create` — must be present; `""` creates an empty file.
202fn 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
209/// A 1-based line number ≥ 1.
210fn 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
218/// A line number ≥ 0 (0 only meaningful as `insert_after` "top of file").
219fn 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        // "" is a valid empty file — unlike anchored ops where "" means delete.
370        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}