1use 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#[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
29impl fmt::Display for OpKind {
31 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32 self.serialize(f)
33 }
34}
35
36#[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
55impl fmt::Display for Position {
57 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58 self.serialize(f)
59 }
60}
61
62fn 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
70pub struct PatchOp {
71 pub op: OpKind,
72 pub path: String,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub from: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub through: Option<String>,
80 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub to_page: Option<String>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub label: Option<String>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub at_text: Option<String>,
95}
96
97#[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#[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 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub enum PatchTarget {
218 PageContent,
219 CourseStructure,
220 CourseSettings,
221 GlossaryAndReferences,
222}
223
224impl PatchTarget {
225 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 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 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 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
320pub 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 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
566pub(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}