1use std::collections::{BTreeMap, HashMap};
27
28use omgbase_format::hash::{hex, sha256};
29use serde_json::{Map, Value, json};
30
31use crate::changeset::Op;
32use crate::error::{ErrorCode, MutationError, Result};
33use crate::ops::{At, Expect, OpResult, Parent, To};
34use crate::opset::{OpsetSummary, PlanDisposition};
35
36pub const PLAN_VERSION: u64 = 1;
38pub const PLANNER_V: &str = "p3";
43pub const SELECTION_CEILING: usize = 1000;
46
47#[must_use]
51pub fn parse_new_symbol(s: &str) -> Option<usize> {
52 let n = s.strip_prefix("$new.")?;
53 if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) {
54 return None;
55 }
56 n.parse().ok()
57}
58
59#[must_use]
61pub fn new_symbol(n: usize) -> String {
62 format!("$new.{n}")
63}
64
65#[derive(Clone, Debug, PartialEq, Eq)]
71pub enum Target {
72 Document {
73 id: Option<String>,
74 path: Option<String>,
75 },
76 Block {
77 id: String,
78 },
79 Node {
80 id: String,
81 },
82 Section {
83 heading_id: String,
84 },
85}
86
87impl Target {
88 #[must_use]
90 pub const fn kind(&self) -> &'static str {
91 match self {
92 Target::Document { .. } => "document",
93 Target::Block { .. } => "block",
94 Target::Node { .. } => "node",
95 Target::Section { .. } => "section",
96 }
97 }
98
99 #[must_use]
102 pub fn id(&self) -> Option<&str> {
103 match self {
104 Target::Document { id, .. } => id.as_deref(),
105 Target::Block { id } | Target::Node { id } => Some(id),
106 Target::Section { heading_id } => Some(heading_id),
107 }
108 }
109
110 #[must_use]
112 pub fn to_json(&self) -> Value {
113 let mut m = Map::new();
114 m.insert("kind".to_owned(), json!(self.kind()));
115 match self {
116 Target::Document { id, path } => {
117 if let Some(id) = id {
118 m.insert("id".to_owned(), json!(id));
119 }
120 if let Some(path) = path {
121 m.insert("path".to_owned(), json!(path));
122 }
123 }
124 Target::Block { id } | Target::Node { id } => {
125 m.insert("id".to_owned(), json!(id));
126 }
127 Target::Section { heading_id } => {
128 m.insert("heading_id".to_owned(), json!(heading_id));
129 }
130 }
131 Value::Object(m)
132 }
133
134 pub fn from_json(v: &Value) -> std::result::Result<Target, String> {
136 validate_target(Some(v), "target", &["document", "block", "node", "section"])
137 }
138}
139
140fn block_refs_to_json(ids: &[String]) -> Value {
142 Value::Array(
143 ids.iter()
144 .map(|id| Target::Block { id: id.clone() }.to_json())
145 .collect(),
146 )
147}
148
149#[derive(Clone, Debug, Default, PartialEq, Eq)]
154pub struct EffectExpect {
155 pub content_hash: Option<String>,
156 pub parent_children_hash: Option<String>,
157 pub revision: Option<String>,
158}
159
160impl EffectExpect {
161 #[must_use]
163 pub fn kernel(&self) -> Option<Expect> {
164 if self.content_hash.is_none() && self.parent_children_hash.is_none() {
165 return None;
166 }
167 Some(Expect {
168 content_hash: self.content_hash.clone(),
169 parent_children_hash: self.parent_children_hash.clone(),
170 })
171 }
172
173 #[must_use]
174 pub fn to_json(&self) -> Value {
175 let mut m = Map::new();
176 if let Some(h) = &self.content_hash {
177 m.insert("content_hash".to_owned(), json!(h));
178 }
179 if let Some(h) = &self.parent_children_hash {
180 m.insert("parent_children_hash".to_owned(), json!(h));
181 }
182 if let Some(r) = &self.revision {
183 m.insert("revision".to_owned(), json!(r));
184 }
185 Value::Object(m)
186 }
187
188 pub fn from_json(v: &Value) -> std::result::Result<Self, String> {
189 validate_expect(Some(v), "expect").map(|e| e.expect("given"))
190 }
191}
192
193#[derive(Clone, Debug, PartialEq, Eq)]
197pub struct Repair {
198 pub from: String,
199 pub to: String,
200}
201
202pub type ExpectMap = BTreeMap<String, Expect>;
204
205#[derive(Clone, Debug, PartialEq)]
208pub enum Effect {
209 Create {
212 path: String,
213 markdown: String,
214 frontmatter: Option<Map<String, Value>>,
215 },
216 Insert {
219 into: Target,
220 at: At,
221 markdown: String,
222 expect: Option<EffectExpect>,
223 },
224 Set {
228 target: Target,
229 field: String,
230 value: Value,
231 expect: Option<EffectExpect>,
232 },
233 Unset {
235 target: Target,
236 field: String,
237 expect: Option<EffectExpect>,
238 },
239 MoveDocument {
242 target: Target,
243 to: String,
244 retarget_inbound: bool,
245 },
246 MoveBlocks {
248 targets: Vec<String>,
249 into: Target,
250 at: At,
251 expect: Option<EffectExpect>,
252 },
253 RemoveDocument { target: Target },
255 RemoveBlocks {
257 targets: Vec<String>,
258 expect: Option<ExpectMap>,
259 },
260 Split {
262 target: String,
263 at: Vec<usize>,
264 expect: Option<EffectExpect>,
265 },
266 Merge {
269 targets: Vec<String>,
270 separator: Option<String>,
271 expect: Option<ExpectMap>,
272 },
273 Retarget {
275 repairs: Vec<Repair>,
276 path_glob: Option<String>,
277 },
278}
279
280#[must_use]
282pub fn at_to_json(at: &At) -> Value {
283 match at {
284 At::Start => json!("start"),
285 At::End => json!("end"),
286 At::Before(id) => json!({ "before": id }),
287 At::After(id) => json!({ "after": id }),
288 }
289}
290
291pub fn at_from_json(v: Option<&Value>, here: &str) -> std::result::Result<At, String> {
293 match v {
294 None => Ok(At::End),
295 Some(Value::String(s)) if s == "start" => Ok(At::Start),
296 Some(Value::String(s)) if s == "end" => Ok(At::End),
297 Some(Value::Object(a)) if a.len() == 1 => {
298 if let Some(b) = a.get("before").and_then(Value::as_str) {
299 Ok(At::Before(b.to_owned()))
300 } else if let Some(b) = a.get("after").and_then(Value::as_str) {
301 Ok(At::After(b.to_owned()))
302 } else {
303 Err(format!(
304 "{here}: `at` must be \"start\", \"end\", {{ before: <block id> }} or {{ after: <block id> }}"
305 ))
306 }
307 }
308 _ => Err(format!(
309 "{here}: `at` must be \"start\", \"end\", {{ before: <block id> }} or {{ after: <block id> }}"
310 )),
311 }
312}
313
314fn require_string(
315 obj: &Map<String, Value>,
316 key: &str,
317 here: &str,
318) -> std::result::Result<String, String> {
319 obj.get(key)
320 .and_then(Value::as_str)
321 .map(str::to_owned)
322 .ok_or_else(|| format!("{here}: `{key}` must be a string"))
323}
324
325fn optional_string(
326 obj: &Map<String, Value>,
327 key: &str,
328 here: &str,
329) -> std::result::Result<Option<String>, String> {
330 match obj.get(key) {
331 None => Ok(None),
332 Some(Value::String(s)) => Ok(Some(s.clone())),
333 Some(_) => Err(format!("{here}: `{key}` must be a string")),
334 }
335}
336
337fn only_keys(
338 obj: &Map<String, Value>,
339 allowed: &[&str],
340 here: &str,
341) -> std::result::Result<(), String> {
342 let extra: Vec<String> = obj
343 .keys()
344 .filter(|k| !allowed.contains(&k.as_str()))
345 .map(|k| format!("`{k}`"))
346 .collect();
347 if extra.is_empty() {
348 Ok(())
349 } else {
350 Err(format!(
351 "{here}: unknown key{} {}",
352 if extra.len() > 1 { "s" } else { "" },
353 extra.join(", ")
354 ))
355 }
356}
357
358fn validate_target(
360 v: Option<&Value>,
361 here: &str,
362 kinds: &[&str],
363) -> std::result::Result<Target, String> {
364 let kinds_text = kinds
365 .iter()
366 .map(|k| format!("\"{k}\""))
367 .collect::<Vec<_>>()
368 .join(" | ");
369 let obj = v
370 .and_then(Value::as_object)
371 .filter(|o| o.get("kind").is_some_and(Value::is_string))
372 .ok_or_else(|| format!("{here}: a target is {{ kind: {kinds_text}, … }}"))?;
373 let kind = obj["kind"].as_str().expect("checked");
374 if !kinds.contains(&kind) {
375 return Err(format!(
376 "{here}: `kind` must be one of {} (got \"{kind}\")",
377 kinds.join(", ")
378 ));
379 }
380 Ok(match kind {
381 "document" => {
382 only_keys(obj, &["kind", "id", "path"], here)?;
383 let id = optional_string(obj, "id", here)?;
384 let path = optional_string(obj, "path", here)?;
385 if id.is_none() && path.is_none() {
386 return Err(format!("{here}: a document target needs `id` or `path`"));
387 }
388 Target::Document { id, path }
389 }
390 "block" => {
391 only_keys(obj, &["kind", "id"], here)?;
392 Target::Block {
393 id: require_string(obj, "id", here)?,
394 }
395 }
396 "node" => {
397 only_keys(obj, &["kind", "id"], here)?;
398 Target::Node {
399 id: require_string(obj, "id", here)?,
400 }
401 }
402 "section" => {
403 only_keys(obj, &["kind", "heading_id"], here)?;
404 Target::Section {
405 heading_id: require_string(obj, "heading_id", here)?,
406 }
407 }
408 _ => unreachable!("kinds are the four above"),
409 })
410}
411
412fn validate_targets(v: Option<&Value>, here: &str) -> std::result::Result<Vec<String>, String> {
414 let list = v
415 .and_then(Value::as_array)
416 .filter(|a| !a.is_empty())
417 .ok_or_else(|| format!("{here}: `targets` must be a non-empty array of block targets"))?;
418 list.iter()
419 .enumerate()
420 .map(|(i, t)| {
421 match validate_target(Some(t), &format!("{here}.targets[{i}]"), &["block"])? {
422 Target::Block { id } => Ok(id),
423 _ => unreachable!("kinds is [block]"),
424 }
425 })
426 .collect()
427}
428
429fn validate_expect(
430 v: Option<&Value>,
431 here: &str,
432) -> std::result::Result<Option<EffectExpect>, String> {
433 let Some(v) = v else { return Ok(None) };
434 let obj = v
435 .as_object()
436 .ok_or_else(|| format!("{here}: `expect` must be an object"))?;
437 let at = format!("{here}.expect");
438 only_keys(
439 obj,
440 &["content_hash", "parent_children_hash", "revision"],
441 &at,
442 )?;
443 Ok(Some(EffectExpect {
444 content_hash: optional_string(obj, "content_hash", &at)?,
445 parent_children_hash: optional_string(obj, "parent_children_hash", &at)?,
446 revision: optional_string(obj, "revision", &at)?,
447 }))
448}
449
450fn validate_expect_map(
451 v: Option<&Value>,
452 here: &str,
453) -> std::result::Result<Option<ExpectMap>, String> {
454 let Some(v) = v else { return Ok(None) };
455 let obj = v
456 .as_object()
457 .ok_or_else(|| format!("{here}: `expect` must be an object keyed by block id"))?;
458 let mut out = ExpectMap::new();
459 for (id, x) in obj {
460 let at = format!("{here}.expect[{}]", Value::String(id.clone()));
461 let x = x
462 .as_object()
463 .ok_or_else(|| format!("{at}: must be an object"))?;
464 only_keys(x, &["content_hash", "parent_children_hash"], &at)?;
465 out.insert(
466 id.clone(),
467 Expect {
468 content_hash: optional_string(x, "content_hash", &at)?,
469 parent_children_hash: optional_string(x, "parent_children_hash", &at)?,
470 },
471 );
472 }
473 Ok(Some(out))
474}
475
476fn check_field(
478 target: &Target,
479 field: &str,
480 here: &str,
481 op: &str,
482) -> std::result::Result<(), String> {
483 match target {
484 Target::Document { .. } => {
485 if field
486 .strip_prefix("frontmatter.")
487 .is_some_and(|k| !k.is_empty())
488 || (op == "set" && field == "content")
489 {
490 Ok(())
491 } else {
492 Err(format!(
493 "{here}: a document's `field` is `frontmatter.<key>`{} (got \"{field}\")",
494 if op == "set" { " or `content`" } else { "" }
495 ))
496 }
497 }
498 Target::Block { .. } => {
499 if field == "markdown" || field == "attrs.checked" {
500 Ok(())
501 } else {
502 Err(format!(
503 "{here}: a block's `field` is `markdown` or `attrs.checked` (got \"{field}\")"
504 ))
505 }
506 }
507 Target::Node { .. } => {
508 if field.is_empty() || field.contains('.') {
509 Err(format!(
510 "{here}: a node's `field` is one of its editable properties (got \"{field}\")"
511 ))
512 } else {
513 Ok(())
514 }
515 }
516 Target::Section { .. } => Err(format!("{here}: a section has no settable field")),
517 }
518}
519
520fn expect_map_to_json(m: &ExpectMap) -> Value {
521 Value::Object(
522 m.iter()
523 .map(|(k, e)| {
524 let mut o = Map::new();
525 if let Some(h) = &e.content_hash {
526 o.insert("content_hash".to_owned(), json!(h));
527 }
528 if let Some(h) = &e.parent_children_hash {
529 o.insert("parent_children_hash".to_owned(), json!(h));
530 }
531 (k.clone(), Value::Object(o))
532 })
533 .collect(),
534 )
535}
536
537impl Effect {
538 #[must_use]
540 pub const fn name(&self) -> &'static str {
541 match self {
542 Effect::Create { .. } => "create",
543 Effect::Insert { .. } => "insert",
544 Effect::Set { .. } => "set",
545 Effect::Unset { .. } => "unset",
546 Effect::MoveDocument { .. } | Effect::MoveBlocks { .. } => "move",
547 Effect::RemoveDocument { .. } | Effect::RemoveBlocks { .. } => "remove",
548 Effect::Split { .. } => "split",
549 Effect::Merge { .. } => "merge",
550 Effect::Retarget { .. } => "retarget",
551 }
552 }
553
554 #[must_use]
558 pub fn to_json(&self) -> Value {
559 let mut m = Map::new();
560 m.insert("op".to_owned(), json!(self.name()));
561 let expect = |m: &mut Map<String, Value>, e: &Option<EffectExpect>| {
562 if let Some(e) = e {
563 m.insert("expect".to_owned(), e.to_json());
564 }
565 };
566 match self {
567 Effect::Create {
568 path,
569 markdown,
570 frontmatter,
571 } => {
572 m.insert("path".to_owned(), json!(path));
573 m.insert("markdown".to_owned(), json!(markdown));
574 if let Some(fm) = frontmatter {
575 m.insert("frontmatter".to_owned(), Value::Object(fm.clone()));
576 }
577 }
578 Effect::Insert {
579 into,
580 at,
581 markdown,
582 expect: e,
583 } => {
584 m.insert("into".to_owned(), into.to_json());
585 m.insert("at".to_owned(), at_to_json(at));
586 m.insert("markdown".to_owned(), json!(markdown));
587 expect(&mut m, e);
588 }
589 Effect::Set {
590 target,
591 field,
592 value,
593 expect: e,
594 } => {
595 m.insert("target".to_owned(), target.to_json());
596 m.insert("field".to_owned(), json!(field));
597 m.insert("value".to_owned(), value.clone());
598 expect(&mut m, e);
599 }
600 Effect::Unset {
601 target,
602 field,
603 expect: e,
604 } => {
605 m.insert("target".to_owned(), target.to_json());
606 m.insert("field".to_owned(), json!(field));
607 expect(&mut m, e);
608 }
609 Effect::MoveDocument {
610 target,
611 to,
612 retarget_inbound,
613 } => {
614 m.insert("target".to_owned(), target.to_json());
615 m.insert("to".to_owned(), json!(to));
616 m.insert("retarget_inbound".to_owned(), json!(retarget_inbound));
617 }
618 Effect::MoveBlocks {
619 targets,
620 into,
621 at,
622 expect: e,
623 } => {
624 m.insert("targets".to_owned(), block_refs_to_json(targets));
625 m.insert("into".to_owned(), into.to_json());
626 m.insert("at".to_owned(), at_to_json(at));
627 expect(&mut m, e);
628 }
629 Effect::RemoveDocument { target } => {
630 m.insert("target".to_owned(), target.to_json());
631 }
632 Effect::RemoveBlocks { targets, expect: e } => {
633 m.insert("targets".to_owned(), block_refs_to_json(targets));
634 if let Some(e) = e {
635 m.insert("expect".to_owned(), expect_map_to_json(e));
636 }
637 }
638 Effect::Split {
639 target,
640 at,
641 expect: e,
642 } => {
643 m.insert(
644 "target".to_owned(),
645 Target::Block { id: target.clone() }.to_json(),
646 );
647 m.insert("at".to_owned(), json!(at));
648 expect(&mut m, e);
649 }
650 Effect::Merge {
651 targets,
652 separator,
653 expect: e,
654 } => {
655 m.insert("targets".to_owned(), block_refs_to_json(targets));
656 if let Some(s) = separator {
657 m.insert("separator".to_owned(), json!(s));
658 }
659 if let Some(e) = e {
660 m.insert("expect".to_owned(), expect_map_to_json(e));
661 }
662 }
663 Effect::Retarget { repairs, path_glob } => {
664 m.insert(
665 "repairs".to_owned(),
666 Value::Array(
667 repairs
668 .iter()
669 .map(|r| json!({ "from": r.from, "to": r.to }))
670 .collect(),
671 ),
672 );
673 if let Some(g) = path_glob {
674 m.insert("path_glob".to_owned(), json!(g));
675 }
676 }
677 }
678 Value::Object(m)
679 }
680
681 pub fn validate(v: &Value, i: usize) -> std::result::Result<Effect, String> {
686 let here = format!("effects[{i}]");
687 let ops = "create/insert/set/unset/move/remove/split/merge/retarget";
688 let obj = v
689 .as_object()
690 .filter(|o| o.get("op").is_some_and(Value::is_string))
691 .ok_or_else(|| format!("{here}: an effect is an object whose `op` is one of {ops}"))?;
692 let op = obj["op"].as_str().expect("checked");
693 Ok(match op {
694 "create" => {
695 only_keys(obj, &["op", "path", "markdown", "frontmatter"], &here)?;
696 let path = require_string(obj, "path", &here)?;
697 let markdown = require_string(obj, "markdown", &here)?;
698 let frontmatter = match obj.get("frontmatter") {
699 None => None,
700 Some(Value::Object(fm)) => Some(fm.clone()),
701 Some(_) => return Err(format!("{here}: `frontmatter` must be an object")),
702 };
703 Effect::Create {
704 path,
705 markdown,
706 frontmatter,
707 }
708 }
709 "insert" => {
710 only_keys(obj, &["op", "into", "at", "markdown", "expect"], &here)?;
711 let into = validate_target(
712 obj.get("into"),
713 &format!("{here}.into"),
714 &["document", "section", "block"],
715 )?;
716 let at = at_from_json(obj.get("at"), &here)?;
717 let markdown = require_string(obj, "markdown", &here)?;
718 let expect = validate_expect(obj.get("expect"), &here)?;
719 Effect::Insert {
720 into,
721 at,
722 markdown,
723 expect,
724 }
725 }
726 "set" => {
727 only_keys(obj, &["op", "target", "field", "value", "expect"], &here)?;
728 let target = validate_target(
729 obj.get("target"),
730 &format!("{here}.target"),
731 &["document", "block", "node"],
732 )?;
733 let field = require_string(obj, "field", &here)?;
734 let value = obj
735 .get("value")
736 .cloned()
737 .ok_or_else(|| format!("{here}: `value` is required"))?;
738 check_field(&target, &field, &here, "set")?;
739 let expect = validate_expect(obj.get("expect"), &here)?;
740 Effect::Set {
741 target,
742 field,
743 value,
744 expect,
745 }
746 }
747 "unset" => {
748 only_keys(obj, &["op", "target", "field", "expect"], &here)?;
749 let target =
750 validate_target(obj.get("target"), &format!("{here}.target"), &["document"])?;
751 let field = require_string(obj, "field", &here)?;
752 check_field(&target, &field, &here, "unset")?;
753 let expect = validate_expect(obj.get("expect"), &here)?;
754 Effect::Unset {
755 target,
756 field,
757 expect,
758 }
759 }
760 "move" => {
761 if obj.contains_key("targets") {
762 only_keys(obj, &["op", "targets", "into", "at", "expect"], &here)?;
763 let targets = validate_targets(obj.get("targets"), &here)?;
764 let into = validate_target(
765 obj.get("into"),
766 &format!("{here}.into"),
767 &["document", "section", "block"],
768 )?;
769 let at = at_from_json(obj.get("at"), &here)?;
770 let expect = validate_expect(obj.get("expect"), &here)?;
771 Effect::MoveBlocks {
772 targets,
773 into,
774 at,
775 expect,
776 }
777 } else {
778 only_keys(obj, &["op", "target", "to", "retarget_inbound"], &here)?;
779 let target = validate_target(
780 obj.get("target"),
781 &format!("{here}.target"),
782 &["document"],
783 )?;
784 let to = require_string(obj, "to", &here)?;
785 let retarget_inbound = match obj.get("retarget_inbound") {
786 None => true,
787 Some(Value::Bool(b)) => *b,
788 Some(_) => {
789 return Err(format!("{here}: `retarget_inbound` must be a boolean"));
790 }
791 };
792 Effect::MoveDocument {
793 target,
794 to,
795 retarget_inbound,
796 }
797 }
798 }
799 "remove" => {
800 if obj.contains_key("targets") {
801 only_keys(obj, &["op", "targets", "expect"], &here)?;
802 Effect::RemoveBlocks {
803 targets: validate_targets(obj.get("targets"), &here)?,
804 expect: validate_expect_map(obj.get("expect"), &here)?,
805 }
806 } else {
807 only_keys(obj, &["op", "target"], &here)?;
808 Effect::RemoveDocument {
809 target: validate_target(
810 obj.get("target"),
811 &format!("{here}.target"),
812 &["document"],
813 )?,
814 }
815 }
816 }
817 "split" => {
818 only_keys(obj, &["op", "target", "at", "expect"], &here)?;
819 let Target::Block { id } =
820 validate_target(obj.get("target"), &format!("{here}.target"), &["block"])?
821 else {
822 unreachable!("kinds is [block]")
823 };
824 let at = obj
825 .get("at")
826 .and_then(Value::as_array)
827 .and_then(|a| {
828 a.iter()
829 .map(|n| n.as_u64().and_then(|n| usize::try_from(n).ok()))
830 .collect::<Option<Vec<usize>>>()
831 })
832 .ok_or_else(|| format!("{here}: `at` must be an array of byte offsets"))?;
833 let expect = validate_expect(obj.get("expect"), &here)?;
834 Effect::Split {
835 target: id,
836 at,
837 expect,
838 }
839 }
840 "merge" => {
841 only_keys(obj, &["op", "targets", "separator", "expect"], &here)?;
842 Effect::Merge {
843 targets: validate_targets(obj.get("targets"), &here)?,
844 separator: optional_string(obj, "separator", &here)?,
845 expect: validate_expect_map(obj.get("expect"), &here)?,
846 }
847 }
848 "retarget" => {
849 only_keys(obj, &["op", "repairs", "path_glob"], &here)?;
850 let repairs = obj
851 .get("repairs")
852 .and_then(Value::as_array)
853 .ok_or_else(|| format!("{here}: `repairs` must be an array of {{ from, to }}"))?
854 .iter()
855 .enumerate()
856 .map(|(j, r)| {
857 let at = format!("{here}.repairs[{j}]");
858 let r = r
859 .as_object()
860 .ok_or_else(|| format!("{at}: must be {{ from, to }}"))?;
861 only_keys(r, &["from", "to"], &at)?;
862 Ok(Repair {
863 from: require_string(r, "from", &at)?,
864 to: require_string(r, "to", &at)?,
865 })
866 })
867 .collect::<std::result::Result<Vec<_>, String>>()?;
868 Effect::Retarget {
869 repairs,
870 path_glob: optional_string(obj, "path_glob", &here)?,
871 }
872 }
873 other => {
874 return Err(format!(
875 "{here}: `op` must be one of {ops} (got \"{other}\")"
876 ));
877 }
878 })
879 }
880
881 pub fn from_json(v: &Value) -> std::result::Result<Effect, String> {
883 Effect::validate(v, 0)
884 }
885
886 pub fn list_from_json(v: &Value) -> std::result::Result<Vec<Effect>, String> {
889 let list = v
890 .as_array()
891 .ok_or("`effects` must be an array of effects")?;
892 if list.is_empty() {
893 return Err("`effects` must name at least one effect".into());
894 }
895 list.iter()
896 .enumerate()
897 .map(|(i, e)| Effect::validate(e, i))
898 .collect()
899 }
900
901 #[must_use]
903 pub fn expect(&self) -> Option<&EffectExpect> {
904 match self {
905 Effect::Insert { expect, .. }
906 | Effect::Set { expect, .. }
907 | Effect::Unset { expect, .. }
908 | Effect::MoveBlocks { expect, .. }
909 | Effect::Split { expect, .. } => expect.as_ref(),
910 _ => None,
911 }
912 }
913
914 #[must_use]
917 pub fn describe(&self) -> String {
918 fn name(t: &Target) -> String {
919 match t {
920 Target::Section { heading_id } => format!("section {heading_id}"),
921 other => format!("{} {}", other.kind(), other.id().unwrap_or_default()),
922 }
923 }
924 fn blocks(ids: &[String]) -> String {
925 ids.iter()
926 .map(|id| format!("block {id}"))
927 .collect::<Vec<_>>()
928 .join(",")
929 }
930 match self {
931 Effect::Create { path, .. } => format!("create {path}"),
932 Effect::Insert { into, .. } => format!("insert into {}", name(into)),
933 Effect::Set { target, field, .. } => format!("set {field} on {}", name(target)),
934 Effect::Unset { target, field, .. } => format!("unset {field} on {}", name(target)),
935 Effect::MoveDocument { target, to, .. } => format!("move {} -> {to}", name(target)),
936 Effect::MoveBlocks { targets, .. } => format!("move {}", blocks(targets)),
937 Effect::RemoveDocument { target } => format!("remove {}", name(target)),
938 Effect::RemoveBlocks { targets, .. } => format!("remove {}", blocks(targets)),
939 Effect::Split { target, .. } => format!("split block {target}"),
940 Effect::Merge { targets, .. } => format!("merge {}", blocks(targets)),
941 Effect::Retarget { repairs, .. } => format!(
942 "retarget {}",
943 repairs
944 .iter()
945 .map(|r| format!("{}->{}", r.from, r.to))
946 .collect::<Vec<_>>()
947 .join(",")
948 ),
949 }
950 }
951}
952
953#[derive(Clone, Debug, PartialEq, Eq)]
957pub enum Field<'a> {
958 Frontmatter(&'a str),
960 Content,
962 Markdown,
964 Checked,
966 NodeProp(&'a str),
968}
969
970pub fn classify_field<'a>(
972 target: &Target,
973 field: &'a str,
974) -> std::result::Result<Field<'a>, String> {
975 check_field(target, field, "field", "set")?;
976 Ok(match target {
977 Target::Document { .. } => match field.strip_prefix("frontmatter.") {
978 Some(key) => Field::Frontmatter(key),
979 None => Field::Content,
980 },
981 Target::Block { .. } => {
982 if field == "markdown" {
983 Field::Markdown
984 } else {
985 Field::Checked
986 }
987 }
988 Target::Node { .. } => Field::NodeProp(field),
989 Target::Section { .. } => unreachable!("check_field rejects sections"),
990 })
991}
992
993#[must_use]
996pub fn shift_placeholders(op: &Op, offset: usize) -> Op {
997 map_block_ids(op, |v| match crate::changeset::parse_placeholder(v) {
998 Some((n, i)) => format!("${}.ids[{i}]", n + offset),
999 None => v.to_owned(),
1000 })
1001}
1002
1003#[must_use]
1008pub fn map_block_ids(op: &Op, f: impl Fn(&str) -> String) -> Op {
1009 let s = |v: &String| -> String { f(v) };
1010 let to = |t: &To| To {
1011 parent: match &t.parent {
1012 Parent::Block(id) => Parent::Block(s(id)),
1013 Parent::Section { heading } => Parent::Section {
1014 heading: s(heading),
1015 },
1016 Parent::Doc => Parent::Doc,
1017 },
1018 at: match &t.at {
1019 At::Before(id) => At::Before(s(id)),
1020 At::After(id) => At::After(s(id)),
1021 other => other.clone(),
1022 },
1023 };
1024 let map = |m: &Option<BTreeMap<String, Expect>>| -> Option<BTreeMap<String, Expect>> {
1025 m.as_ref()
1026 .map(|m| m.iter().map(|(k, e)| (s(k), e.clone())).collect())
1027 };
1028 match op {
1029 Op::Insert {
1030 doc,
1031 to: t,
1032 markdown,
1033 expect,
1034 } => Op::Insert {
1035 doc: doc.as_ref().map(s),
1036 to: to(t),
1037 markdown: markdown.clone(),
1038 expect: expect.clone(),
1039 },
1040 Op::Update {
1041 block,
1042 markdown,
1043 attrs,
1044 expect,
1045 trivia,
1046 child_ids,
1047 } => Op::Update {
1048 block: s(block),
1049 markdown: markdown.clone(),
1050 attrs: attrs.clone(),
1051 expect: expect.clone(),
1052 trivia: trivia.clone(),
1053 child_ids: child_ids.clone(),
1054 },
1055 Op::Move {
1056 blocks,
1057 to: t,
1058 doc,
1059 expect,
1060 } => Op::Move {
1061 blocks: blocks.iter().map(s).collect(),
1062 to: to(t),
1063 doc: doc.as_ref().map(s),
1064 expect: expect.clone(),
1065 },
1066 Op::Remove { blocks, expect } => Op::Remove {
1067 blocks: blocks.iter().map(s).collect(),
1068 expect: map(expect),
1069 },
1070 Op::Split { block, at, expect } => Op::Split {
1071 block: s(block),
1072 at: at.clone(),
1073 expect: expect.clone(),
1074 },
1075 Op::Merge {
1076 blocks,
1077 separator,
1078 expect,
1079 } => Op::Merge {
1080 blocks: blocks.iter().map(s).collect(),
1081 separator: separator.clone(),
1082 expect: map(expect),
1083 },
1084 }
1085}
1086
1087#[derive(Clone, Debug, PartialEq, Eq)]
1091pub struct FocusTarget {
1092 pub kind: &'static str,
1093 pub id: Option<String>,
1094 pub path: Option<String>,
1095}
1096
1097impl FocusTarget {
1098 #[must_use]
1099 pub fn document(id: &str) -> Self {
1100 Self {
1101 kind: "document",
1102 id: Some(id.to_owned()),
1103 path: None,
1104 }
1105 }
1106
1107 #[must_use]
1108 pub fn block(id: &str) -> Self {
1109 Self {
1110 kind: "block",
1111 id: Some(id.to_owned()),
1112 path: None,
1113 }
1114 }
1115
1116 #[must_use]
1117 pub fn node(id: &str) -> Self {
1118 Self {
1119 kind: "node",
1120 id: Some(id.to_owned()),
1121 path: None,
1122 }
1123 }
1124
1125 #[must_use]
1126 pub fn path(path: &str) -> Self {
1127 Self {
1128 kind: "path",
1129 id: None,
1130 path: Some(path.to_owned()),
1131 }
1132 }
1133
1134 #[must_use]
1135 pub fn to_json(&self) -> Value {
1136 let mut m = Map::new();
1137 m.insert("kind".to_owned(), json!(self.kind));
1138 if let Some(id) = &self.id {
1139 m.insert("id".to_owned(), json!(id));
1140 }
1141 if let Some(path) = &self.path {
1142 m.insert("path".to_owned(), json!(path));
1143 }
1144 Value::Object(m)
1145 }
1146}
1147
1148#[derive(Clone, Debug, PartialEq, Eq)]
1150pub struct Focus {
1151 pub kind: &'static str,
1153 pub target: FocusTarget,
1154 pub field: Option<String>,
1155}
1156
1157impl Focus {
1158 #[must_use]
1159 pub fn to_json(&self) -> Value {
1160 let mut m = Map::new();
1161 m.insert("kind".to_owned(), json!(self.kind));
1162 m.insert("target".to_owned(), self.target.to_json());
1163 if let Some(f) = &self.field {
1164 m.insert("field".to_owned(), json!(f));
1165 }
1166 Value::Object(m)
1167 }
1168}
1169
1170#[must_use]
1172pub fn conflicting_effects(i: usize, j: usize, focus: &Focus) -> MutationError {
1173 let who = focus
1174 .target
1175 .id
1176 .clone()
1177 .or_else(|| focus.target.path.clone())
1178 .unwrap_or_default();
1179 let what = match &focus.field {
1180 Some(f) => format!("{} {who} {f}", focus.target.kind),
1181 None => format!("{} {who}", focus.target.kind),
1182 };
1183 MutationError::with_data(
1184 ErrorCode::ConflictingEffects,
1185 format!("effects {i} and {j} contradict each other on {what}"),
1186 json!({ "effects": [i, j], "focus": focus.to_json(), "retriable": false }),
1187 )
1188}
1189
1190#[must_use]
1193pub fn symbol_missing(symbol: &str, kind: Option<NewIdKind>, wanted: &str) -> MutationError {
1194 let message = match kind {
1195 None => format!("{symbol} is not created by an earlier effect"),
1196 Some(k) => format!("{symbol} names a {}, not a {wanted}", k.as_str()),
1197 };
1198 MutationError::with_data(
1199 ErrorCode::TargetMissing,
1200 message,
1201 json!({ "symbol": symbol }),
1202 )
1203}
1204
1205#[derive(Clone, Debug, PartialEq)]
1207pub struct ScalarWrite {
1208 pub key: String,
1210 pub target: FocusTarget,
1211 pub field: String,
1212 pub value: Option<Value>,
1214}
1215
1216#[derive(Clone, Debug, Default, PartialEq)]
1219pub struct FocusInfo {
1220 pub scalar: Option<ScalarWrite>,
1221 pub scalars: Vec<ScalarWrite>,
1223 pub docs: Vec<String>,
1225 pub blocks: Vec<String>,
1226 pub paths: Vec<String>,
1227 pub retires_docs: Vec<String>,
1229 pub retires_blocks: Vec<String>,
1230 pub retires_paths: Vec<String>,
1231 pub whole_doc: Option<String>,
1233}
1234
1235#[derive(Debug, Default)]
1237pub struct ConflictTracker {
1238 scalar: HashMap<String, (usize, ScalarWrite)>,
1239 retired_docs: HashMap<String, usize>,
1240 retired_blocks: HashMap<String, usize>,
1241 retired_paths: HashMap<String, usize>,
1242 touched_docs: HashMap<String, usize>,
1243 pub diagnostics: Vec<String>,
1245}
1246
1247impl ConflictTracker {
1248 pub fn check(&mut self, i: usize, info: &FocusInfo, describe: &str) -> Result<bool> {
1252 for d in &info.docs {
1253 if let Some(&by) = self.retired_docs.get(d) {
1254 return Err(conflicting_effects(
1255 by,
1256 i,
1257 &identity(FocusTarget::document(d)),
1258 ));
1259 }
1260 }
1261 for b in &info.blocks {
1262 if let Some(&by) = self.retired_blocks.get(b) {
1263 return Err(conflicting_effects(by, i, &identity(FocusTarget::block(b))));
1264 }
1265 }
1266 for p in &info.paths {
1267 if let Some(&by) = self.retired_paths.get(p) {
1268 return Err(conflicting_effects(by, i, &identity(FocusTarget::path(p))));
1269 }
1270 }
1271 if let Some(d) = &info.whole_doc {
1272 if let Some(&by) = self.touched_docs.get(d) {
1273 return Err(conflicting_effects(
1274 by,
1275 i,
1276 &identity(FocusTarget::document(d)),
1277 ));
1278 }
1279 }
1280 if let Some(s) = &info.scalar {
1281 if let Some((prev_i, prev)) = self.scalar.get(&s.key) {
1282 let identical = match (&prev.value, &s.value) {
1283 (None, None) => true,
1284 (Some(a), Some(b)) => canonical_json(a) == canonical_json(b),
1285 _ => false,
1286 };
1287 if !identical {
1288 return Err(conflicting_effects(
1289 *prev_i,
1290 i,
1291 &Focus {
1292 kind: "scalar",
1293 target: s.target.clone(),
1294 field: Some(s.field.clone()),
1295 },
1296 ));
1297 }
1298 self.diagnostics.push(format!(
1299 "effect {i} ({describe}) duplicates effect {prev_i}; dropped"
1300 ));
1301 return Ok(false);
1302 }
1303 }
1304 for s in &info.scalars {
1305 if let Some((prev_i, _)) = self.scalar.get(&s.key) {
1306 return Err(conflicting_effects(
1307 *prev_i,
1308 i,
1309 &Focus {
1310 kind: "scalar",
1311 target: s.target.clone(),
1312 field: Some(s.field.clone()),
1313 },
1314 ));
1315 }
1316 }
1317 if let Some(s) = &info.scalar {
1319 self.scalar.insert(s.key.clone(), (i, s.clone()));
1320 }
1321 for s in &info.scalars {
1322 self.scalar.insert(s.key.clone(), (i, s.clone()));
1323 }
1324 for d in &info.docs {
1325 self.touched_docs.entry(d.clone()).or_insert(i);
1326 }
1327 for d in &info.retires_docs {
1328 self.retired_docs.insert(d.clone(), i);
1329 }
1330 for b in &info.retires_blocks {
1331 self.retired_blocks.insert(b.clone(), i);
1332 }
1333 for p in &info.retires_paths {
1334 self.retired_paths.insert(p.clone(), i);
1335 }
1336 Ok(true)
1337 }
1338}
1339
1340fn identity(target: FocusTarget) -> Focus {
1341 Focus {
1342 kind: "identity",
1343 target,
1344 field: None,
1345 }
1346}
1347
1348#[must_use]
1352pub fn canonical_json(v: &Value) -> String {
1353 let mut out = String::new();
1354 write_canonical(v, &mut out);
1355 out
1356}
1357
1358fn write_canonical(v: &Value, out: &mut String) {
1359 match v {
1360 Value::Object(map) => {
1361 let mut keys: Vec<&String> = map.keys().collect();
1362 keys.sort_unstable_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
1363 out.push('{');
1364 for (i, k) in keys.iter().enumerate() {
1365 if i > 0 {
1366 out.push(',');
1367 }
1368 out.push_str(&Value::String((*k).clone()).to_string());
1369 out.push(':');
1370 write_canonical(&map[*k], out);
1371 }
1372 out.push('}');
1373 }
1374 Value::Array(items) => {
1375 out.push('[');
1376 for (i, item) in items.iter().enumerate() {
1377 if i > 0 {
1378 out.push(',');
1379 }
1380 write_canonical(item, out);
1381 }
1382 out.push(']');
1383 }
1384 Value::Number(n) => out.push_str(&canonical_number(n)),
1385 scalar => out.push_str(&scalar.to_string()),
1386 }
1387}
1388
1389fn canonical_number(n: &serde_json::Number) -> String {
1391 if let Some(i) = n.as_i64() {
1392 return i.to_string();
1393 }
1394 if let Some(u) = n.as_u64() {
1395 return u.to_string();
1396 }
1397 let Some(f) = n.as_f64() else {
1398 return "null".to_owned();
1399 };
1400 js_number(f)
1401}
1402
1403#[must_use]
1405pub fn js_number(x: f64) -> String {
1406 const TWO_53: f64 = 9_007_199_254_740_992.0;
1407 if !x.is_finite() {
1408 return "null".to_owned();
1409 }
1410 if x == 0.0 {
1411 return "0".to_owned();
1412 }
1413 if x.fract() == 0.0 && x.abs() < TWO_53 {
1414 #[allow(clippy::cast_possible_truncation)]
1416 return (x as i64).to_string();
1417 }
1418 let sci = format!("{:e}", x.abs());
1421 let (mantissa, exp) = sci.split_once('e').expect("{:e} has an exponent");
1422 let exp: i32 = exp.parse().expect("{:e} exponent is an integer");
1423 let digits: String = mantissa.chars().filter(|c| *c != '.').collect();
1424 let k = digits.len() as i32;
1425 let n = exp + 1; let mut out = String::new();
1427 if x < 0.0 {
1428 out.push('-');
1429 }
1430 if k <= n && n <= 21 {
1431 out.push_str(&digits);
1432 out.push_str(&"0".repeat((n - k) as usize));
1433 } else if 0 < n && n <= 21 {
1434 out.push_str(&digits[..n as usize]);
1435 out.push('.');
1436 out.push_str(&digits[n as usize..]);
1437 } else if -6 < n && n <= 0 {
1438 out.push_str("0.");
1439 out.push_str(&"0".repeat((-n) as usize));
1440 out.push_str(&digits);
1441 } else {
1442 let e = n - 1;
1443 out.push_str(&digits[..1]);
1444 if k > 1 {
1445 out.push('.');
1446 out.push_str(&digits[1..]);
1447 }
1448 out.push('e');
1449 out.push(if e < 0 { '-' } else { '+' });
1450 out.push_str(&e.abs().to_string());
1451 }
1452 out
1453}
1454
1455#[must_use]
1457pub fn digest(core: &Value) -> String {
1458 hex(&sha256(canonical_json(core).as_bytes()))
1459}
1460
1461#[derive(Clone, Debug, PartialEq, Eq)]
1468pub struct PlanCreate {
1469 pub symbol: String,
1470 pub path: String,
1471 pub source: String,
1472 pub before_op: usize,
1473}
1474
1475#[derive(Clone, Debug, PartialEq, Eq)]
1477pub struct PlanMove {
1478 pub id: String,
1479 pub from: String,
1480 pub to: String,
1481}
1482
1483#[derive(Clone, Debug, PartialEq, Eq)]
1485pub struct PlanTombstone {
1486 pub id: String,
1487 pub path: String,
1488}
1489
1490#[derive(Clone, Debug, PartialEq, Eq)]
1494pub struct PlanFrontmatter {
1495 pub doc: String,
1496 pub raw: String,
1497}
1498
1499#[derive(Clone, Debug, Default, PartialEq)]
1502pub struct PlanOpset {
1503 pub creates: Vec<PlanCreate>,
1504 pub moves: Vec<PlanMove>,
1505 pub tombstones: Vec<PlanTombstone>,
1506 pub set_frontmatter: Vec<PlanFrontmatter>,
1507 pub ops: Vec<Op>,
1508}
1509
1510impl PlanOpset {
1511 #[must_use]
1512 pub fn to_json(&self) -> Value {
1513 json!({
1514 "creates": self.creates.iter().map(|c| json!({ "symbol": c.symbol, "path": c.path, "source": c.source, "before_op": c.before_op })).collect::<Vec<_>>(),
1515 "moves": self.moves.iter().map(|m| json!({ "id": m.id, "from": m.from, "to": m.to })).collect::<Vec<_>>(),
1516 "tombstones": self.tombstones.iter().map(|t| json!({ "id": t.id, "path": t.path })).collect::<Vec<_>>(),
1517 "set_frontmatter": self.set_frontmatter.iter().map(|f| json!({ "doc": f.doc, "raw": f.raw })).collect::<Vec<_>>(),
1518 "ops": self.ops.iter().map(Op::to_json).collect::<Vec<_>>(),
1519 })
1520 }
1521
1522 pub fn from_json(v: &Value) -> std::result::Result<Self, String> {
1523 let obj = v.as_object().ok_or("`plan.opset` must be an object")?;
1524 let arr = |k: &str| -> std::result::Result<&Vec<Value>, String> {
1525 obj.get(k)
1526 .and_then(Value::as_array)
1527 .ok_or_else(|| format!("`plan.opset.{k}` must be an array"))
1528 };
1529 let s = |v: &Value, k: &str, here: &str| -> std::result::Result<String, String> {
1530 v.get(k)
1531 .and_then(Value::as_str)
1532 .map(str::to_owned)
1533 .ok_or_else(|| format!("`plan.opset.{here}.{k}` must be a string"))
1534 };
1535 Ok(Self {
1536 creates: arr("creates")?
1537 .iter()
1538 .map(|c| {
1539 let str_of = |k: &str| c.get(k).and_then(Value::as_str).map(str::to_owned);
1540 let before_op = c
1541 .get("before_op")
1542 .and_then(Value::as_u64)
1543 .and_then(|n| usize::try_from(n).ok());
1544 match (str_of("symbol"), str_of("path"), str_of("source"), before_op) {
1545 (Some(symbol), Some(path), Some(source), Some(before_op)) => {
1546 Ok(PlanCreate {
1547 symbol,
1548 path,
1549 source,
1550 before_op,
1551 })
1552 }
1553 _ => Err(
1554 "`plan.opset.creates[]` entries are { symbol, path, source, before_op }"
1555 .to_owned(),
1556 ),
1557 }
1558 })
1559 .collect::<std::result::Result<_, String>>()?,
1560 moves: arr("moves")?
1561 .iter()
1562 .map(|m| {
1563 Ok(PlanMove {
1564 id: s(m, "id", "moves[]")?,
1565 from: s(m, "from", "moves[]")?,
1566 to: s(m, "to", "moves[]")?,
1567 })
1568 })
1569 .collect::<std::result::Result<_, String>>()?,
1570 tombstones: arr("tombstones")?
1571 .iter()
1572 .map(|t| {
1573 Ok(PlanTombstone {
1574 id: s(t, "id", "tombstones[]")?,
1575 path: s(t, "path", "tombstones[]")?,
1576 })
1577 })
1578 .collect::<std::result::Result<_, String>>()?,
1579 set_frontmatter: arr("set_frontmatter")?
1580 .iter()
1581 .map(|f| {
1582 Ok(PlanFrontmatter {
1583 doc: s(f, "doc", "set_frontmatter[]")?,
1584 raw: s(f, "raw", "set_frontmatter[]")?,
1585 })
1586 })
1587 .collect::<std::result::Result<_, String>>()?,
1588 ops: arr("ops")?
1589 .iter()
1590 .map(Op::from_json)
1591 .collect::<std::result::Result<Vec<_>, String>>()?,
1592 })
1593 }
1594}
1595
1596#[derive(Clone, Debug, PartialEq, Eq)]
1599pub struct DocPrecondition {
1600 pub id: String,
1601 pub path: String,
1602 pub base_revision: Option<String>,
1603 pub hash: Option<String>,
1605}
1606
1607#[derive(Clone, Debug, PartialEq, Eq)]
1609pub struct BlockPrecondition {
1610 pub id: String,
1611 pub content_hash: String,
1612}
1613
1614#[derive(Clone, Debug, PartialEq, Eq)]
1617pub struct ParentPrecondition {
1618 pub doc: String,
1619 pub parent: Option<String>,
1620 pub parent_children_hash: String,
1621}
1622
1623#[derive(Clone, Debug, Default, PartialEq, Eq)]
1625pub struct Preconditions {
1626 pub docs: Vec<DocPrecondition>,
1627 pub blocks: Vec<BlockPrecondition>,
1628 pub parents: Vec<ParentPrecondition>,
1629 pub absent_paths: Vec<String>,
1630}
1631
1632impl Preconditions {
1633 #[must_use]
1634 pub fn to_json(&self) -> Value {
1635 json!({
1636 "docs": self.docs.iter().map(|d| json!({ "id": d.id, "path": d.path, "base_revision": d.base_revision, "hash": d.hash })).collect::<Vec<_>>(),
1637 "blocks": self.blocks.iter().map(|b| json!({ "id": b.id, "content_hash": b.content_hash })).collect::<Vec<_>>(),
1638 "parents": self.parents.iter().map(|p| json!({ "doc": p.doc, "parent": p.parent, "parent_children_hash": p.parent_children_hash })).collect::<Vec<_>>(),
1639 "absent_paths": self.absent_paths,
1640 })
1641 }
1642
1643 pub fn from_json(v: &Value) -> std::result::Result<Self, String> {
1644 let obj = v
1645 .as_object()
1646 .ok_or("`plan.preconditions` must be an object")?;
1647 let arr = |k: &str| -> std::result::Result<&Vec<Value>, String> {
1648 obj.get(k)
1649 .and_then(Value::as_array)
1650 .ok_or_else(|| format!("`plan.preconditions.{k}` must be an array"))
1651 };
1652 let s = |v: &Value, k: &str, here: &str| -> std::result::Result<String, String> {
1653 v.get(k)
1654 .and_then(Value::as_str)
1655 .map(str::to_owned)
1656 .ok_or_else(|| format!("`plan.preconditions.{here}.{k}` must be a string"))
1657 };
1658 let opt = |v: &Value, k: &str, here: &str| -> std::result::Result<Option<String>, String> {
1659 match v.get(k) {
1660 None | Some(Value::Null) => Ok(None),
1661 Some(Value::String(x)) => Ok(Some(x.clone())),
1662 Some(_) => Err(format!(
1663 "`plan.preconditions.{here}.{k}` must be a string or null"
1664 )),
1665 }
1666 };
1667 Ok(Self {
1668 docs: arr("docs")?
1669 .iter()
1670 .map(|d| {
1671 Ok(DocPrecondition {
1672 id: s(d, "id", "docs[]")?,
1673 path: s(d, "path", "docs[]")?,
1674 base_revision: opt(d, "base_revision", "docs[]")?,
1675 hash: opt(d, "hash", "docs[]")?,
1676 })
1677 })
1678 .collect::<std::result::Result<_, String>>()?,
1679 blocks: arr("blocks")?
1680 .iter()
1681 .map(|b| {
1682 Ok(BlockPrecondition {
1683 id: s(b, "id", "blocks[]")?,
1684 content_hash: s(b, "content_hash", "blocks[]")?,
1685 })
1686 })
1687 .collect::<std::result::Result<_, String>>()?,
1688 parents: arr("parents")?
1689 .iter()
1690 .map(|p| {
1691 Ok(ParentPrecondition {
1692 doc: s(p, "doc", "parents[]")?,
1693 parent: opt(p, "parent", "parents[]")?,
1694 parent_children_hash: s(p, "parent_children_hash", "parents[]")?,
1695 })
1696 })
1697 .collect::<std::result::Result<_, String>>()?,
1698 absent_paths: arr("absent_paths")?
1699 .iter()
1700 .map(|p| {
1701 p.as_str().map(str::to_owned).ok_or_else(|| {
1702 "`plan.preconditions.absent_paths[]` must be strings".to_owned()
1703 })
1704 })
1705 .collect::<std::result::Result<_, String>>()?,
1706 })
1707 }
1708}
1709
1710#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1712pub enum NewIdKind {
1713 Block,
1714 Document,
1715}
1716
1717impl NewIdKind {
1718 #[must_use]
1719 pub const fn as_str(&self) -> &'static str {
1720 match self {
1721 NewIdKind::Block => "block",
1722 NewIdKind::Document => "document",
1723 }
1724 }
1725
1726 #[must_use]
1728 pub fn from_prefix(prefix: &str) -> Option<Self> {
1729 match prefix {
1730 "b" => Some(NewIdKind::Block),
1731 "d" => Some(NewIdKind::Document),
1732 _ => None,
1733 }
1734 }
1735}
1736
1737#[derive(Clone, Debug, PartialEq, Eq)]
1739pub struct NewId {
1740 pub symbol: String,
1741 pub kind: NewIdKind,
1742}
1743
1744#[derive(Clone, Debug, PartialEq, Eq)]
1747pub struct PlanRevision {
1748 pub id: String,
1749 pub path: String,
1750 pub hash: String,
1751}
1752
1753#[derive(Clone, Debug, PartialEq)]
1757pub struct IdentityOp {
1758 pub op_index: usize,
1759 pub disposition: PlanDisposition,
1760 pub blocks: Vec<String>,
1762 pub confidence: Option<f64>,
1763 pub reason: Option<String>,
1764 pub detail: Option<Value>,
1766}
1767
1768#[derive(Clone, Debug, PartialEq)]
1773pub struct IdentityPreview {
1774 pub matcher_v: String,
1775 pub summary: OpsetSummary,
1776 pub ops: Vec<IdentityOp>,
1777 pub diagnostics: Vec<String>,
1778}
1779
1780impl IdentityPreview {
1781 #[must_use]
1782 pub fn to_json(&self) -> Value {
1783 json!({
1784 "matcher_v": self.matcher_v,
1785 "summary": self.summary.to_json(),
1786 "ops": self.ops.iter().map(|o| {
1787 let mut m = Map::new();
1788 m.insert("op_index".to_owned(), json!(o.op_index));
1789 m.insert("disposition".to_owned(), json!(o.disposition.as_str()));
1790 m.insert("blocks".to_owned(), json!(o.blocks));
1791 m.insert("confidence".to_owned(), json!(o.confidence));
1792 m.insert("reason".to_owned(), json!(o.reason));
1793 if let Some(d) = &o.detail {
1794 m.insert("detail".to_owned(), d.clone());
1795 }
1796 Value::Object(m)
1797 }).collect::<Vec<_>>(),
1798 "diagnostics": self.diagnostics,
1799 })
1800 }
1801
1802 pub fn from_json(v: &Value) -> std::result::Result<Self, String> {
1803 let obj = v
1804 .as_object()
1805 .ok_or("`plan.preview.identity[]` must be an object")?;
1806 let matcher_v = obj
1807 .get("matcher_v")
1808 .and_then(Value::as_str)
1809 .ok_or("`plan.preview.identity[].matcher_v` must be a string")?
1810 .to_owned();
1811 let summary = obj
1812 .get("summary")
1813 .and_then(Value::as_object)
1814 .ok_or("`plan.preview.identity[].summary` must be an object")?;
1815 let count = |k: &str| -> usize {
1816 summary
1817 .get(k)
1818 .and_then(Value::as_u64)
1819 .and_then(|n| usize::try_from(n).ok())
1820 .unwrap_or(0)
1821 };
1822 let summary = OpsetSummary {
1823 preserved: count("preserved"),
1824 updated: count("updated"),
1825 moved: count("moved"),
1826 created: count("created"),
1827 removed: count("removed"),
1828 split: count("split"),
1829 merged: count("merged"),
1830 ambiguous: count("ambiguous"),
1831 };
1832 let ops = obj
1833 .get("ops")
1834 .and_then(Value::as_array)
1835 .ok_or("`plan.preview.identity[].ops` must be an array")?
1836 .iter()
1837 .map(|o| {
1838 let disposition = o
1839 .get("disposition")
1840 .and_then(Value::as_str)
1841 .and_then(PlanDisposition::parse)
1842 .ok_or("`plan.preview.identity[].ops[].disposition` is not a disposition")?;
1843 let op_index = o
1844 .get("op_index")
1845 .and_then(Value::as_u64)
1846 .and_then(|n| usize::try_from(n).ok())
1847 .ok_or("`plan.preview.identity[].ops[].op_index` must be an integer")?;
1848 Ok(IdentityOp {
1849 op_index,
1850 disposition,
1851 blocks: o
1852 .get("blocks")
1853 .and_then(Value::as_array)
1854 .map(|a| {
1855 a.iter()
1856 .filter_map(Value::as_str)
1857 .map(str::to_owned)
1858 .collect()
1859 })
1860 .unwrap_or_default(),
1861 confidence: o.get("confidence").and_then(Value::as_f64),
1862 reason: o.get("reason").and_then(Value::as_str).map(str::to_owned),
1863 detail: o.get("detail").cloned(),
1864 })
1865 })
1866 .collect::<std::result::Result<Vec<_>, String>>()?;
1867 let diagnostics = obj
1868 .get("diagnostics")
1869 .and_then(Value::as_array)
1870 .map(|a| {
1871 a.iter()
1872 .filter_map(Value::as_str)
1873 .map(str::to_owned)
1874 .collect()
1875 })
1876 .unwrap_or_default();
1877 Ok(Self {
1878 matcher_v,
1879 summary,
1880 ops,
1881 diagnostics,
1882 })
1883 }
1884}
1885
1886#[derive(Clone, Debug, PartialEq, Eq)]
1889pub struct RetargetHitPreview {
1890 pub id: String,
1891 pub path: String,
1892 pub old_raw: String,
1893 pub new_raw: String,
1894}
1895
1896#[derive(Clone, Debug, PartialEq, Eq)]
1898pub struct RetargetPairPreview {
1899 pub from: String,
1900 pub to: String,
1901 pub hits: usize,
1902}
1903
1904#[derive(Clone, Debug, PartialEq, Eq)]
1908pub struct RetargetPreview {
1909 pub effect_index: usize,
1910 pub hits: Vec<RetargetHitPreview>,
1911 pub pairs: Vec<RetargetPairPreview>,
1912}
1913
1914impl RetargetPreview {
1915 #[must_use]
1916 pub fn to_json(&self) -> Value {
1917 json!({
1918 "effect_index": self.effect_index,
1919 "hits": self.hits.iter().map(|h| json!({ "id": h.id, "path": h.path, "oldRaw": h.old_raw, "newRaw": h.new_raw })).collect::<Vec<_>>(),
1920 "pairs": self.pairs.iter().map(|p| json!({ "from": p.from, "to": p.to, "hits": p.hits })).collect::<Vec<_>>(),
1921 })
1922 }
1923
1924 pub fn from_json(v: &Value) -> std::result::Result<Self, String> {
1925 let obj = v
1926 .as_object()
1927 .ok_or("`plan.preview.retarget[]` must be an object")?;
1928 let s = |v: &Value, k: &str| -> std::result::Result<String, String> {
1929 v.get(k)
1930 .and_then(Value::as_str)
1931 .map(str::to_owned)
1932 .ok_or_else(|| format!("`plan.preview.retarget[]` {k} must be a string"))
1933 };
1934 let n = |v: &Value, k: &str| -> std::result::Result<usize, String> {
1935 v.get(k)
1936 .and_then(Value::as_u64)
1937 .and_then(|n| usize::try_from(n).ok())
1938 .ok_or_else(|| format!("`plan.preview.retarget[]` {k} must be an integer"))
1939 };
1940 Ok(Self {
1941 effect_index: n(v, "effect_index")?,
1942 hits: obj
1943 .get("hits")
1944 .and_then(Value::as_array)
1945 .ok_or("`plan.preview.retarget[].hits` must be an array")?
1946 .iter()
1947 .map(|h| {
1948 Ok(RetargetHitPreview {
1949 id: s(h, "id")?,
1950 path: s(h, "path")?,
1951 old_raw: s(h, "oldRaw")?,
1952 new_raw: s(h, "newRaw")?,
1953 })
1954 })
1955 .collect::<std::result::Result<_, String>>()?,
1956 pairs: obj
1957 .get("pairs")
1958 .and_then(Value::as_array)
1959 .ok_or("`plan.preview.retarget[].pairs` must be an array")?
1960 .iter()
1961 .map(|p| {
1962 Ok(RetargetPairPreview {
1963 from: s(p, "from")?,
1964 to: s(p, "to")?,
1965 hits: n(p, "hits")?,
1966 })
1967 })
1968 .collect::<std::result::Result<_, String>>()?,
1969 })
1970 }
1971}
1972
1973#[derive(Clone, Debug, Default, PartialEq)]
1977pub struct PlanPreview {
1978 pub results: Vec<OpResult>,
1979 pub revisions: Vec<PlanRevision>,
1980 pub diffs: Vec<(String, (String, String))>,
1982 pub identity: Vec<(String, IdentityPreview)>,
1984 pub retarget: Vec<RetargetPreview>,
1985 pub summary: Vec<String>,
1988}
1989
1990impl PlanPreview {
1991 #[must_use]
1992 pub fn to_json(&self) -> Value {
1993 let mut diffs = Map::new();
1994 for (path, (before, after)) in &self.diffs {
1995 diffs.insert(path.clone(), json!({ "before": before, "after": after }));
1996 }
1997 let mut m = Map::new();
1998 m.insert(
1999 "results".to_owned(),
2000 Value::Array(self.results.iter().map(OpResult::to_json).collect()),
2001 );
2002 m.insert(
2003 "revisions".to_owned(),
2004 Value::Array(
2005 self.revisions
2006 .iter()
2007 .map(|r| json!({ "id": r.id, "path": r.path, "hash": r.hash }))
2008 .collect(),
2009 ),
2010 );
2011 m.insert("diffs".to_owned(), Value::Object(diffs));
2012 m.insert("summary".to_owned(), json!(self.summary));
2013 if !self.identity.is_empty() {
2014 let mut identity = Map::new();
2015 for (doc, i) in &self.identity {
2016 identity.insert(doc.clone(), i.to_json());
2017 }
2018 m.insert("identity".to_owned(), Value::Object(identity));
2019 }
2020 if !self.retarget.is_empty() {
2021 m.insert(
2022 "retarget".to_owned(),
2023 Value::Array(self.retarget.iter().map(RetargetPreview::to_json).collect()),
2024 );
2025 }
2026 Value::Object(m)
2027 }
2028
2029 pub fn from_json(v: &Value) -> std::result::Result<Self, String> {
2030 let obj = v.as_object().ok_or("`plan.preview` must be an object")?;
2031 let identity = obj
2032 .get("identity")
2033 .and_then(Value::as_object)
2034 .map(|m| {
2035 m.iter()
2036 .map(|(doc, i)| Ok((doc.clone(), IdentityPreview::from_json(i)?)))
2037 .collect::<std::result::Result<Vec<_>, String>>()
2038 })
2039 .transpose()?
2040 .unwrap_or_default();
2041 let retarget = obj
2042 .get("retarget")
2043 .and_then(Value::as_array)
2044 .map(|a| {
2045 a.iter()
2046 .map(RetargetPreview::from_json)
2047 .collect::<std::result::Result<Vec<_>, String>>()
2048 })
2049 .transpose()?
2050 .unwrap_or_default();
2051 let strings = |v: Option<&Value>| -> Option<Vec<String>> {
2052 v.and_then(Value::as_array).map(|l| {
2053 l.iter()
2054 .filter_map(Value::as_str)
2055 .map(str::to_owned)
2056 .collect()
2057 })
2058 };
2059 let results = obj
2060 .get("results")
2061 .and_then(Value::as_array)
2062 .map(|a| {
2063 a.iter()
2064 .map(|r| OpResult {
2065 ids: strings(r.get("ids")).unwrap_or_default(),
2066 removed: strings(r.get("removed")),
2067 merged_into: strings(r.get("merged_into")),
2068 })
2069 .collect()
2070 })
2071 .unwrap_or_default();
2072 let str_of = |v: &Value, k: &str| -> std::result::Result<String, String> {
2073 v.get(k)
2074 .and_then(Value::as_str)
2075 .map(str::to_owned)
2076 .ok_or_else(|| format!("`plan.preview` {k} must be a string"))
2077 };
2078 let revisions = obj
2079 .get("revisions")
2080 .and_then(Value::as_array)
2081 .map(|a| {
2082 a.iter()
2083 .map(|r| {
2084 Ok(PlanRevision {
2085 id: str_of(r, "id")?,
2086 path: str_of(r, "path")?,
2087 hash: str_of(r, "hash")?,
2088 })
2089 })
2090 .collect::<std::result::Result<Vec<_>, String>>()
2091 })
2092 .transpose()?
2093 .unwrap_or_default();
2094 let diffs = obj
2095 .get("diffs")
2096 .and_then(Value::as_object)
2097 .map(|d| {
2098 d.iter()
2099 .map(|(p, v)| Ok((p.clone(), (str_of(v, "before")?, str_of(v, "after")?))))
2100 .collect::<std::result::Result<Vec<_>, String>>()
2101 })
2102 .transpose()?
2103 .unwrap_or_default();
2104 let summary = strings(obj.get("summary")).unwrap_or_default();
2105 Ok(Self {
2106 results,
2107 revisions,
2108 diffs,
2109 identity,
2110 retarget,
2111 summary,
2112 })
2113 }
2114}
2115
2116#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2118pub struct SelectionExpect {
2119 pub max: usize,
2120}
2121
2122#[derive(Clone, Debug, PartialEq)]
2129pub struct Selection {
2130 pub query: String,
2131 pub expect: Option<SelectionExpect>,
2132 pub templates: Vec<Value>,
2133 pub cardinality: usize,
2134 pub identities: Vec<String>,
2135}
2136
2137impl Selection {
2138 #[must_use]
2141 pub fn to_json(&self) -> Value {
2142 let mut m = Map::new();
2143 m.insert("query".to_owned(), json!(self.query));
2144 if let Some(e) = &self.expect {
2145 m.insert("expect".to_owned(), json!({ "max": e.max }));
2146 }
2147 m.insert("templates".to_owned(), Value::Array(self.templates.clone()));
2148 m.insert("cardinality".to_owned(), json!(self.cardinality));
2149 m.insert("identities".to_owned(), json!(self.identities));
2150 Value::Object(m)
2151 }
2152
2153 pub fn from_json(v: &Value) -> std::result::Result<Self, String> {
2154 let obj = v.as_object().ok_or("`plan.selection` must be an object")?;
2155 only_keys(
2156 obj,
2157 &["query", "expect", "templates", "cardinality", "identities"],
2158 "`plan.selection`",
2159 )?;
2160 let query = obj
2161 .get("query")
2162 .and_then(Value::as_str)
2163 .ok_or("`plan.selection.query` must be a string")?
2164 .to_owned();
2165 let expect = match obj.get("expect") {
2166 None => None,
2167 Some(e) => {
2168 let e = e
2169 .as_object()
2170 .ok_or("`plan.selection.expect` must be { max }")?;
2171 only_keys(e, &["max"], "`plan.selection.expect`")?;
2172 let max = e
2173 .get("max")
2174 .and_then(Value::as_u64)
2175 .and_then(|n| usize::try_from(n).ok())
2176 .ok_or("`plan.selection.expect.max` must be a non-negative integer")?;
2177 Some(SelectionExpect { max })
2178 }
2179 };
2180 let templates = obj
2181 .get("templates")
2182 .and_then(Value::as_array)
2183 .filter(|t| !t.is_empty())
2184 .ok_or("`plan.selection.templates` must be a non-empty array")?
2185 .clone();
2186 let cardinality = obj
2187 .get("cardinality")
2188 .and_then(Value::as_u64)
2189 .and_then(|n| usize::try_from(n).ok())
2190 .ok_or("`plan.selection.cardinality` must be a non-negative integer")?;
2191 let identities = obj
2192 .get("identities")
2193 .and_then(Value::as_array)
2194 .ok_or("`plan.selection.identities` must be an array of ids")?
2195 .iter()
2196 .map(|id| {
2197 id.as_str()
2198 .map(str::to_owned)
2199 .ok_or_else(|| "`plan.selection.identities[]` must be strings".to_owned())
2200 })
2201 .collect::<std::result::Result<Vec<_>, String>>()?;
2202 if identities.len() != cardinality {
2203 return Err(
2204 "`plan.selection.cardinality` must equal the number of identities".to_owned(),
2205 );
2206 }
2207 Ok(Self {
2208 query,
2209 expect,
2210 templates,
2211 cardinality,
2212 identities,
2213 })
2214 }
2215}
2216
2217#[derive(Clone, Debug, PartialEq)]
2219pub struct Plan {
2220 pub version: u64,
2221 pub planner_v: String,
2222 pub effects: Vec<Effect>,
2225 pub opset: PlanOpset,
2226 pub preconditions: Preconditions,
2227 pub new_ids: Vec<NewId>,
2228 pub selection: Option<Selection>,
2230 pub templates: Option<Vec<Value>>,
2236 pub plan_id: String,
2237 pub diagnostics: Vec<String>,
2238 pub preview: Option<PlanPreview>,
2239}
2240
2241#[derive(Clone, Debug, PartialEq)]
2243pub enum Admission {
2244 Refused(MutationError),
2246 Invalid(String),
2248}
2249
2250impl Plan {
2251 #[must_use]
2255 pub fn core_json(&self) -> Value {
2256 let mut core = json!({
2257 "version": self.version,
2258 "planner_v": self.planner_v,
2259 "effects": self.effects.iter().map(Effect::to_json).collect::<Vec<_>>(),
2260 "opset": self.opset.to_json(),
2261 "preconditions": self.preconditions.to_json(),
2262 "new_ids": self.new_ids.iter().map(|n| json!({ "symbol": n.symbol, "kind": n.kind.as_str() })).collect::<Vec<_>>(),
2263 });
2264 if let Some(sel) = &self.selection {
2265 core["selection"] = sel.to_json();
2266 }
2267 core
2268 }
2269
2270 #[must_use]
2272 pub fn digest(&self) -> String {
2273 digest(&self.core_json())
2274 }
2275
2276 #[must_use]
2278 pub fn sealed(mut self) -> Self {
2279 self.plan_id = self.digest();
2280 self
2281 }
2282
2283 #[must_use]
2285 pub fn to_json(&self, with_preview: bool) -> Value {
2286 let Value::Object(mut m) = self.core_json() else {
2287 unreachable!("core_json is an object")
2288 };
2289 if let Some(t) = &self.templates {
2290 m.insert("templates".to_owned(), Value::Array(t.clone()));
2291 }
2292 m.insert("plan_id".to_owned(), json!(self.plan_id));
2293 m.insert("diagnostics".to_owned(), json!(self.diagnostics));
2294 if with_preview {
2295 if let Some(p) = &self.preview {
2296 m.insert("preview".to_owned(), p.to_json());
2297 }
2298 }
2299 Value::Object(m)
2300 }
2301
2302 pub fn from_json(v: &Value) -> std::result::Result<Plan, String> {
2305 let obj = v.as_object().ok_or("`plan` must be an object")?;
2306 for k in [
2307 "planner_v",
2308 "effects",
2309 "opset",
2310 "preconditions",
2311 "new_ids",
2312 "plan_id",
2313 ] {
2314 if !obj.contains_key(k) {
2315 return Err(format!("`plan.{k}` is required"));
2316 }
2317 }
2318 let version = obj
2319 .get("version")
2320 .and_then(Value::as_u64)
2321 .ok_or("`plan.version` must be an integer")?;
2322 let planner_v = obj["planner_v"]
2323 .as_str()
2324 .ok_or("`plan.planner_v` must be a string")?
2325 .to_owned();
2326 let effects = obj["effects"]
2327 .as_array()
2328 .ok_or("`plan.effects` must be an array")?
2329 .iter()
2330 .enumerate()
2331 .map(|(i, e)| Effect::validate(e, i))
2332 .collect::<std::result::Result<Vec<_>, String>>()?;
2333 let opset = PlanOpset::from_json(&obj["opset"])?;
2334 let preconditions = Preconditions::from_json(&obj["preconditions"])?;
2335 let new_ids = obj["new_ids"]
2336 .as_array()
2337 .ok_or("`plan.new_ids` must be an array")?
2338 .iter()
2339 .map(|n| {
2340 let kind = match n.get("kind").and_then(Value::as_str) {
2341 Some("block") => NewIdKind::Block,
2342 Some("document") => NewIdKind::Document,
2343 _ => return Err("`plan.new_ids[].kind` must be block or document".to_owned()),
2344 };
2345 let symbol = n
2346 .get("symbol")
2347 .and_then(Value::as_str)
2348 .ok_or("`plan.new_ids[].symbol` must be a string")?
2349 .to_owned();
2350 Ok(NewId { symbol, kind })
2351 })
2352 .collect::<std::result::Result<Vec<_>, String>>()?;
2353 let plan_id = obj["plan_id"]
2354 .as_str()
2355 .ok_or("`plan.plan_id` must be a string")?
2356 .to_owned();
2357 let diagnostics = obj
2358 .get("diagnostics")
2359 .and_then(Value::as_array)
2360 .map(|a| {
2361 a.iter()
2362 .filter_map(Value::as_str)
2363 .map(str::to_owned)
2364 .collect()
2365 })
2366 .unwrap_or_default();
2367 let preview = match obj.get("preview") {
2368 None | Some(Value::Null) => None,
2369 Some(p) => Some(PlanPreview::from_json(p)?),
2370 };
2371 let selection = match obj.get("selection") {
2375 None => None,
2376 Some(s) => Some(Selection::from_json(s)?),
2377 };
2378 let templates = match obj.get("templates") {
2381 None | Some(Value::Null) => None,
2382 Some(t) => Some(
2383 t.as_array()
2384 .ok_or("`plan.templates` must be an array of effects as written")?
2385 .clone(),
2386 ),
2387 };
2388 Ok(Plan {
2389 version,
2390 planner_v,
2391 effects,
2392 opset,
2393 preconditions,
2394 new_ids,
2395 selection,
2396 templates,
2397 plan_id,
2398 diagnostics,
2399 preview,
2400 })
2401 }
2402
2403 pub fn admit(v: &Value) -> std::result::Result<Plan, Admission> {
2408 let obj = v
2409 .as_object()
2410 .ok_or_else(|| Admission::Invalid("`plan` must be an object".into()))?;
2411 let version = obj.get("version").cloned().unwrap_or(Value::Null);
2412 if version != json!(PLAN_VERSION) {
2413 return Err(Admission::Refused(unsupported_plan_version(&version)));
2414 }
2415 let plan = Plan::from_json(v).map_err(Admission::Invalid)?;
2416 let actual = plan.digest();
2417 if actual != plan.plan_id {
2418 return Err(Admission::Refused(plan_mismatch(&plan.plan_id, &actual)));
2419 }
2420 Ok(plan)
2421 }
2422}
2423
2424#[must_use]
2426pub fn unsupported_plan_version(version: &Value) -> MutationError {
2427 MutationError::with_data(
2428 ErrorCode::UnsupportedPlanVersion,
2429 format!("plan version {version} is not supported (supported: {PLAN_VERSION})"),
2430 json!({ "version": version, "supported": [PLAN_VERSION] }),
2431 )
2432}
2433
2434#[must_use]
2437pub fn plan_mismatch(expected: &str, actual: &str) -> MutationError {
2438 MutationError::with_data(
2439 ErrorCode::PlanMismatch,
2440 "the plan's digest does not match its plan_id; it was edited or produced by a different planner — re-plan",
2441 json!({ "expected": expected, "actual": actual }),
2442 )
2443}
2444
2445#[must_use]
2449pub fn plan_mismatch_replanned(expected: &str, actual: &str) -> MutationError {
2450 MutationError::with_data(
2451 ErrorCode::PlanMismatch,
2452 "the effects or the documents they target changed since the plan was made; re-plan",
2453 json!({ "expected": expected, "actual": actual }),
2454 )
2455}
2456
2457#[must_use]
2463pub fn intent_record(
2464 plan_id: &str,
2465 effects: &[Effect],
2466 selection: Option<&Selection>,
2467 templates: Option<&[Value]>,
2468) -> String {
2469 let mut record = json!({
2470 "v": 1,
2471 "plan_id": plan_id,
2472 "effects": effects.iter().map(Effect::to_json).collect::<Vec<_>>(),
2473 });
2474 if let Some(sel) = selection {
2475 record["selection"] = sel.to_json();
2476 }
2477 if let Some(t) = templates {
2478 record["templates"] = Value::Array(t.to_vec());
2479 }
2480 canonical_json(&record)
2481}
2482
2483#[cfg(test)]
2484mod tests {
2485 use super::*;
2486
2487 fn doc(id: &str, path: &str) -> Target {
2488 Target::Document {
2489 id: Some(id.into()),
2490 path: Some(path.into()),
2491 }
2492 }
2493
2494 fn fm_set(key: &str, value: Value) -> Effect {
2495 Effect::Set {
2496 target: doc("d_0", "a.md"),
2497 field: format!("frontmatter.{key}"),
2498 value,
2499 expect: None,
2500 }
2501 }
2502
2503 fn scalar(key: &str, target: FocusTarget, field: &str, value: Option<Value>) -> FocusInfo {
2504 FocusInfo {
2505 scalar: Some(ScalarWrite {
2506 key: key.into(),
2507 target,
2508 field: field.into(),
2509 value,
2510 }),
2511 docs: vec!["d_0".into()],
2512 ..Default::default()
2513 }
2514 }
2515
2516 #[test]
2517 fn symbols_parse() {
2518 assert_eq!(parse_new_symbol("$new.0"), Some(0));
2519 assert_eq!(parse_new_symbol("$new.12"), Some(12));
2520 assert_eq!(parse_new_symbol("$new."), None);
2521 assert_eq!(parse_new_symbol("$new.a"), None);
2522 assert_eq!(parse_new_symbol("b_0"), None);
2523 assert_eq!(new_symbol(3), "$new.3");
2524 }
2525
2526 #[test]
2527 fn effects_validate_and_round_trip_through_json() {
2528 let effects = json!([
2529 { "op": "create", "path": "n.md", "markdown": "# N", "frontmatter": { "k": 1 } },
2530 { "op": "insert", "into": { "kind": "section", "heading_id": "b_1" }, "at": "end", "markdown": "x" },
2531 { "op": "insert", "into": { "kind": "document", "id": "d_0", "path": "a.md" }, "at": { "after": "b_1" }, "markdown": "x", "expect": { "parent_children_hash": "cd", "revision": "r_0" } },
2532 { "op": "set", "target": { "kind": "document", "path": "a.md" }, "field": "frontmatter.status", "value": "done" },
2533 { "op": "set", "target": { "kind": "block", "id": "b_1" }, "field": "attrs.checked", "value": true, "expect": { "content_hash": "ab" } },
2534 { "op": "set", "target": { "kind": "node", "id": "n_1" }, "field": "value", "value": 3 },
2535 { "op": "unset", "target": { "kind": "document", "id": "d_0" }, "field": "frontmatter.old" },
2536 { "op": "move", "target": { "kind": "document", "id": "d_0" }, "to": "b.md", "retarget_inbound": true },
2537 { "op": "move", "targets": [{ "kind": "block", "id": "b_1" }], "into": { "kind": "block", "id": "b_2" }, "at": "start" },
2538 { "op": "remove", "target": { "kind": "document", "id": "d_0" } },
2539 { "op": "remove", "targets": [{ "kind": "block", "id": "b_1" }], "expect": { "b_1": { "content_hash": "ab" } } },
2540 { "op": "split", "target": { "kind": "block", "id": "b_1" }, "at": [3] },
2541 { "op": "merge", "targets": [{ "kind": "block", "id": "b_1" }, { "kind": "block", "id": "b_2" }], "separator": " " },
2542 { "op": "retarget", "repairs": [{ "from": "a.md", "to": "b.md" }], "path_glob": "notes/*" },
2543 ]);
2544 let parsed = Effect::list_from_json(&effects).unwrap();
2545 assert_eq!(parsed.len(), 14);
2546 for (v, e) in effects.as_array().unwrap().iter().zip(&parsed) {
2547 assert_eq!(e.to_json(), *v, "{}", e.name());
2548 }
2549 let e = Effect::from_json(&json!({ "op": "move", "target": { "kind": "document", "path": "a.md" }, "to": "b.md" })).unwrap();
2551 assert_eq!(e.to_json()["retarget_inbound"], json!(true));
2552 let e = Effect::from_json(
2553 &json!({ "op": "insert", "into": { "kind": "block", "id": "b_1" }, "markdown": "x" }),
2554 )
2555 .unwrap();
2556 assert_eq!(e.to_json()["at"], json!("end"));
2557 let bad = |v: Value| Effect::validate(&v, 2).unwrap_err();
2559 assert!(bad(json!({ "op": "nope" })).starts_with("effects[2]: `op` must be one of"));
2560 assert_eq!(
2561 bad(
2562 json!({ "op": "set", "target": { "kind": "block", "id": "b" }, "field": "markdown" })
2563 ),
2564 "effects[2]: `value` is required"
2565 );
2566 assert!(
2567 bad(json!({ "op": "move", "target": { "kind": "block", "id": "b" }, "to": "x" }))
2568 .contains("`kind` must be one of document")
2569 );
2570 assert!(
2571 bad(json!({ "op": "create", "path": "x.md", "markdown": "", "extra": 1 }))
2572 .contains("unknown key `extra`")
2573 );
2574 assert!(
2575 bad(json!({ "op": "remove", "targets": ["b_1"] }))
2576 .contains("effects[2].targets[0]: a target is")
2577 );
2578 assert!(bad(json!({ "op": "remove", "targets": [] })).contains("non-empty"));
2579 assert!(bad(json!({ "op": "set", "target": { "kind": "document", "path": "a.md" }, "field": "title", "value": 1 })).contains("a document's `field` is `frontmatter.<key>` or `content`"));
2580 assert!(bad(json!({ "op": "unset", "target": { "kind": "document", "path": "a.md" }, "field": "content" })).contains("(got \"content\")"));
2581 assert!(bad(json!({ "op": "set", "target": { "kind": "node", "id": "n" }, "field": "a.b", "value": 1 })).contains("editable properties"));
2582 assert!(bad(json!({ "op": "insert", "into": { "kind": "document", "path": "a.md" }, "at": { "before": "b", "after": "c" }, "markdown": "x" })).contains("`at` must be"));
2583 assert!(Effect::list_from_json(&json!([])).is_err());
2584 assert!(Effect::list_from_json(&json!({})).is_err());
2585 }
2586
2587 #[test]
2588 fn describe_and_classify() {
2589 assert_eq!(
2590 fm_set("status", json!(1)).describe(),
2591 "set frontmatter.status on document d_0"
2592 );
2593 let mv = Effect::MoveBlocks {
2594 targets: vec!["b_1".into(), "b_2".into()],
2595 into: Target::Section {
2596 heading_id: "b_0".into(),
2597 },
2598 at: At::End,
2599 expect: None,
2600 };
2601 assert_eq!(mv.describe(), "move block b_1,block b_2");
2602 let rt = Effect::Retarget {
2603 repairs: vec![Repair {
2604 from: "a".into(),
2605 to: "b".into(),
2606 }],
2607 path_glob: None,
2608 };
2609 assert_eq!(rt.describe(), "retarget a->b");
2610 assert_eq!(
2611 classify_field(&doc("d_0", "a.md"), "frontmatter.status"),
2612 Ok(Field::Frontmatter("status"))
2613 );
2614 assert_eq!(
2615 classify_field(&doc("d_0", "a.md"), "content"),
2616 Ok(Field::Content)
2617 );
2618 assert_eq!(
2619 classify_field(&Target::Block { id: "b".into() }, "attrs.checked"),
2620 Ok(Field::Checked)
2621 );
2622 assert_eq!(
2623 classify_field(&Target::Node { id: "n".into() }, "value"),
2624 Ok(Field::NodeProp("value"))
2625 );
2626 assert!(
2627 classify_field(
2628 &Target::Section {
2629 heading_id: "h".into()
2630 },
2631 "x"
2632 )
2633 .is_err()
2634 );
2635 }
2636
2637 #[test]
2638 fn placeholders_shift() {
2639 let op = Op::Move {
2640 blocks: vec!["$0.ids[1]".into(), "b_3".into()],
2641 to: To {
2642 parent: Parent::Block("$1.ids[0]".into()),
2643 at: At::After("$2.ids[0]".into()),
2644 },
2645 doc: None,
2646 expect: None,
2647 };
2648 assert_eq!(
2649 shift_placeholders(&op, 5).to_json(),
2650 json!({ "op": "move", "blocks": ["$5.ids[1]", "b_3"], "to": { "parent": "$6.ids[0]", "at": { "after": "$7.ids[0]" } } })
2651 );
2652 }
2653
2654 #[test]
2655 fn the_tracker_dedupes_identical_scalars_and_refuses_the_rest() {
2656 let mut t = ConflictTracker::default();
2657 let write = |v: Option<Value>| {
2658 scalar(
2659 "document:d_0:frontmatter.status",
2660 FocusTarget::document("d_0"),
2661 "frontmatter.status",
2662 v,
2663 )
2664 };
2665 assert!(
2666 t.check(
2667 0,
2668 &write(Some(json!("done"))),
2669 "set frontmatter.status on document d_0"
2670 )
2671 .unwrap()
2672 );
2673 assert!(
2674 !t.check(
2675 1,
2676 &write(Some(json!("done"))),
2677 "set frontmatter.status on document d_0"
2678 )
2679 .unwrap()
2680 );
2681 assert_eq!(
2682 t.diagnostics,
2683 ["effect 1 (set frontmatter.status on document d_0) duplicates effect 0; dropped"]
2684 );
2685 let e = t.check(2, &write(Some(json!("open"))), "x").unwrap_err();
2686 assert_eq!(e.code, ErrorCode::ConflictingEffects);
2687 assert_eq!(
2688 e.to_json(),
2689 json!({ "code": "conflicting_effects", "effects": [0, 2], "focus": { "kind": "scalar", "target": { "kind": "document", "id": "d_0" }, "field": "frontmatter.status" }, "retriable": false })
2690 );
2691 let e = t.check(3, &write(None), "x").unwrap_err();
2693 assert_eq!(e.data["effects"], json!([0, 3]));
2694 let mut t = ConflictTracker::default();
2696 let hit = FocusInfo {
2697 scalars: vec![ScalarWrite {
2698 key: "block:b_1:markdown".into(),
2699 target: FocusTarget::block("b_1"),
2700 field: "markdown".into(),
2701 value: None,
2702 }],
2703 blocks: vec!["b_1".into()],
2704 ..Default::default()
2705 };
2706 assert!(t.check(0, &hit, "retarget").unwrap());
2707 let e = t.check(1, &hit, "retarget").unwrap_err();
2708 assert_eq!(
2709 e.data["focus"]["target"],
2710 json!({ "kind": "block", "id": "b_1" })
2711 );
2712 }
2713
2714 #[test]
2715 fn the_tracker_enforces_identity_retirements() {
2716 let mut t = ConflictTracker::default();
2717 let remove = FocusInfo {
2718 docs: vec!["d_0".into()],
2719 blocks: vec!["b_1".into()],
2720 retires_blocks: vec!["b_1".into(), "b_2".into()],
2721 ..Default::default()
2722 };
2723 assert!(t.check(0, &remove, "remove block b_1").unwrap());
2724 let touch_child = FocusInfo {
2725 docs: vec!["d_0".into()],
2726 blocks: vec!["b_2".into()],
2727 ..Default::default()
2728 };
2729 let e = t.check(1, &touch_child, "x").unwrap_err();
2730 assert_eq!(
2731 e.data["focus"],
2732 json!({ "kind": "identity", "target": { "kind": "block", "id": "b_2" } })
2733 );
2734 let mut t = ConflictTracker::default();
2736 let mv = FocusInfo {
2737 docs: vec!["d_0".into()],
2738 paths: vec!["moved/a.md".into()],
2739 retires_paths: vec!["a.md".into()],
2740 ..Default::default()
2741 };
2742 assert!(t.check(0, &mv, "move").unwrap());
2743 let create = FocusInfo {
2744 paths: vec!["a.md".into()],
2745 ..Default::default()
2746 };
2747 let e = t.check(1, &create, "create a.md").unwrap_err();
2748 assert_eq!(
2749 e.data["focus"],
2750 json!({ "kind": "identity", "target": { "kind": "path", "path": "a.md" } })
2751 );
2752 let mut t = ConflictTracker::default();
2754 let insert = FocusInfo {
2755 docs: vec!["d_0".into()],
2756 blocks: vec!["b_2".into()],
2757 ..Default::default()
2758 };
2759 assert!(t.check(0, &insert, "insert").unwrap());
2760 let content = FocusInfo {
2761 scalar: Some(ScalarWrite {
2762 key: "document:d_0:content".into(),
2763 target: FocusTarget::document("d_0"),
2764 field: "content".into(),
2765 value: Some(json!("x")),
2766 }),
2767 docs: vec!["d_0".into()],
2768 whole_doc: Some("d_0".into()),
2769 ..Default::default()
2770 };
2771 let e = t
2772 .check(1, &content, "set content on document d_0")
2773 .unwrap_err();
2774 assert_eq!(
2775 e.data["focus"],
2776 json!({ "kind": "identity", "target": { "kind": "document", "id": "d_0" } })
2777 );
2778 let mut t = ConflictTracker::default();
2780 let rm = FocusInfo {
2781 docs: vec!["d_0".into()],
2782 retires_docs: vec!["d_0".into()],
2783 ..Default::default()
2784 };
2785 assert!(t.check(0, &rm, "remove document d_0").unwrap());
2786 let e = t.check(1, &insert, "insert").unwrap_err();
2787 assert_eq!(
2788 e.data["focus"]["target"],
2789 json!({ "kind": "document", "id": "d_0" })
2790 );
2791 let m = symbol_missing("$new.5", None, "block");
2792 assert_eq!(
2793 m.to_json(),
2794 json!({ "code": "target_missing", "symbol": "$new.5" })
2795 );
2796 let m = symbol_missing("$new.0", Some(NewIdKind::Document), "block");
2797 assert!(m.message.contains("names a document, not a block"));
2798 }
2799
2800 #[test]
2801 fn canonical_json_sorts_keys_and_prints_numbers_like_javascript() {
2802 let v = json!({ "b": [1, 2.0, 2.5, -0.0, 1e21, 1e-7, 123456789012345680000.0, 0.000001, 1.5e300],
2803 "a": { "z": null, "y": "q\"\n\u{1}é" }, "c": true });
2804 assert_eq!(
2805 canonical_json(&v),
2806 "{\"a\":{\"y\":\"q\\\"\\n\\u0001é\",\"z\":null},\"b\":[1,2,2.5,0,1e+21,1e-7,123456789012345680000,0.000001,1.5e+300],\"c\":true}"
2807 );
2808 assert_eq!(js_number(9007199254740993.0), "9007199254740992");
2809 assert_eq!(js_number(0.1), "0.1");
2810 assert_eq!(js_number(0.30000000000000004), "0.30000000000000004");
2811 assert_eq!(js_number(-1.5), "-1.5");
2812 assert_eq!(js_number(1e-6), "0.000001");
2813 assert_eq!(js_number(1.2e-7), "1.2e-7");
2814 assert_eq!(js_number(f64::NAN), "null");
2815 assert_eq!(js_number(100.0), "100");
2816 assert_eq!(js_number(1e20), "100000000000000000000");
2817 assert_eq!(js_number(1.7976931348623157e308), "1.7976931348623157e+308");
2818 assert_eq!(js_number(5e-324), "5e-324");
2819 let empty = json!({ "version": 1, "planner_v": "p1", "effects": [], "opset": { "creates": [], "moves": [], "tombstones": [], "set_frontmatter": [], "ops": [] }, "preconditions": { "docs": [], "blocks": [], "parents": [], "absent_paths": [] }, "new_ids": [] });
2822 assert_eq!(
2823 digest(&empty),
2824 "a20097a9c319b59a1313c83e1fd494bbb3cf4895040fbbddd2866e56b16cddd3"
2825 );
2826 let mut current = empty.clone();
2827 current["planner_v"] = json!(PLANNER_V);
2828 assert_eq!(
2829 digest(¤t),
2830 "5051e40051314fa855721d1943b42a9acb867c79a7b49eb4db0fd811559289ff"
2831 );
2832 current["planner_v"] = json!("p2");
2835 assert_eq!(
2836 digest(¤t),
2837 "6f43b302fdcd58ca4fd182b21a5bdab6fee3787ddc074f5e7c5c23b2cc368bf9"
2838 );
2839 let numbers: Value = serde_json::from_str(
2841 "[1, 1.5, 0.30000000000000004, 1e+21, 1e-07, 9007199254740992, 1.7976931348623157e+308, -2.5, 100, 123456789012345680000]",
2842 )
2843 .unwrap();
2844 assert_eq!(
2845 canonical_json(&numbers),
2846 "[1,1.5,0.30000000000000004,1e+21,1e-7,9007199254740992,1.7976931348623157e+308,-2.5,100,123456789012345680000]"
2847 );
2848 assert_eq!(
2850 canonical_json(&json!({ "é": "e", "z": 0 })),
2851 "{\"z\":0,\"é\":\"e\"}"
2852 );
2853 assert_eq!(canonical_json(&json!("a/b\u{1f}é😀")), "\"a/b\\u001fé😀\"");
2854 }
2855
2856 #[test]
2862 fn templates_ride_outside_the_digest() {
2863 let bare = Plan {
2864 version: PLAN_VERSION,
2865 planner_v: PLANNER_V.into(),
2866 effects: vec![fm_set("status", json!("done"))],
2867 opset: PlanOpset::default(),
2868 preconditions: Preconditions::default(),
2869 new_ids: vec![],
2870 selection: None,
2871 templates: None,
2872 plan_id: String::new(),
2873 diagnostics: vec![],
2874 preview: None,
2875 }
2876 .sealed();
2877 let written = vec![
2878 json!({ "op": "set", "target": "a.md", "field": "frontmatter.status", "value": "-> `done`" }),
2879 ];
2880 let sugared = Plan {
2881 templates: Some(written.clone()),
2882 ..bare.clone()
2883 }
2884 .sealed();
2885 assert_eq!(sugared.plan_id, bare.plan_id);
2886 assert!(sugared.core_json().get("templates").is_none());
2887 assert!(bare.to_json(false).get("templates").is_none());
2888 let wire = sugared.to_json(false);
2889 assert_eq!(wire["templates"], Value::Array(written.clone()));
2890 assert_eq!(Plan::admit(&wire).unwrap(), sugared);
2891 let mut nulled = wire.clone();
2892 nulled["templates"] = Value::Null;
2893 assert_eq!(Plan::admit(&nulled).unwrap(), bare);
2894 let mut mistyped = wire;
2895 mistyped["templates"] = json!("x");
2896 assert!(matches!(
2897 Plan::admit(&mistyped),
2898 Err(Admission::Invalid(m)) if m.contains("`plan.templates` must be an array")
2899 ));
2900 let plain = intent_record(&bare.plan_id, &bare.effects, None, None);
2901 assert!(!plain.contains("templates"), "{plain}");
2902 let record = intent_record(
2903 &sugared.plan_id,
2904 &sugared.effects,
2905 None,
2906 sugared.templates.as_deref(),
2907 );
2908 assert!(
2909 record.ends_with(
2910 "\"templates\":[{\"field\":\"frontmatter.status\",\"op\":\"set\",\"target\":\"a.md\",\"value\":\"-> `done`\"}],\"v\":1}"
2911 ),
2912 "{record}"
2913 );
2914 }
2915
2916 #[test]
2917 fn a_plan_digests_its_core_and_round_trips() {
2918 let plan = Plan {
2919 version: PLAN_VERSION,
2920 planner_v: PLANNER_V.into(),
2921 effects: vec![fm_set("status", json!("done"))],
2922 templates: None,
2923 opset: PlanOpset {
2924 set_frontmatter: vec![PlanFrontmatter {
2925 doc: "d_0".into(),
2926 raw: "---\nstatus: done\n---\n\n".into(),
2927 }],
2928 ..Default::default()
2929 },
2930 preconditions: Preconditions {
2931 docs: vec![DocPrecondition {
2932 id: "d_0".into(),
2933 path: "a.md".into(),
2934 base_revision: Some("r_0".into()),
2935 hash: None,
2936 }],
2937 ..Default::default()
2938 },
2939 new_ids: vec![NewId {
2940 symbol: "$new.0".into(),
2941 kind: NewIdKind::Block,
2942 }],
2943 selection: None,
2944 plan_id: String::new(),
2945 diagnostics: vec![],
2946 preview: Some(PlanPreview::default()),
2947 }
2948 .sealed();
2949 assert_eq!(plan.plan_id.len(), 64);
2950 assert_eq!(plan.plan_id, digest(&plan.core_json()));
2951 let wire = plan.to_json(true);
2952 assert!(wire.get("preview").is_some());
2953 assert_eq!(
2954 wire["preconditions"]["docs"][0]["hash"],
2955 Value::Null,
2956 "a null value stays"
2957 );
2958 assert_eq!(wire["opset"]["creates"], json!([]));
2959 let back = Plan::from_json(&wire).unwrap();
2960 assert_eq!(back, plan);
2961 let trimmed = plan.to_json(false);
2962 assert!(trimmed.get("preview").is_none());
2963 assert_eq!(Plan::admit(&trimmed).unwrap().digest(), plan.plan_id);
2964 let mut v2 = trimmed.clone();
2966 v2["version"] = json!(2);
2967 v2["opset"] = json!({});
2968 match Plan::admit(&v2) {
2969 Err(Admission::Refused(m)) => {
2970 assert_eq!(m.code, ErrorCode::UnsupportedPlanVersion);
2971 assert_eq!(m.data["supported"], json!([1]));
2972 assert_eq!(m.data["version"], json!(2));
2973 }
2974 other => panic!("{other:?}"),
2975 }
2976 let mut bare = trimmed.clone();
2977 bare["opset"] = json!({});
2978 assert!(
2979 matches!(Plan::admit(&bare), Err(Admission::Invalid(m)) if m.contains("opset.creates"))
2980 );
2981 let mut tampered = trimmed.clone();
2982 tampered["effects"] = json!([]);
2983 match Plan::admit(&tampered) {
2984 Err(Admission::Refused(m)) => {
2985 assert_eq!(m.code, ErrorCode::PlanMismatch);
2986 assert_eq!(m.data["expected"], json!(plan.plan_id));
2987 assert_ne!(m.data["actual"], json!(plan.plan_id));
2988 }
2989 other => panic!("{other:?}"),
2990 }
2991 let record = intent_record(&plan.plan_id, &plan.effects, None, None);
2992 assert!(
2993 record.starts_with("{\"effects\":[{\"field\":\"frontmatter.status\""),
2994 "{record}"
2995 );
2996 assert!(record.ends_with("\"v\":1}"));
2997 assert!(!record.contains("selection"));
2998 }
2999
3000 #[test]
3005 fn a_selection_is_digested_and_round_trips() {
3006 let selection = Selection {
3007 query: "from docs where status == \"completed\"".into(),
3008 expect: Some(SelectionExpect { max: 10 }),
3009 templates: vec![
3010 json!({ "op": "set", "field": "frontmatter.rolled_up", "value": true }),
3011 ],
3012 cardinality: 2,
3013 identities: vec!["d_1".into(), "d_0".into()],
3014 };
3015 assert_eq!(
3016 canonical_json(&selection.to_json()),
3017 "{\"cardinality\":2,\"expect\":{\"max\":10},\"identities\":[\"d_1\",\"d_0\"],\"query\":\"from docs where status == \\\"completed\\\"\",\"templates\":[{\"field\":\"frontmatter.rolled_up\",\"op\":\"set\",\"value\":true}]}"
3018 );
3019 let without = Selection {
3020 expect: None,
3021 ..selection.clone()
3022 };
3023 assert!(!canonical_json(&without.to_json()).contains("expect"));
3024 assert_eq!(
3025 Selection::from_json(&selection.to_json()),
3026 Ok(selection.clone())
3027 );
3028 assert_eq!(Selection::from_json(&without.to_json()), Ok(without));
3029 let bare = Plan {
3030 version: PLAN_VERSION,
3031 planner_v: PLANNER_V.into(),
3032 effects: vec![],
3033 opset: PlanOpset::default(),
3034 preconditions: Preconditions::default(),
3035 new_ids: vec![],
3036 selection: None,
3037 templates: None,
3038 plan_id: String::new(),
3039 diagnostics: vec![],
3040 preview: None,
3041 }
3042 .sealed();
3043 let selected = Plan {
3044 selection: Some(selection.clone()),
3045 ..bare.clone()
3046 }
3047 .sealed();
3048 assert_ne!(bare.plan_id, selected.plan_id);
3049 assert!(bare.core_json().get("selection").is_none());
3050 assert_eq!(selected.core_json()["selection"], selection.to_json());
3051 let zero = Plan {
3054 selection: Some(Selection {
3055 query: "from docs where type == \"nope\"".into(),
3056 expect: None,
3057 templates: vec![json!({ "op": "set", "field": "frontmatter.x", "value": 1 })],
3058 cardinality: 0,
3059 identities: vec![],
3060 }),
3061 ..bare.clone()
3062 }
3063 .sealed();
3064 assert_eq!(
3065 zero.plan_id,
3066 "73ca03407ddf05fb522c4118bd5ef1e88887f8430acbaba7428378a0b1e342c9"
3067 );
3068 let record = intent_record(
3070 &selected.plan_id,
3071 &selected.effects,
3072 selected.selection.as_ref(),
3073 None,
3074 );
3075 assert!(
3076 record.contains("\"selection\":{\"cardinality\":2"),
3077 "{record}"
3078 );
3079 let wire = selected.to_json(false);
3080 assert_eq!(Plan::admit(&wire).unwrap(), selected);
3081 let mut tampered = wire.clone();
3084 tampered["selection"]["identities"] = json!(["d_1", "d_2"]);
3085 assert!(matches!(
3086 Plan::admit(&tampered),
3087 Err(Admission::Refused(m)) if m.code == ErrorCode::PlanMismatch
3088 ));
3089 let mut short = wire.clone();
3090 short["selection"]["identities"] = json!(["d_1"]);
3091 assert!(matches!(
3092 Plan::admit(&short),
3093 Err(Admission::Invalid(m)) if m.contains("cardinality")
3094 ));
3095 let mut nulled = wire.clone();
3096 nulled["selection"] = Value::Null;
3097 assert!(matches!(Plan::admit(&nulled), Err(Admission::Invalid(_))));
3098 let mut empty = wire;
3099 empty["selection"]["templates"] = json!([]);
3100 assert!(matches!(
3101 Plan::admit(&empty),
3102 Err(Admission::Invalid(m)) if m.contains("templates")
3103 ));
3104 }
3105}