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