Skip to main content

headless_lms_mcp/engine/
op.rs

1//! The JSON-Patch-style op shared by every patch tool, and its parsing with teaching errors.
2
3use std::fmt;
4
5use schemars::JsonSchema;
6use serde::de::IntoDeserializer;
7use serde::de::value::{Error as ValueError, StrDeserializer};
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value, json};
10
11use super::error::{EditError, EditErrorCode};
12
13/// What a patch op does, as agents name it in `op`.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
15#[serde(rename_all = "snake_case")]
16pub enum OpKind {
17    Add,
18    Remove,
19    Replace,
20    Move,
21    Copy,
22    Wrap,
23    Unwrap,
24    Split,
25    Merge,
26    Reorder,
27}
28
29/// The op's name on the wire, e.g. `move`.
30impl fmt::Display for OpKind {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        self.serialize(f)
33    }
34}
35
36/// Where an op puts items: next to the sibling at `path`, or at an end of the list at `path`.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
38#[serde(rename_all = "snake_case")]
39pub enum Position {
40    Before,
41    After,
42    Start,
43    End,
44}
45
46impl Position {
47    const ALL: [Position; 4] = [
48        Position::Before,
49        Position::After,
50        Position::Start,
51        Position::End,
52    ];
53}
54
55/// The position's name on the wire, e.g. `before`.
56impl fmt::Display for Position {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        self.serialize(f)
59    }
60}
61
62/// The unit variant whose wire name is `name`.
63fn from_wire_name<'de, T: Deserialize<'de>>(name: &'de str) -> Option<T> {
64    let deserializer: StrDeserializer<'de, ValueError> = name.into_deserializer();
65    T::deserialize(deserializer).ok()
66}
67
68/// One edit. Which fields an op takes depends on `op` and the tool; parsing checks them.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
70pub struct PatchOp {
71    pub op: OpKind,
72    /// The target, rooted at an id: `/p1`, `/box1/innerBlocks`, `/p1/attributes/content`; `""` is the page's top level.
73    pub path: String,
74    /// Source of `move`, `copy` and `merge`.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub from: Option<String>,
77    /// Last sibling (inclusive) of a range starting at `from`, or at `path` for `remove` and `wrap`.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub through: Option<String>,
80    /// What is written. Untyped: block schemas come from the `guide` tool.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    #[schemars(schema_with = "crate::tools::schema::untyped_value")]
83    pub value: Option<Value>,
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub position: Option<Position>,
86    /// Target page of a cross-page `move` or `copy`.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub to_page: Option<String>,
89    /// `$name` for the item a `copy`, `wrap` or `split` creates; for `add`, put `label` inside `value`.
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub label: Option<String>,
92    /// Text a `split` starts the second block with.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub at_text: Option<String>,
95}
96
97/// One op after [`parse_ops`]: it has every field its tool requires for it and no field it doesn't
98/// take.
99#[derive(Debug, Clone, PartialEq)]
100pub enum Op {
101    Add {
102        path: String,
103        value: Value,
104        position: Option<Position>,
105    },
106    Remove {
107        path: String,
108        through: Option<String>,
109    },
110    Replace {
111        path: String,
112        value: Value,
113    },
114    Move(Transfer),
115    Copy {
116        transfer: Transfer,
117        label: Option<String>,
118    },
119    Wrap {
120        path: String,
121        through: Option<String>,
122        value: Value,
123        label: Option<String>,
124    },
125    Unwrap {
126        path: String,
127    },
128    Split {
129        path: String,
130        at_text: String,
131        label: Option<String>,
132    },
133    Merge {
134        path: String,
135        from: String,
136    },
137    Reorder {
138        path: String,
139        value: Value,
140    },
141}
142
143/// What a `move` or `copy` takes and where it puts it.
144#[derive(Debug, Clone, PartialEq)]
145pub struct Transfer {
146    pub from: String,
147    pub through: Option<String>,
148    pub path: String,
149    pub position: Position,
150    pub to_page: Option<String>,
151}
152
153impl Op {
154    /// The op's name.
155    pub fn kind(&self) -> OpKind {
156        match self {
157            Op::Add { .. } => OpKind::Add,
158            Op::Remove { .. } => OpKind::Remove,
159            Op::Replace { .. } => OpKind::Replace,
160            Op::Move(_) => OpKind::Move,
161            Op::Copy { .. } => OpKind::Copy,
162            Op::Wrap { .. } => OpKind::Wrap,
163            Op::Unwrap { .. } => OpKind::Unwrap,
164            Op::Split { .. } => OpKind::Split,
165            Op::Merge { .. } => OpKind::Merge,
166            Op::Reorder { .. } => OpKind::Reorder,
167        }
168    }
169
170    /// Where the op acts; for `move` and `copy`, where the items go.
171    pub fn path(&self) -> &str {
172        match self {
173            Op::Move(transfer) | Op::Copy { transfer, .. } => &transfer.path,
174            Op::Add { path, .. }
175            | Op::Remove { path, .. }
176            | Op::Replace { path, .. }
177            | Op::Wrap { path, .. }
178            | Op::Unwrap { path }
179            | Op::Split { path, .. }
180            | Op::Merge { path, .. }
181            | Op::Reorder { path, .. } => path,
182        }
183    }
184
185    /// What the op writes, for the ops that take a value.
186    pub fn value(&self) -> Option<&Value> {
187        match self {
188            Op::Add { value, .. }
189            | Op::Replace { value, .. }
190            | Op::Wrap { value, .. }
191            | Op::Reorder { value, .. } => Some(value),
192            _ => None,
193        }
194    }
195
196    /// The op-level `$label` for the item the op creates.
197    pub fn label(&self) -> Option<&str> {
198        match self {
199            Op::Copy { label, .. } | Op::Wrap { label, .. } | Op::Split { label, .. } => {
200                label.as_deref()
201            }
202            _ => None,
203        }
204    }
205
206    /// The page a `move` or `copy` puts items on, when not the edited one.
207    pub fn to_page(&self) -> Option<&str> {
208        match self {
209            Op::Move(transfer) | Op::Copy { transfer, .. } => transfer.to_page.as_deref(),
210            _ => None,
211        }
212    }
213}
214
215/// Which patch tool the ops are for; decides the allowed ops and fields.
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub enum PatchTarget {
218    PageContent,
219    CourseStructure,
220    CourseSettings,
221    GlossaryAndReferences,
222}
223
224impl PatchTarget {
225    /// The name of the tool that takes these ops.
226    pub fn tool_name(self) -> &'static str {
227        match self {
228            PatchTarget::PageContent => "patch_page_content",
229            PatchTarget::CourseStructure => "patch_course_structure",
230            PatchTarget::CourseSettings => "patch_course_settings",
231            PatchTarget::GlossaryAndReferences => "patch_course_glossary_and_references",
232        }
233    }
234
235    /// The ops the tool takes.
236    pub fn allowed_ops(self) -> &'static [OpKind] {
237        match self {
238            PatchTarget::PageContent => &[
239                OpKind::Add,
240                OpKind::Remove,
241                OpKind::Replace,
242                OpKind::Move,
243                OpKind::Copy,
244                OpKind::Wrap,
245                OpKind::Unwrap,
246                OpKind::Split,
247                OpKind::Merge,
248            ],
249            PatchTarget::CourseStructure => &[
250                OpKind::Add,
251                OpKind::Remove,
252                OpKind::Replace,
253                OpKind::Move,
254                OpKind::Copy,
255                OpKind::Reorder,
256            ],
257            PatchTarget::CourseSettings | PatchTarget::GlossaryAndReferences => {
258                &[OpKind::Add, OpKind::Remove, OpKind::Replace]
259            }
260        }
261    }
262
263    /// (required fields, optional fields) besides `op` and `path`.
264    fn fields(self, op: OpKind) -> (&'static [&'static str], &'static [&'static str]) {
265        use OpKind::*;
266        match (self, op) {
267            (_, Add) => (&["value"], &["position"]),
268            (PatchTarget::PageContent | PatchTarget::CourseStructure, Remove) => {
269                (&[], &["through"])
270            }
271            (_, Remove) => (&[], &[]),
272            (_, Replace) => (&["value"], &[]),
273            (PatchTarget::PageContent, Move) => (&["from", "position"], &["through", "to_page"]),
274            (_, Move) => (&["from", "position"], &["through"]),
275            (PatchTarget::PageContent, Copy) => {
276                (&["from", "position"], &["through", "to_page", "label"])
277            }
278            (_, Copy) => (&["from", "position"], &["label"]),
279            (_, Wrap) => (&["value"], &["through", "label"]),
280            (_, Unwrap) => (&[], &[]),
281            (_, Split) => (&["at_text"], &["label"]),
282            (_, Merge) => (&["from"], &[]),
283            (_, Reorder) => (&["value"], &[]),
284        }
285    }
286
287    /// The error for an op outside [`PatchTarget::allowed_ops`].
288    pub(crate) fn rejected_op(self, op: OpKind) -> EditError {
289        let fix = if self == PatchTarget::PageContent && op == OpKind::Reorder {
290            REORDER_ON_PAGE_FIX.to_string()
291        } else {
292            self.allowed_ops_fix()
293        };
294        EditError::new(
295            EditErrorCode::InvalidOp,
296            format!("{} doesn't take op \"{op}\"", self.tool_name()),
297        )
298        .with_fix(fix)
299    }
300
301    fn allowed_ops_fix(self) -> String {
302        format!(
303            "{} takes: {}",
304            self.tool_name(),
305            self.allowed_ops()
306                .iter()
307                .map(OpKind::to_string)
308                .collect::<Vec<_>>()
309                .join(", ")
310        )
311    }
312}
313
314const REORDER_ON_PAGE_FIX: &str = "reorder blocks with replace on the children list using stubs: {\"op\":\"replace\",\"path\":\"/box1/innerBlocks\",\"value\":[{\"clientId\":\"c2\"},{\"clientId\":\"c1\"}]}";
315
316const ALL_FIELDS: [&str; 9] = [
317    "op", "path", "from", "through", "value", "position", "to_page", "label", "at_text",
318];
319
320/// Parses raw op objects, rejecting unknown fields and fields the op doesn't take.
321pub fn parse_ops(raw: &[Value], target: PatchTarget) -> Result<Vec<Op>, EditError> {
322    if raw.is_empty() {
323        return Err(EditError::new(
324            EditErrorCode::MissingField,
325            "ops is empty; send at least one op",
326        ));
327    }
328    raw.iter()
329        .enumerate()
330        .map(|(index, value)| parse_op(value, target).map_err(|e| e.at_op(index)))
331        .collect()
332}
333
334fn parse_op(raw: &Value, target: PatchTarget) -> Result<Op, EditError> {
335    let Some(object) = raw.as_object() else {
336        return Err(EditError::new(
337            EditErrorCode::InvalidOp,
338            "each op must be an object like {\"op\":\"remove\",\"path\":\"/p1\"}",
339        ));
340    };
341    let op_name = match object.get("op") {
342        Some(Value::String(name)) => name.as_str(),
343        _ => {
344            return Err(EditError::new(
345                EditErrorCode::MissingField,
346                "op is missing; every op has \"op\" and \"path\"",
347            ));
348        }
349    };
350    let Some(op) = from_wire_name::<OpKind>(op_name) else {
351        let message = if op_name == "test" {
352            "op \"test\" isn't supported; ops already apply all-or-nothing, and an op whose target is gone fails on its own".to_string()
353        } else {
354            format!("unknown op \"{op_name}\"")
355        };
356        return Err(
357            EditError::new(EditErrorCode::InvalidOp, message).with_fix(target.allowed_ops_fix())
358        );
359    };
360    if !target.allowed_ops().contains(&op) {
361        return Err(target.rejected_op(op));
362    }
363
364    check_unknown_fields(object)?;
365
366    let (required, optional) = target.fields(op);
367    for field in ALL_FIELDS.iter().skip(2) {
368        // `"value": null` is a real value: it clears a nullable field.
369        let present = if *field == "value" {
370            object.contains_key("value")
371        } else {
372            object.get(*field).is_some_and(|v| !v.is_null())
373        };
374        if present && !required.contains(field) && !optional.contains(field) {
375            return Err(field_not_allowed(op, field, target));
376        }
377        if !present && required.contains(field) {
378            return Err(missing_field(op, field));
379        }
380    }
381
382    let path = match object.get("path") {
383        Some(Value::String(path)) => path.clone(),
384        Some(_) => {
385            return Err(EditError::new(
386                EditErrorCode::InvalidPath,
387                "path must be a string such as \"/p1\" or \"\"",
388            ));
389        }
390        None => {
391            return Err(EditError::new(
392                EditErrorCode::MissingField,
393                format!("{op_name} needs \"path\""),
394            ));
395        }
396    };
397
398    let position = match object.get("position") {
399        None | Some(Value::Null) => None,
400        Some(Value::String(name)) => {
401            Some(from_wire_name::<Position>(name).ok_or_else(|| invalid_position(name))?)
402        }
403        Some(other) => return Err(invalid_position(&other.to_string())),
404    };
405
406    let string = |field| optional_string(object, field);
407    let required_string = |field| string(field)?.ok_or_else(|| missing_field(op, field));
408    let value = || {
409        object
410            .get("value")
411            .cloned()
412            .ok_or_else(|| missing_field(op, "value"))
413    };
414    let transfer = || -> Result<Transfer, EditError> {
415        Ok(Transfer {
416            from: required_string("from")?,
417            through: string("through")?,
418            path: path.clone(),
419            position: position.ok_or_else(|| missing_field(op, "position"))?,
420            to_page: string("to_page")?,
421        })
422    };
423    Ok(match op {
424        OpKind::Add => Op::Add {
425            path,
426            value: value()?,
427            position,
428        },
429        OpKind::Remove => Op::Remove {
430            through: string("through")?,
431            path,
432        },
433        OpKind::Replace => Op::Replace {
434            path,
435            value: value()?,
436        },
437        OpKind::Move => Op::Move(transfer()?),
438        OpKind::Copy => Op::Copy {
439            transfer: transfer()?,
440            label: string("label")?,
441        },
442        OpKind::Wrap => Op::Wrap {
443            through: string("through")?,
444            label: string("label")?,
445            path,
446            value: value()?,
447        },
448        OpKind::Unwrap => Op::Unwrap { path },
449        OpKind::Split => Op::Split {
450            label: string("label")?,
451            at_text: required_string("at_text")?,
452            path,
453        },
454        OpKind::Merge => Op::Merge {
455            from: required_string("from")?,
456            path,
457        },
458        OpKind::Reorder => Op::Reorder {
459            path,
460            value: value()?,
461        },
462    })
463}
464
465fn check_unknown_fields(object: &Map<String, Value>) -> Result<(), EditError> {
466    let unknown: Vec<&String> = object
467        .keys()
468        .filter(|k| !ALL_FIELDS.contains(&k.as_str()))
469        .collect();
470    if unknown.is_empty() {
471        return Ok(());
472    }
473    let reads_only = unknown
474        .iter()
475        .any(|k| matches!(k.as_str(), "offset" | "limit" | "index" | "around"));
476    let message = if reads_only {
477        format!(
478            "unknown field(s) {}: offsets and windows exist only in reads; writes address things by id",
479            quote_list(&unknown)
480        )
481    } else {
482        format!("unknown field(s) {}", quote_list(&unknown))
483    };
484    Err(EditError::new(EditErrorCode::UnknownField, message)
485        .with_fix(format!("op fields are: {}", ALL_FIELDS.join(", "))))
486}
487
488fn field_not_allowed(op: OpKind, field: &str, target: PatchTarget) -> EditError {
489    let (required, optional) = target.fields(op);
490    let hint = match (op, field) {
491        (OpKind::Add, "from") => {
492            Some("add creates new items from value; to relocate existing ones use move")
493        }
494        (OpKind::Add, "through") => {
495            Some("add inserts the values given; ranges belong to move, copy, remove and wrap")
496        }
497        (OpKind::Replace, "position") => {
498            Some("replace edits the thing at path in place; use move to relocate it")
499        }
500        (OpKind::Remove, "value") => Some("remove takes no value"),
501        (OpKind::Merge, "through") => {
502            Some("merge joins one block (from) into the block before it (path)")
503        }
504        _ => None,
505    };
506    let fix = if (op, field) == (OpKind::Add, "label") {
507        "put \"label\" inside each value item: {\"op\":\"add\",...,\"value\":{\"label\":\"$x\",...}}".to_string()
508    } else {
509        let accepted = [["op", "path"].as_slice(), required, optional].concat();
510        format!("{op} takes: {}", accepted.join(", "))
511    };
512    EditError::new(
513        EditErrorCode::FieldNotAllowed,
514        format!(
515            "{} doesn't take \"{field}\"{}",
516            op,
517            hint.map(|h| format!("; {h}")).unwrap_or_default()
518        ),
519    )
520    .with_fix(fix)
521}
522
523fn missing_field(op: OpKind, field: &str) -> EditError {
524    let fix = match (op, field) {
525        (OpKind::Move | OpKind::Copy, "position") => {
526            "position is before/after a sibling at path, or start/end of the children list at path".to_string()
527        }
528        (OpKind::Move | OpKind::Copy, "from") => {
529            "from is the item being moved or copied, path is where it goes: {\"op\":\"move\",\"from\":\"/p4\",\"path\":\"/h2\",\"position\":\"after\"}".to_string()
530        }
531        (OpKind::Merge, "from") => {
532            "{\"op\":\"merge\",\"from\":\"/p8\",\"path\":\"/p7\"} merges p8 into p7".to_string()
533        }
534        (OpKind::Split, "at_text") => {
535            "{\"op\":\"split\",\"path\":\"/p7\",\"at_text\":\"However,\"} starts a new block at \"However,\"".to_string()
536        }
537        _ => format!("add \"{field}\""),
538    };
539    EditError::new(
540        EditErrorCode::MissingField,
541        format!("{op} needs \"{field}\""),
542    )
543    .with_fix(fix)
544}
545
546fn invalid_position(given: &str) -> EditError {
547    EditError::new(
548        EditErrorCode::NumericIndex,
549        format!("position {given} isn't supported; numeric indexes and \"-\" are rejected"),
550    )
551    .with_fix("position is \"before\" or \"after\" a sibling at path, or \"start\" or \"end\" of the children list at path, e.g. {\"path\":\"/box1/innerBlocks\",\"position\":\"end\"}")
552    .with_details(json!({"positions": Position::ALL}))
553}
554
555fn optional_string(object: &Map<String, Value>, field: &str) -> Result<Option<String>, EditError> {
556    match object.get(field) {
557        None | Some(Value::Null) => Ok(None),
558        Some(Value::String(s)) => Ok(Some(s.clone())),
559        Some(_) => Err(EditError::new(
560            EditErrorCode::InvalidValue,
561            format!("{field} must be a string"),
562        )),
563    }
564}
565
566/// `items` double-quoted and comma-separated, for error messages.
567pub(crate) fn quote_list<S: AsRef<str>>(items: &[S]) -> String {
568    items
569        .iter()
570        .map(|s| format!("\"{}\"", s.as_ref()))
571        .collect::<Vec<_>>()
572        .join(", ")
573}
574
575#[cfg(test)]
576mod tests {
577    use super::*;
578
579    fn parse_one(op: Value, target: PatchTarget) -> Result<Op, EditError> {
580        parse_ops(&[op], target).map(|mut ops| ops.remove(0))
581    }
582
583    #[test]
584    fn parses_a_full_move() {
585        let op = parse_one(
586            json!({"op":"move","from":"/p5","through":"/p9","to_page":"pg2","path":"/h1","position":"after"}),
587            PatchTarget::PageContent,
588        )
589        .unwrap();
590        let Op::Move(transfer) = op else {
591            panic!("expected a move, got {op:?}");
592        };
593        assert_eq!(transfer.position, Position::After);
594        assert_eq!(transfer.through.as_deref(), Some("/p9"));
595    }
596
597    #[test]
598    fn rejects_test_op() {
599        let error = parse_one(
600            json!({"op":"test","path":"/p1","value":1}),
601            PatchTarget::PageContent,
602        )
603        .unwrap_err();
604        assert_eq!(error.code, EditErrorCode::InvalidOp);
605        assert!(error.message.contains("test"));
606        assert_eq!(error.op, Some(0));
607    }
608
609    #[test]
610    fn rejects_offset_in_writes() {
611        let error = parse_one(
612            json!({"op":"remove","path":"/p1","offset":3}),
613            PatchTarget::PageContent,
614        )
615        .unwrap_err();
616        assert_eq!(error.code, EditErrorCode::UnknownField);
617        assert!(error.message.contains("only in reads"));
618    }
619
620    #[test]
621    fn rejects_numeric_position() {
622        let error = parse_one(
623            json!({"op":"add","path":"","position":3,"value":{}}),
624            PatchTarget::PageContent,
625        )
626        .unwrap_err();
627        assert_eq!(error.code, EditErrorCode::NumericIndex);
628    }
629
630    #[test]
631    fn add_position_optional_in_settings() {
632        parse_one(
633            json!({"op":"add","path":"/course/audiences","value":{"name":"x"}}),
634            PatchTarget::CourseSettings,
635        )
636        .unwrap();
637    }
638
639    #[test]
640    fn reorder_on_page_points_to_stubs() {
641        let error = parse_one(
642            json!({"op":"reorder","path":"","value":["a"]}),
643            PatchTarget::PageContent,
644        )
645        .unwrap_err();
646        assert!(error.fix.unwrap().contains("stubs"));
647    }
648
649    #[test]
650    fn rejects_fields_an_op_does_not_take() {
651        let error = parse_one(
652            json!({"op":"replace","path":"/p1","value":{},"position":"after"}),
653            PatchTarget::PageContent,
654        )
655        .unwrap_err();
656        assert_eq!(error.code, EditErrorCode::FieldNotAllowed);
657    }
658
659    #[test]
660    fn replace_keeps_explicit_null() {
661        let op = parse_one(
662            json!({"op":"replace","path":"/ch1/deadline","value":null}),
663            PatchTarget::CourseStructure,
664        )
665        .unwrap();
666        assert_eq!(op.value(), Some(&Value::Null));
667    }
668}