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" => 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            // Line 0 means "insert at the top"; it has no preceding line to hash.
108            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            // Single-line delete ({line,hash}) or a range ({start,end}).
122            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
162/// `new_text` must be *present* but may be empty (`""` = delete).
163fn 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
170/// `new_text` for `create` — must be present; `""` creates an empty file.
171fn 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
178/// A 1-based line number ≥ 1.
179fn 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
187/// A line number ≥ 0 (0 only meaningful as `insert_after` "top of file").
188fn 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        // "" is a valid empty file — unlike anchored ops where "" means delete.
339        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}