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}
44
45/// A stale anchor: the line the model referenced no longer hashes to the value
46/// it was given (the file drifted, or the model copied the wrong anchor).
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub(crate) struct AnchorMiss {
49    /// 1-based line the anchor pointed at.
50    pub line: usize,
51    /// The hash the model supplied.
52    pub expected: String,
53    /// The hash of the line currently on disk (`<eof>` when out of range).
54    pub actual: String,
55}
56
57/// Parse the tool arguments into one or more [`AnchorOp`]s.
58///
59/// Two shapes are accepted: a batch `ops:[{…}, …]`, or a single op described by
60/// the top-level fields. Every op must name its `op` explicitly so error
61/// messages and model steering stay unambiguous (the "two tools" pitfall).
62pub(crate) fn parse_ops(args: &Map<String, Value>) -> Result<Vec<AnchorOp>, String> {
63    if let Some(ops) = args.get("ops") {
64        let arr = ops
65            .as_array()
66            .ok_or_else(|| "ops must be an array of edit objects".to_string())?;
67        if arr.is_empty() {
68            return Err("ops[] is empty — provide at least one edit".to_string());
69        }
70        return arr
71            .iter()
72            .enumerate()
73            .map(|(i, v)| {
74                let obj = v
75                    .as_object()
76                    .ok_or_else(|| format!("ops[{i}] must be an object"))?;
77                parse_one(obj).map_err(|e| format!("ops[{i}]: {e}"))
78            })
79            .collect();
80    }
81    Ok(vec![parse_one(args)?])
82}
83
84fn parse_one(obj: &Map<String, Value>) -> Result<AnchorOp, String> {
85    let op = get_str(obj, "op").ok_or_else(|| {
86        "missing 'op' (one of: set_line, replace_lines, insert_after, delete)".to_string()
87    })?;
88    match op.as_str() {
89        "set_line" => Ok(AnchorOp::SetLine {
90            line: req_line(obj, "line")?,
91            hash: req_str(obj, "hash")?,
92            new_text: req_new_text(obj)?,
93        }),
94        "replace_lines" => Ok(AnchorOp::ReplaceLines {
95            start_line: req_line(obj, "start_line")?,
96            start_hash: req_str(obj, "start_hash")?,
97            end_line: req_line(obj, "end_line")?,
98            end_hash: req_str(obj, "end_hash")?,
99            new_text: req_new_text(obj)?,
100        }),
101        "insert_after" => {
102            // Line 0 means "insert at the top"; it has no preceding line to hash.
103            let line = req_line_allow_zero(obj, "line")?;
104            let hash = if line == 0 {
105                None
106            } else {
107                Some(req_str(obj, "hash")?)
108            };
109            Ok(AnchorOp::InsertAfter {
110                line,
111                hash,
112                new_text: req_new_text(obj)?,
113            })
114        }
115        "delete" => {
116            // Single-line delete ({line,hash}) or a range ({start,end}).
117            if obj.contains_key("start_line") || obj.contains_key("end_line") {
118                Ok(AnchorOp::Delete {
119                    start_line: req_line(obj, "start_line")?,
120                    start_hash: req_str(obj, "start_hash")?,
121                    end_line: req_line(obj, "end_line")?,
122                    end_hash: req_str(obj, "end_hash")?,
123                })
124            } else {
125                let line = req_line(obj, "line")?;
126                let hash = req_str(obj, "hash")?;
127                Ok(AnchorOp::Delete {
128                    start_line: line,
129                    start_hash: hash.clone(),
130                    end_line: line,
131                    end_hash: hash,
132                })
133            }
134        }
135        "replace_symbol" => Err(
136            "replace_symbol cannot be batched in ops[] — it is a different (symbol \
137             resolution) write path; send it as a single top-level op"
138                .to_string(),
139        ),
140        other => Err(format!(
141            "unknown op '{other}' (one of: set_line, replace_lines, insert_after, delete, replace_symbol)"
142        )),
143    }
144}
145
146fn get_str(obj: &Map<String, Value>, key: &str) -> Option<String> {
147    obj.get(key).and_then(|v| v.as_str()).map(String::from)
148}
149
150fn req_str(obj: &Map<String, Value>, key: &str) -> Result<String, String> {
151    get_str(obj, key).ok_or_else(|| format!("missing '{key}'"))
152}
153
154/// `new_text` must be *present* but may be empty (`""` = delete).
155fn req_new_text(obj: &Map<String, Value>) -> Result<String, String> {
156    obj.get("new_text")
157        .and_then(|v| v.as_str())
158        .map(String::from)
159        .ok_or_else(|| "missing 'new_text' (use \"\" to delete)".to_string())
160}
161
162/// A 1-based line number ≥ 1.
163fn req_line(obj: &Map<String, Value>, key: &str) -> Result<usize, String> {
164    let n = req_line_allow_zero(obj, key)?;
165    if n == 0 {
166        return Err(format!("'{key}' must be ≥ 1 (lines are 1-based)"));
167    }
168    Ok(n)
169}
170
171/// A line number ≥ 0 (0 only meaningful as `insert_after` "top of file").
172fn req_line_allow_zero(obj: &Map<String, Value>, key: &str) -> Result<usize, String> {
173    let v = obj
174        .get(key)
175        .ok_or_else(|| format!("missing '{key}'"))?
176        .as_u64()
177        .ok_or_else(|| format!("'{key}' must be a non-negative integer"))?;
178    usize::try_from(v).map_err(|_| format!("'{key}' is out of range"))
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use serde_json::json;
185
186    fn obj(v: Value) -> Map<String, Value> {
187        match v {
188            Value::Object(m) => m,
189            _ => panic!("expected a JSON object"),
190        }
191    }
192
193    #[test]
194    fn parses_single_set_line() {
195        let ops = parse_ops(&obj(
196            json!({"op": "set_line", "line": 3, "hash": "ab12", "new_text": "x"}),
197        ))
198        .unwrap();
199        assert_eq!(
200            ops,
201            vec![AnchorOp::SetLine {
202                line: 3,
203                hash: "ab12".into(),
204                new_text: "x".into()
205            }]
206        );
207    }
208
209    #[test]
210    fn empty_new_text_is_allowed_for_delete() {
211        let ops = parse_ops(&obj(
212            json!({"op": "set_line", "line": 1, "hash": "aa", "new_text": ""}),
213        ))
214        .unwrap();
215        assert!(matches!(&ops[0], AnchorOp::SetLine { new_text, .. } if new_text.is_empty()));
216    }
217
218    #[test]
219    fn insert_after_top_needs_no_hash() {
220        let ops = parse_ops(&obj(
221            json!({"op": "insert_after", "line": 0, "new_text": "// header"}),
222        ))
223        .unwrap();
224        assert_eq!(
225            ops,
226            vec![AnchorOp::InsertAfter {
227                line: 0,
228                hash: None,
229                new_text: "// header".into()
230            }]
231        );
232    }
233
234    #[test]
235    fn insert_after_nonzero_requires_hash() {
236        let err = parse_ops(&obj(
237            json!({"op": "insert_after", "line": 5, "new_text": "x"}),
238        ))
239        .unwrap_err();
240        assert!(err.contains("hash"), "got: {err}");
241    }
242
243    #[test]
244    fn delete_single_and_range() {
245        let single = parse_ops(&obj(json!({"op": "delete", "line": 4, "hash": "cc"}))).unwrap();
246        assert_eq!(
247            single[0],
248            AnchorOp::Delete {
249                start_line: 4,
250                start_hash: "cc".into(),
251                end_line: 4,
252                end_hash: "cc".into()
253            }
254        );
255        let range = parse_ops(&obj(json!({
256            "op": "delete", "start_line": 2, "start_hash": "aa", "end_line": 5, "end_hash": "bb"
257        })))
258        .unwrap();
259        assert_eq!(
260            range[0],
261            AnchorOp::Delete {
262                start_line: 2,
263                start_hash: "aa".into(),
264                end_line: 5,
265                end_hash: "bb".into()
266            }
267        );
268    }
269
270    #[test]
271    fn parses_batch_ops() {
272        let ops = parse_ops(&obj(json!({
273            "ops": [
274                {"op": "set_line", "line": 1, "hash": "aa", "new_text": "A"},
275                {"op": "insert_after", "line": 3, "hash": "bb", "new_text": "B"}
276            ]
277        })))
278        .unwrap();
279        assert_eq!(ops.len(), 2);
280    }
281
282    #[test]
283    fn empty_ops_array_is_rejected() {
284        let err = parse_ops(&obj(json!({"ops": []}))).unwrap_err();
285        assert!(err.contains("empty"), "got: {err}");
286    }
287
288    #[test]
289    fn line_zero_rejected_for_set_line() {
290        let err = parse_ops(&obj(
291            json!({"op": "set_line", "line": 0, "hash": "aa", "new_text": "x"}),
292        ))
293        .unwrap_err();
294        assert!(err.contains("1-based"), "got: {err}");
295    }
296
297    #[test]
298    fn unknown_op_is_rejected() {
299        let err = parse_ops(&obj(json!({"op": "frobnicate", "line": 1}))).unwrap_err();
300        assert!(err.contains("unknown op"), "got: {err}");
301    }
302
303    #[test]
304    fn missing_op_is_rejected() {
305        let err = parse_ops(&obj(json!({"line": 1, "hash": "aa", "new_text": "x"}))).unwrap_err();
306        assert!(err.contains("missing 'op'"), "got: {err}");
307    }
308}