use serde_json::{Map, Value};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AnchorOp {
SetLine {
line: usize,
hash: String,
new_text: String,
},
ReplaceLines {
start_line: usize,
start_hash: String,
end_line: usize,
end_hash: String,
new_text: String,
},
InsertAfter {
line: usize,
hash: Option<String>,
new_text: String,
},
Delete {
start_line: usize,
start_hash: String,
end_line: usize,
end_hash: String,
},
Create { new_text: String },
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct AnchorMiss {
pub line: usize,
pub expected: String,
pub actual: String,
}
pub(crate) fn parse_ops(args: &Map<String, Value>) -> Result<Vec<AnchorOp>, String> {
if let Some(ops) = args.get("ops") {
let arr = ops
.as_array()
.ok_or_else(|| "ops must be an array of edit objects".to_string())?;
if arr.is_empty() {
return Err("ops[] is empty — provide at least one edit".to_string());
}
return arr
.iter()
.enumerate()
.map(|(i, v)| {
let obj = v
.as_object()
.ok_or_else(|| format!("ops[{i}] must be an object"))?;
parse_one(obj).map_err(|e| format!("ops[{i}]: {e}"))
})
.collect();
}
Ok(vec![parse_one(args)?])
}
fn parse_one(obj: &Map<String, Value>) -> Result<AnchorOp, String> {
let op = get_str(obj, "op").ok_or_else(|| {
"missing 'op' (one of: set_line, replace_lines, insert_after, delete, create)".to_string()
})?;
match op.as_str() {
"set_line" => Ok(AnchorOp::SetLine {
line: req_line(obj, "line")?,
hash: req_str(obj, "hash")?,
new_text: req_new_text(obj)?,
}),
"replace_lines" => Ok(AnchorOp::ReplaceLines {
start_line: req_line(obj, "start_line")?,
start_hash: req_str(obj, "start_hash")?,
end_line: req_line(obj, "end_line")?,
end_hash: req_str(obj, "end_hash")?,
new_text: req_new_text(obj)?,
}),
"insert_after" => {
let line = req_line_allow_zero(obj, "line")?;
let hash = if line == 0 {
None
} else {
Some(req_str(obj, "hash")?)
};
Ok(AnchorOp::InsertAfter {
line,
hash,
new_text: req_new_text(obj)?,
})
}
"delete" => {
if obj.contains_key("start_line") || obj.contains_key("end_line") {
Ok(AnchorOp::Delete {
start_line: req_line(obj, "start_line")?,
start_hash: req_str(obj, "start_hash")?,
end_line: req_line(obj, "end_line")?,
end_hash: req_str(obj, "end_hash")?,
})
} else {
let line = req_line(obj, "line")?;
let hash = req_str(obj, "hash")?;
Ok(AnchorOp::Delete {
start_line: line,
start_hash: hash.clone(),
end_line: line,
end_hash: hash,
})
}
}
"create" => Ok(AnchorOp::Create {
new_text: req_new_text_create(obj)?,
}),
"replace_symbol" => Err(
"replace_symbol cannot be batched in ops[] — it is a different (symbol \
resolution) write path; send it as a single top-level op"
.to_string(),
),
other => Err(format!(
"unknown op '{other}' (one of: set_line, replace_lines, insert_after, delete, create, replace_symbol)"
)),
}
}
fn get_str(obj: &Map<String, Value>, key: &str) -> Option<String> {
obj.get(key).and_then(|v| v.as_str()).map(String::from)
}
fn req_str(obj: &Map<String, Value>, key: &str) -> Result<String, String> {
get_str(obj, key).ok_or_else(|| format!("missing '{key}'"))
}
fn req_new_text(obj: &Map<String, Value>) -> Result<String, String> {
obj.get("new_text")
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| "missing 'new_text' (use \"\" to delete)".to_string())
}
fn req_new_text_create(obj: &Map<String, Value>) -> Result<String, String> {
obj.get("new_text")
.and_then(|v| v.as_str())
.map(String::from)
.ok_or_else(|| "create requires 'new_text' (the full file content)".to_string())
}
fn req_line(obj: &Map<String, Value>, key: &str) -> Result<usize, String> {
let n = req_line_allow_zero(obj, key)?;
if n == 0 {
return Err(format!("'{key}' must be ≥ 1 (lines are 1-based)"));
}
Ok(n)
}
fn req_line_allow_zero(obj: &Map<String, Value>, key: &str) -> Result<usize, String> {
let v = obj
.get(key)
.ok_or_else(|| format!("missing '{key}'"))?
.as_u64()
.ok_or_else(|| format!("'{key}' must be a non-negative integer"))?;
usize::try_from(v).map_err(|_| format!("'{key}' is out of range"))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn obj(v: Value) -> Map<String, Value> {
match v {
Value::Object(m) => m,
_ => panic!("expected a JSON object"),
}
}
#[test]
fn parses_single_set_line() {
let ops = parse_ops(&obj(
json!({"op": "set_line", "line": 3, "hash": "ab12", "new_text": "x"}),
))
.unwrap();
assert_eq!(
ops,
vec![AnchorOp::SetLine {
line: 3,
hash: "ab12".into(),
new_text: "x".into()
}]
);
}
#[test]
fn empty_new_text_is_allowed_for_delete() {
let ops = parse_ops(&obj(
json!({"op": "set_line", "line": 1, "hash": "aa", "new_text": ""}),
))
.unwrap();
assert!(matches!(&ops[0], AnchorOp::SetLine { new_text, .. } if new_text.is_empty()));
}
#[test]
fn insert_after_top_needs_no_hash() {
let ops = parse_ops(&obj(
json!({"op": "insert_after", "line": 0, "new_text": "// header"}),
))
.unwrap();
assert_eq!(
ops,
vec![AnchorOp::InsertAfter {
line: 0,
hash: None,
new_text: "// header".into()
}]
);
}
#[test]
fn insert_after_nonzero_requires_hash() {
let err = parse_ops(&obj(
json!({"op": "insert_after", "line": 5, "new_text": "x"}),
))
.unwrap_err();
assert!(err.contains("hash"), "got: {err}");
}
#[test]
fn delete_single_and_range() {
let single = parse_ops(&obj(json!({"op": "delete", "line": 4, "hash": "cc"}))).unwrap();
assert_eq!(
single[0],
AnchorOp::Delete {
start_line: 4,
start_hash: "cc".into(),
end_line: 4,
end_hash: "cc".into()
}
);
let range = parse_ops(&obj(json!({
"op": "delete", "start_line": 2, "start_hash": "aa", "end_line": 5, "end_hash": "bb"
})))
.unwrap();
assert_eq!(
range[0],
AnchorOp::Delete {
start_line: 2,
start_hash: "aa".into(),
end_line: 5,
end_hash: "bb".into()
}
);
}
#[test]
fn parses_batch_ops() {
let ops = parse_ops(&obj(json!({
"ops": [
{"op": "set_line", "line": 1, "hash": "aa", "new_text": "A"},
{"op": "insert_after", "line": 3, "hash": "bb", "new_text": "B"}
]
})))
.unwrap();
assert_eq!(ops.len(), 2);
}
#[test]
fn empty_ops_array_is_rejected() {
let err = parse_ops(&obj(json!({"ops": []}))).unwrap_err();
assert!(err.contains("empty"), "got: {err}");
}
#[test]
fn line_zero_rejected_for_set_line() {
let err = parse_ops(&obj(
json!({"op": "set_line", "line": 0, "hash": "aa", "new_text": "x"}),
))
.unwrap_err();
assert!(err.contains("1-based"), "got: {err}");
}
#[test]
fn unknown_op_is_rejected() {
let err = parse_ops(&obj(json!({"op": "frobnicate", "line": 1}))).unwrap_err();
assert!(err.contains("unknown op"), "got: {err}");
}
#[test]
fn parses_create_with_content() {
let ops = parse_ops(&obj(json!({"op": "create", "new_text": "fn main() {}\n"}))).unwrap();
assert_eq!(
ops,
vec![AnchorOp::Create {
new_text: "fn main() {}\n".into()
}]
);
}
#[test]
fn create_requires_new_text() {
let err = parse_ops(&obj(json!({"op": "create"}))).unwrap_err();
assert!(err.contains("new_text"), "got: {err}");
}
#[test]
fn create_allows_empty_new_text() {
let ops = parse_ops(&obj(json!({"op": "create", "new_text": ""}))).unwrap();
assert!(matches!(&ops[0], AnchorOp::Create { new_text } if new_text.is_empty()));
}
#[test]
fn missing_op_is_rejected() {
let err = parse_ops(&obj(json!({"line": 1, "hash": "aa", "new_text": "x"}))).unwrap_err();
assert!(err.contains("missing 'op'"), "got: {err}");
}
}