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 struct ShapeError {
72 pub message: String,
73 pub fix: Option<String>,
74}
75
76impl From<String> for ShapeError {
77 fn from(message: String) -> Self {
78 Self { message, fix: None }
79 }
80}
81
82#[derive(Clone, Debug, PartialEq, Eq)]
85pub struct ParentRow {
86 pub parent: Option<String>,
87 pub path: String,
88}
89
90pub struct ShapeFixContext<'a> {
99 pub select: Option<&'a Value>,
100 pub effects: &'a Value,
101 pub expect: Option<&'a Value>,
102 pub parent_of: &'a dyn Fn(&str) -> Option<ParentRow>,
103}
104
105struct Fixer<'a> {
109 ctx: &'a ShapeFixContext<'a>,
110 i: usize,
111 written: Map<String, Value>,
113}
114
115impl<'a> Fixer<'a> {
116 fn new(ctx: Option<&'a ShapeFixContext<'a>>, i: usize, v: &Map<String, Value>) -> Option<Self> {
117 let ctx = ctx?;
118 let written = ctx
119 .effects
120 .as_array()
121 .and_then(|l| l.get(i))
122 .and_then(Value::as_object)
123 .unwrap_or(v)
124 .clone();
125 Some(Self { ctx, i, written })
126 }
127
128 fn build(&self, effect: Map<String, Value>, expect: Option<Value>) -> String {
129 let effects = match self.ctx.effects {
130 Value::Array(list) => {
131 let mut copy = list.clone();
132 if self.i < copy.len() {
133 copy[self.i] = Value::Object(effect);
134 } else {
135 copy.resize(self.i, Value::Null);
136 copy.push(Value::Object(effect));
137 }
138 Value::Array(copy)
139 }
140 other => {
141 let mut o = other.as_object().cloned().unwrap_or_default();
142 o.insert(self.i.to_string(), Value::Object(effect));
143 Value::Object(o)
144 }
145 };
146 let mut m = Map::new();
147 if let Some(s) = self.ctx.select {
148 m.insert("select".to_owned(), s.clone());
149 }
150 m.insert("effects".to_owned(), effects);
151 match expect {
152 Some(e) => {
153 m.insert("expect".to_owned(), e);
154 }
155 None => {
156 if let Some(e) = self.ctx.expect {
157 m.insert("expect".to_owned(), e.clone());
158 }
159 }
160 }
161 canonical_json(&Value::Object(m))
162 }
163}
164
165fn reference_path(path: &str) -> String {
167 format!("/{}", path.trim_start_matches('/'))
168}
169
170fn effect_level_max(v: &Map<String, Value>, here: &str, f: Option<&Fixer<'_>>) -> ShapeError {
178 let message = format!(
179 "{here}: `expect.max` bounds a selection — it is the request's top-level `expect`, beside `select`, not an effect's"
180 );
181 let Some(f) = f else {
182 return message.into();
183 };
184 let mut rest = v.clone();
185 let max = rest.remove("max").unwrap_or(Value::Null);
186 let mut effect = f.written.clone();
187 if rest.is_empty() {
188 effect.remove("expect");
189 } else {
190 effect.insert("expect".to_owned(), Value::Object(rest));
191 }
192 if f.ctx.select.is_none() {
193 return ShapeError {
194 message,
195 fix: Some(f.build(effect, None)),
196 };
197 }
198 let mut top = f
199 .ctx
200 .expect
201 .and_then(Value::as_object)
202 .cloned()
203 .unwrap_or_default();
204 if !top.contains_key("max") {
205 top.insert("max".to_owned(), max);
206 }
207 ShapeError {
208 message,
209 fix: Some(f.build(effect, Some(Value::Object(top)))),
210 }
211}
212
213fn bare_anchor(at: &str, into: &Target, here: &str, f: Option<&Fixer<'_>>) -> ShapeError {
219 let message = format!(
220 "{here}: `at` must be \"start\", \"end\", {{ before: <block id> }} or {{ after: <block id> }} — \"{at} block X\" is into: X's parent (a block, or the document when X is top-level), at: {{ {at}: X }}"
221 );
222 let (Some(f), Target::Block { id }) = (f, into) else {
223 return message.into();
224 };
225 let Some(row) = (f.ctx.parent_of)(id) else {
226 return message.into();
227 };
228 let parent = match row.parent {
229 None => json!(reference_path(&row.path)),
230 Some(p) => json!({ "kind": "block", "id": p }),
231 };
232 let mut effect = f.written.clone();
233 effect.insert("into".to_owned(), parent);
234 effect.insert("at".to_owned(), json!({ at: id }));
235 ShapeError {
236 message,
237 fix: Some(f.build(effect, None)),
238 }
239}
240
241fn inverted_expect_map(
245 v: &Map<String, Value>,
246 here: &str,
247 f: Option<&Fixer<'_>>,
248) -> Option<ShapeError> {
249 let is_string_record = |x: &Value| {
250 x.as_object()
251 .is_some_and(|o| o.values().all(Value::is_string))
252 };
253 if v.is_empty()
254 || !v
255 .keys()
256 .all(|k| k == "content_hash" || k == "parent_children_hash")
257 || !v.values().all(is_string_record)
258 {
259 return None;
260 }
261 let message = format!(
262 "{here}: a plural-target `expect` is keyed by block id — {{ <block id>: {{ content_hash?, parent_children_hash? }}, … }}"
263 );
264 let Some(f) = f else {
265 return Some(message.into());
266 };
267 let mut inverted: Map<String, Value> = Map::new();
268 for (guard, by_id) in v {
269 for (id, hash) in by_id.as_object().expect("checked") {
270 let entry = inverted
271 .entry(id.clone())
272 .or_insert_with(|| Value::Object(Map::new()));
273 if let Some(o) = entry.as_object_mut() {
274 o.insert(guard.clone(), hash.clone());
275 }
276 }
277 }
278 let mut effect = f.written.clone();
279 effect.insert("expect".to_owned(), Value::Object(inverted));
280 Some(ShapeError {
281 message,
282 fix: Some(f.build(effect, None)),
283 })
284}
285
286const BLOCK_FIELD_ALIASES: &[&str] = &["text", "content", "body", "raw"];
288
289#[derive(Clone, Debug, PartialEq, Eq)]
295pub enum Target {
296 Document {
297 id: Option<String>,
298 path: Option<String>,
299 },
300 Block {
301 id: String,
302 },
303 Node {
304 id: String,
305 },
306 Section {
307 heading_id: String,
308 },
309}
310
311impl Target {
312 #[must_use]
314 pub const fn kind(&self) -> &'static str {
315 match self {
316 Target::Document { .. } => "document",
317 Target::Block { .. } => "block",
318 Target::Node { .. } => "node",
319 Target::Section { .. } => "section",
320 }
321 }
322
323 #[must_use]
326 pub fn id(&self) -> Option<&str> {
327 match self {
328 Target::Document { id, .. } => id.as_deref(),
329 Target::Block { id } | Target::Node { id } => Some(id),
330 Target::Section { heading_id } => Some(heading_id),
331 }
332 }
333
334 #[must_use]
336 pub fn to_json(&self) -> Value {
337 let mut m = Map::new();
338 m.insert("kind".to_owned(), json!(self.kind()));
339 match self {
340 Target::Document { id, path } => {
341 if let Some(id) = id {
342 m.insert("id".to_owned(), json!(id));
343 }
344 if let Some(path) = path {
345 m.insert("path".to_owned(), json!(path));
346 }
347 }
348 Target::Block { id } | Target::Node { id } => {
349 m.insert("id".to_owned(), json!(id));
350 }
351 Target::Section { heading_id } => {
352 m.insert("heading_id".to_owned(), json!(heading_id));
353 }
354 }
355 Value::Object(m)
356 }
357
358 pub fn from_json(v: &Value) -> std::result::Result<Target, String> {
360 validate_target(Some(v), "target", &["document", "block", "node", "section"])
361 }
362}
363
364fn block_refs_to_json(ids: &[String]) -> Value {
366 Value::Array(
367 ids.iter()
368 .map(|id| Target::Block { id: id.clone() }.to_json())
369 .collect(),
370 )
371}
372
373#[derive(Clone, Debug, Default, PartialEq, Eq)]
378pub struct EffectExpect {
379 pub content_hash: Option<String>,
380 pub parent_children_hash: Option<String>,
381 pub revision: Option<String>,
382}
383
384impl EffectExpect {
385 #[must_use]
387 pub fn kernel(&self) -> Option<Expect> {
388 if self.content_hash.is_none() && self.parent_children_hash.is_none() {
389 return None;
390 }
391 Some(Expect {
392 content_hash: self.content_hash.clone(),
393 parent_children_hash: self.parent_children_hash.clone(),
394 })
395 }
396
397 #[must_use]
398 pub fn to_json(&self) -> Value {
399 let mut m = Map::new();
400 if let Some(h) = &self.content_hash {
401 m.insert("content_hash".to_owned(), json!(h));
402 }
403 if let Some(h) = &self.parent_children_hash {
404 m.insert("parent_children_hash".to_owned(), json!(h));
405 }
406 if let Some(r) = &self.revision {
407 m.insert("revision".to_owned(), json!(r));
408 }
409 Value::Object(m)
410 }
411
412 pub fn from_json(v: &Value) -> std::result::Result<Self, String> {
413 validate_expect(Some(v), "expect", None)
414 .map(|e| e.expect("given"))
415 .map_err(|e| e.message)
416 }
417}
418
419#[derive(Clone, Debug, PartialEq, Eq)]
423pub struct Repair {
424 pub from: String,
425 pub to: String,
426}
427
428pub type ExpectMap = BTreeMap<String, Expect>;
430
431#[derive(Clone, Debug, PartialEq)]
434pub enum Effect {
435 Create {
438 path: String,
439 markdown: String,
440 frontmatter: Option<Map<String, Value>>,
441 },
442 Insert {
445 into: Target,
446 at: At,
447 markdown: String,
448 expect: Option<EffectExpect>,
449 },
450 Set {
454 target: Target,
455 field: String,
456 value: Value,
457 expect: Option<EffectExpect>,
458 },
459 Unset {
461 target: Target,
462 field: String,
463 expect: Option<EffectExpect>,
464 },
465 MoveDocument {
468 target: Target,
469 to: String,
470 retarget_inbound: bool,
471 },
472 MoveBlocks {
474 targets: Vec<String>,
475 into: Target,
476 at: At,
477 expect: Option<EffectExpect>,
478 },
479 RemoveDocument { target: Target },
481 RemoveBlocks {
483 targets: Vec<String>,
484 expect: Option<ExpectMap>,
485 },
486 Split {
488 target: String,
489 at: Vec<usize>,
490 expect: Option<EffectExpect>,
491 },
492 Merge {
495 targets: Vec<String>,
496 separator: Option<String>,
497 expect: Option<ExpectMap>,
498 },
499 Retarget {
501 repairs: Vec<Repair>,
502 path_glob: Option<String>,
503 },
504}
505
506#[must_use]
508pub fn at_to_json(at: &At) -> Value {
509 match at {
510 At::Start => json!("start"),
511 At::End => json!("end"),
512 At::Before(id) => json!({ "before": id }),
513 At::After(id) => json!({ "after": id }),
514 }
515}
516
517pub fn at_from_json(v: Option<&Value>, here: &str) -> std::result::Result<At, String> {
519 match v {
520 None => Ok(At::End),
521 Some(Value::String(s)) if s == "start" => Ok(At::Start),
522 Some(Value::String(s)) if s == "end" => Ok(At::End),
523 Some(Value::Object(a)) if a.len() == 1 => {
524 if let Some(b) = a.get("before").and_then(Value::as_str) {
525 Ok(At::Before(b.to_owned()))
526 } else if let Some(b) = a.get("after").and_then(Value::as_str) {
527 Ok(At::After(b.to_owned()))
528 } else {
529 Err(format!(
530 "{here}: `at` must be \"start\", \"end\", {{ before: <block id> }} or {{ after: <block id> }}"
531 ))
532 }
533 }
534 _ => Err(format!(
535 "{here}: `at` must be \"start\", \"end\", {{ before: <block id> }} or {{ after: <block id> }}"
536 )),
537 }
538}
539
540fn validate_at(
545 v: Option<&Value>,
546 here: &str,
547 into: &Target,
548 f: Option<&Fixer<'_>>,
549) -> std::result::Result<At, ShapeError> {
550 if let Some(Value::String(s)) = v
551 && (s == "before" || s == "after")
552 {
553 return Err(bare_anchor(s, into, here, f));
554 }
555 at_from_json(v, here).map_err(Into::into)
556}
557
558fn require_string(
559 obj: &Map<String, Value>,
560 key: &str,
561 here: &str,
562) -> std::result::Result<String, String> {
563 obj.get(key)
564 .and_then(Value::as_str)
565 .map(str::to_owned)
566 .ok_or_else(|| format!("{here}: `{key}` must be a string"))
567}
568
569fn optional_string(
570 obj: &Map<String, Value>,
571 key: &str,
572 here: &str,
573) -> std::result::Result<Option<String>, String> {
574 match obj.get(key) {
575 None => Ok(None),
576 Some(Value::String(s)) => Ok(Some(s.clone())),
577 Some(_) => Err(format!("{here}: `{key}` must be a string")),
578 }
579}
580
581fn only_keys(
582 obj: &Map<String, Value>,
583 allowed: &[&str],
584 here: &str,
585) -> std::result::Result<(), String> {
586 let extra: Vec<String> = obj
587 .keys()
588 .filter(|k| !allowed.contains(&k.as_str()))
589 .map(|k| format!("`{k}`"))
590 .collect();
591 if extra.is_empty() {
592 Ok(())
593 } else {
594 Err(format!(
595 "{here}: unknown key{} {}",
596 if extra.len() > 1 { "s" } else { "" },
597 extra.join(", ")
598 ))
599 }
600}
601
602fn validate_target(
604 v: Option<&Value>,
605 here: &str,
606 kinds: &[&str],
607) -> std::result::Result<Target, String> {
608 let kinds_text = kinds
609 .iter()
610 .map(|k| format!("\"{k}\""))
611 .collect::<Vec<_>>()
612 .join(" | ");
613 let obj = v
614 .and_then(Value::as_object)
615 .filter(|o| o.get("kind").is_some_and(Value::is_string))
616 .ok_or_else(|| format!("{here}: a target is {{ kind: {kinds_text}, … }}"))?;
617 let kind = obj["kind"].as_str().expect("checked");
618 if !kinds.contains(&kind) {
619 return Err(format!(
620 "{here}: `kind` must be one of {} (got \"{kind}\")",
621 kinds.join(", ")
622 ));
623 }
624 Ok(match kind {
625 "document" => {
626 only_keys(obj, &["kind", "id", "path"], here)?;
627 let id = optional_string(obj, "id", here)?;
628 let path = optional_string(obj, "path", here)?;
629 if id.is_none() && path.is_none() {
630 return Err(format!("{here}: a document target needs `id` or `path`"));
631 }
632 Target::Document { id, path }
633 }
634 "block" => {
635 only_keys(obj, &["kind", "id"], here)?;
636 Target::Block {
637 id: require_string(obj, "id", here)?,
638 }
639 }
640 "node" => {
641 only_keys(obj, &["kind", "id"], here)?;
642 Target::Node {
643 id: require_string(obj, "id", here)?,
644 }
645 }
646 "section" => {
647 only_keys(obj, &["kind", "heading_id"], here)?;
648 Target::Section {
649 heading_id: require_string(obj, "heading_id", here)?,
650 }
651 }
652 _ => unreachable!("kinds are the four above"),
653 })
654}
655
656fn validate_targets(v: Option<&Value>, here: &str) -> std::result::Result<Vec<String>, String> {
658 let list = v
659 .and_then(Value::as_array)
660 .filter(|a| !a.is_empty())
661 .ok_or_else(|| format!("{here}: `targets` must be a non-empty array of block targets"))?;
662 list.iter()
663 .enumerate()
664 .map(|(i, t)| {
665 match validate_target(Some(t), &format!("{here}.targets[{i}]"), &["block"])? {
666 Target::Block { id } => Ok(id),
667 _ => unreachable!("kinds is [block]"),
668 }
669 })
670 .collect()
671}
672
673fn validate_expect(
674 v: Option<&Value>,
675 here: &str,
676 f: Option<&Fixer<'_>>,
677) -> std::result::Result<Option<EffectExpect>, ShapeError> {
678 let Some(v) = v else { return Ok(None) };
679 let obj = v
680 .as_object()
681 .ok_or_else(|| format!("{here}: `expect` must be an object"))?;
682 if obj.contains_key("max") {
683 return Err(effect_level_max(obj, here, f));
684 }
685 let at = format!("{here}.expect");
686 only_keys(
687 obj,
688 &["content_hash", "parent_children_hash", "revision"],
689 &at,
690 )?;
691 Ok(Some(EffectExpect {
692 content_hash: optional_string(obj, "content_hash", &at)?,
693 parent_children_hash: optional_string(obj, "parent_children_hash", &at)?,
694 revision: optional_string(obj, "revision", &at)?,
695 }))
696}
697
698fn validate_expect_map(
699 v: Option<&Value>,
700 here: &str,
701 f: Option<&Fixer<'_>>,
702) -> std::result::Result<Option<ExpectMap>, ShapeError> {
703 let Some(v) = v else { return Ok(None) };
704 let obj = v
705 .as_object()
706 .ok_or_else(|| format!("{here}: `expect` must be an object keyed by block id"))?;
707 if obj.contains_key("max") {
708 return Err(effect_level_max(obj, here, f));
709 }
710 if let Some(e) = inverted_expect_map(obj, here, f) {
711 return Err(e);
712 }
713 let mut out = ExpectMap::new();
714 for (id, x) in obj {
715 let at = format!("{here}.expect[{}]", Value::String(id.clone()));
716 let x = x
717 .as_object()
718 .ok_or_else(|| format!("{at}: must be an object"))?;
719 only_keys(x, &["content_hash", "parent_children_hash"], &at)?;
720 out.insert(
721 id.clone(),
722 Expect {
723 content_hash: optional_string(x, "content_hash", &at)?,
724 parent_children_hash: optional_string(x, "parent_children_hash", &at)?,
725 },
726 );
727 }
728 Ok(Some(out))
729}
730
731fn check_field(
733 target: &Target,
734 field: &str,
735 here: &str,
736 op: &str,
737 f: Option<&Fixer<'_>>,
738) -> std::result::Result<(), ShapeError> {
739 match target {
740 Target::Document { .. } => {
741 if field
742 .strip_prefix("frontmatter.")
743 .is_some_and(|k| !k.is_empty())
744 || (op == "set" && field == "content")
745 {
746 Ok(())
747 } else {
748 Err(format!(
749 "{here}: a document's `field` is `frontmatter.<key>`{} (got \"{field}\")",
750 if op == "set" { " or `content`" } else { "" }
751 )
752 .into())
753 }
754 }
755 Target::Block { .. } => {
756 if field == "markdown" || field == "attrs.checked" {
757 Ok(())
758 } else {
759 let fix = f.filter(|_| BLOCK_FIELD_ALIASES.contains(&field)).map(|f| {
761 let mut effect = f.written.clone();
762 effect.insert("field".to_owned(), json!("markdown"));
763 f.build(effect, None)
764 });
765 Err(ShapeError {
766 message: format!(
767 "{here}: a block's `field` is `markdown` or `attrs.checked` (got \"{field}\")"
768 ),
769 fix,
770 })
771 }
772 }
773 Target::Node { .. } => {
774 if field.is_empty() || field.contains('.') {
775 Err(format!(
776 "{here}: a node's `field` is one of its editable properties (got \"{field}\")"
777 )
778 .into())
779 } else {
780 Ok(())
781 }
782 }
783 Target::Section { .. } => Err(format!("{here}: a section has no settable field").into()),
784 }
785}
786
787fn expect_map_to_json(m: &ExpectMap) -> Value {
788 Value::Object(
789 m.iter()
790 .map(|(k, e)| {
791 let mut o = Map::new();
792 if let Some(h) = &e.content_hash {
793 o.insert("content_hash".to_owned(), json!(h));
794 }
795 if let Some(h) = &e.parent_children_hash {
796 o.insert("parent_children_hash".to_owned(), json!(h));
797 }
798 (k.clone(), Value::Object(o))
799 })
800 .collect(),
801 )
802}
803
804impl Effect {
805 #[must_use]
807 pub const fn name(&self) -> &'static str {
808 match self {
809 Effect::Create { .. } => "create",
810 Effect::Insert { .. } => "insert",
811 Effect::Set { .. } => "set",
812 Effect::Unset { .. } => "unset",
813 Effect::MoveDocument { .. } | Effect::MoveBlocks { .. } => "move",
814 Effect::RemoveDocument { .. } | Effect::RemoveBlocks { .. } => "remove",
815 Effect::Split { .. } => "split",
816 Effect::Merge { .. } => "merge",
817 Effect::Retarget { .. } => "retarget",
818 }
819 }
820
821 #[must_use]
825 pub fn to_json(&self) -> Value {
826 let mut m = Map::new();
827 m.insert("op".to_owned(), json!(self.name()));
828 let expect = |m: &mut Map<String, Value>, e: &Option<EffectExpect>| {
829 if let Some(e) = e {
830 m.insert("expect".to_owned(), e.to_json());
831 }
832 };
833 match self {
834 Effect::Create {
835 path,
836 markdown,
837 frontmatter,
838 } => {
839 m.insert("path".to_owned(), json!(path));
840 m.insert("markdown".to_owned(), json!(markdown));
841 if let Some(fm) = frontmatter {
842 m.insert("frontmatter".to_owned(), Value::Object(fm.clone()));
843 }
844 }
845 Effect::Insert {
846 into,
847 at,
848 markdown,
849 expect: e,
850 } => {
851 m.insert("into".to_owned(), into.to_json());
852 m.insert("at".to_owned(), at_to_json(at));
853 m.insert("markdown".to_owned(), json!(markdown));
854 expect(&mut m, e);
855 }
856 Effect::Set {
857 target,
858 field,
859 value,
860 expect: e,
861 } => {
862 m.insert("target".to_owned(), target.to_json());
863 m.insert("field".to_owned(), json!(field));
864 m.insert("value".to_owned(), value.clone());
865 expect(&mut m, e);
866 }
867 Effect::Unset {
868 target,
869 field,
870 expect: e,
871 } => {
872 m.insert("target".to_owned(), target.to_json());
873 m.insert("field".to_owned(), json!(field));
874 expect(&mut m, e);
875 }
876 Effect::MoveDocument {
877 target,
878 to,
879 retarget_inbound,
880 } => {
881 m.insert("target".to_owned(), target.to_json());
882 m.insert("to".to_owned(), json!(to));
883 m.insert("retarget_inbound".to_owned(), json!(retarget_inbound));
884 }
885 Effect::MoveBlocks {
886 targets,
887 into,
888 at,
889 expect: e,
890 } => {
891 m.insert("targets".to_owned(), block_refs_to_json(targets));
892 m.insert("into".to_owned(), into.to_json());
893 m.insert("at".to_owned(), at_to_json(at));
894 expect(&mut m, e);
895 }
896 Effect::RemoveDocument { target } => {
897 m.insert("target".to_owned(), target.to_json());
898 }
899 Effect::RemoveBlocks { targets, expect: e } => {
900 m.insert("targets".to_owned(), block_refs_to_json(targets));
901 if let Some(e) = e {
902 m.insert("expect".to_owned(), expect_map_to_json(e));
903 }
904 }
905 Effect::Split {
906 target,
907 at,
908 expect: e,
909 } => {
910 m.insert(
911 "target".to_owned(),
912 Target::Block { id: target.clone() }.to_json(),
913 );
914 m.insert("at".to_owned(), json!(at));
915 expect(&mut m, e);
916 }
917 Effect::Merge {
918 targets,
919 separator,
920 expect: e,
921 } => {
922 m.insert("targets".to_owned(), block_refs_to_json(targets));
923 if let Some(s) = separator {
924 m.insert("separator".to_owned(), json!(s));
925 }
926 if let Some(e) = e {
927 m.insert("expect".to_owned(), expect_map_to_json(e));
928 }
929 }
930 Effect::Retarget { repairs, path_glob } => {
931 m.insert(
932 "repairs".to_owned(),
933 Value::Array(
934 repairs
935 .iter()
936 .map(|r| json!({ "from": r.from, "to": r.to }))
937 .collect(),
938 ),
939 );
940 if let Some(g) = path_glob {
941 m.insert("path_glob".to_owned(), json!(g));
942 }
943 }
944 }
945 Value::Object(m)
946 }
947
948 pub fn validate(v: &Value, i: usize) -> std::result::Result<Effect, String> {
953 Effect::validate_with(v, i, None).map_err(|e| e.message)
954 }
955
956 pub fn validate_with(
961 v: &Value,
962 i: usize,
963 fix: Option<&ShapeFixContext<'_>>,
964 ) -> std::result::Result<Effect, ShapeError> {
965 let here = format!("effects[{i}]");
966 let ops = "create/insert/set/unset/move/remove/split/merge/retarget";
967 let obj = v
968 .as_object()
969 .filter(|o| o.get("op").is_some_and(Value::is_string))
970 .ok_or_else(|| format!("{here}: an effect is an object whose `op` is one of {ops}"))?;
971 let op = obj["op"].as_str().expect("checked");
972 let fixer = Fixer::new(fix, i, obj);
973 let f = fixer.as_ref();
974 Ok(match op {
975 "create" => {
976 only_keys(obj, &["op", "path", "markdown", "frontmatter"], &here)?;
977 let path = require_string(obj, "path", &here)?;
978 let markdown = require_string(obj, "markdown", &here)?;
979 let frontmatter = match obj.get("frontmatter") {
980 None => None,
981 Some(Value::Object(fm)) => Some(fm.clone()),
982 Some(_) => {
983 return Err(format!("{here}: `frontmatter` must be an object").into());
984 }
985 };
986 Effect::Create {
987 path,
988 markdown,
989 frontmatter,
990 }
991 }
992 "insert" => {
993 only_keys(obj, &["op", "into", "at", "markdown", "expect"], &here)?;
994 let into = validate_target(
995 obj.get("into"),
996 &format!("{here}.into"),
997 &["document", "section", "block"],
998 )?;
999 let at = validate_at(obj.get("at"), &here, &into, f)?;
1000 let markdown = require_string(obj, "markdown", &here)?;
1001 let expect = validate_expect(obj.get("expect"), &here, f)?;
1002 Effect::Insert {
1003 into,
1004 at,
1005 markdown,
1006 expect,
1007 }
1008 }
1009 "set" => {
1010 only_keys(obj, &["op", "target", "field", "value", "expect"], &here)?;
1011 let target = validate_target(
1012 obj.get("target"),
1013 &format!("{here}.target"),
1014 &["document", "block", "node"],
1015 )?;
1016 let field = require_string(obj, "field", &here)?;
1017 let value = obj
1018 .get("value")
1019 .cloned()
1020 .ok_or_else(|| format!("{here}: `value` is required"))?;
1021 check_field(&target, &field, &here, "set", f)?;
1022 let expect = validate_expect(obj.get("expect"), &here, f)?;
1023 Effect::Set {
1024 target,
1025 field,
1026 value,
1027 expect,
1028 }
1029 }
1030 "unset" => {
1031 only_keys(obj, &["op", "target", "field", "expect"], &here)?;
1032 let target =
1033 validate_target(obj.get("target"), &format!("{here}.target"), &["document"])?;
1034 let field = require_string(obj, "field", &here)?;
1035 check_field(&target, &field, &here, "unset", None)?;
1036 let expect = validate_expect(obj.get("expect"), &here, f)?;
1037 Effect::Unset {
1038 target,
1039 field,
1040 expect,
1041 }
1042 }
1043 "move" => {
1044 if obj.contains_key("targets") {
1045 only_keys(obj, &["op", "targets", "into", "at", "expect"], &here)?;
1046 let targets = validate_targets(obj.get("targets"), &here)?;
1047 let into = validate_target(
1048 obj.get("into"),
1049 &format!("{here}.into"),
1050 &["document", "section", "block"],
1051 )?;
1052 let at = validate_at(obj.get("at"), &here, &into, f)?;
1053 let expect = validate_expect(obj.get("expect"), &here, f)?;
1054 Effect::MoveBlocks {
1055 targets,
1056 into,
1057 at,
1058 expect,
1059 }
1060 } else {
1061 only_keys(obj, &["op", "target", "to", "retarget_inbound"], &here)?;
1062 let target = validate_target(
1063 obj.get("target"),
1064 &format!("{here}.target"),
1065 &["document"],
1066 )?;
1067 let to = require_string(obj, "to", &here)?;
1068 let retarget_inbound = match obj.get("retarget_inbound") {
1069 None => true,
1070 Some(Value::Bool(b)) => *b,
1071 Some(_) => {
1072 return Err(
1073 format!("{here}: `retarget_inbound` must be a boolean").into()
1074 );
1075 }
1076 };
1077 Effect::MoveDocument {
1078 target,
1079 to,
1080 retarget_inbound,
1081 }
1082 }
1083 }
1084 "remove" => {
1085 if obj.contains_key("targets") {
1086 only_keys(obj, &["op", "targets", "expect"], &here)?;
1087 Effect::RemoveBlocks {
1088 targets: validate_targets(obj.get("targets"), &here)?,
1089 expect: validate_expect_map(obj.get("expect"), &here, f)?,
1090 }
1091 } else {
1092 only_keys(obj, &["op", "target"], &here)?;
1093 Effect::RemoveDocument {
1094 target: validate_target(
1095 obj.get("target"),
1096 &format!("{here}.target"),
1097 &["document"],
1098 )?,
1099 }
1100 }
1101 }
1102 "split" => {
1103 only_keys(obj, &["op", "target", "at", "expect"], &here)?;
1104 let Target::Block { id } =
1105 validate_target(obj.get("target"), &format!("{here}.target"), &["block"])?
1106 else {
1107 unreachable!("kinds is [block]")
1108 };
1109 let at = obj
1110 .get("at")
1111 .and_then(Value::as_array)
1112 .and_then(|a| {
1113 a.iter()
1114 .map(|n| n.as_u64().and_then(|n| usize::try_from(n).ok()))
1115 .collect::<Option<Vec<usize>>>()
1116 })
1117 .ok_or_else(|| format!("{here}: `at` must be an array of byte offsets"))?;
1118 let expect = validate_expect(obj.get("expect"), &here, f)?;
1119 Effect::Split {
1120 target: id,
1121 at,
1122 expect,
1123 }
1124 }
1125 "merge" => {
1126 only_keys(obj, &["op", "targets", "separator", "expect"], &here)?;
1127 Effect::Merge {
1128 targets: validate_targets(obj.get("targets"), &here)?,
1129 separator: optional_string(obj, "separator", &here)?,
1130 expect: validate_expect_map(obj.get("expect"), &here, f)?,
1131 }
1132 }
1133 "retarget" => {
1134 only_keys(obj, &["op", "repairs", "path_glob"], &here)?;
1135 let repairs = obj
1136 .get("repairs")
1137 .and_then(Value::as_array)
1138 .ok_or_else(|| format!("{here}: `repairs` must be an array of {{ from, to }}"))?
1139 .iter()
1140 .enumerate()
1141 .map(|(j, r)| {
1142 let at = format!("{here}.repairs[{j}]");
1143 let r = r
1144 .as_object()
1145 .ok_or_else(|| format!("{at}: must be {{ from, to }}"))?;
1146 only_keys(r, &["from", "to"], &at)?;
1147 Ok(Repair {
1148 from: require_string(r, "from", &at)?,
1149 to: require_string(r, "to", &at)?,
1150 })
1151 })
1152 .collect::<std::result::Result<Vec<_>, String>>()?;
1153 Effect::Retarget {
1154 repairs,
1155 path_glob: optional_string(obj, "path_glob", &here)?,
1156 }
1157 }
1158 other => {
1159 return Err(format!("{here}: `op` must be one of {ops} (got \"{other}\")").into());
1160 }
1161 })
1162 }
1163
1164 pub fn from_json(v: &Value) -> std::result::Result<Effect, String> {
1166 Effect::validate(v, 0)
1167 }
1168
1169 pub fn list_from_json(v: &Value) -> std::result::Result<Vec<Effect>, String> {
1172 let list = v
1173 .as_array()
1174 .ok_or("`effects` must be an array of effects")?;
1175 if list.is_empty() {
1176 return Err("`effects` must name at least one effect".into());
1177 }
1178 list.iter()
1179 .enumerate()
1180 .map(|(i, e)| Effect::validate(e, i))
1181 .collect()
1182 }
1183
1184 #[must_use]
1186 pub fn expect(&self) -> Option<&EffectExpect> {
1187 match self {
1188 Effect::Insert { expect, .. }
1189 | Effect::Set { expect, .. }
1190 | Effect::Unset { expect, .. }
1191 | Effect::MoveBlocks { expect, .. }
1192 | Effect::Split { expect, .. } => expect.as_ref(),
1193 _ => None,
1194 }
1195 }
1196
1197 #[must_use]
1200 pub fn describe(&self) -> String {
1201 fn name(t: &Target) -> String {
1202 match t {
1203 Target::Section { heading_id } => format!("section {heading_id}"),
1204 other => format!("{} {}", other.kind(), other.id().unwrap_or_default()),
1205 }
1206 }
1207 fn blocks(ids: &[String]) -> String {
1208 ids.iter()
1209 .map(|id| format!("block {id}"))
1210 .collect::<Vec<_>>()
1211 .join(",")
1212 }
1213 match self {
1214 Effect::Create { path, .. } => format!("create {path}"),
1215 Effect::Insert { into, .. } => format!("insert into {}", name(into)),
1216 Effect::Set { target, field, .. } => format!("set {field} on {}", name(target)),
1217 Effect::Unset { target, field, .. } => format!("unset {field} on {}", name(target)),
1218 Effect::MoveDocument { target, to, .. } => format!("move {} -> {to}", name(target)),
1219 Effect::MoveBlocks { targets, .. } => format!("move {}", blocks(targets)),
1220 Effect::RemoveDocument { target } => format!("remove {}", name(target)),
1221 Effect::RemoveBlocks { targets, .. } => format!("remove {}", blocks(targets)),
1222 Effect::Split { target, .. } => format!("split block {target}"),
1223 Effect::Merge { targets, .. } => format!("merge {}", blocks(targets)),
1224 Effect::Retarget { repairs, .. } => format!(
1225 "retarget {}",
1226 repairs
1227 .iter()
1228 .map(|r| format!("{}->{}", r.from, r.to))
1229 .collect::<Vec<_>>()
1230 .join(",")
1231 ),
1232 }
1233 }
1234}
1235
1236#[derive(Clone, Debug, PartialEq, Eq)]
1240pub enum Field<'a> {
1241 Frontmatter(&'a str),
1243 Content,
1245 Markdown,
1247 Checked,
1249 NodeProp(&'a str),
1251}
1252
1253pub fn classify_field<'a>(
1255 target: &Target,
1256 field: &'a str,
1257) -> std::result::Result<Field<'a>, String> {
1258 check_field(target, field, "field", "set", None).map_err(|e| e.message)?;
1259 Ok(match target {
1260 Target::Document { .. } => match field.strip_prefix("frontmatter.") {
1261 Some(key) => Field::Frontmatter(key),
1262 None => Field::Content,
1263 },
1264 Target::Block { .. } => {
1265 if field == "markdown" {
1266 Field::Markdown
1267 } else {
1268 Field::Checked
1269 }
1270 }
1271 Target::Node { .. } => Field::NodeProp(field),
1272 Target::Section { .. } => unreachable!("check_field rejects sections"),
1273 })
1274}
1275
1276#[must_use]
1279pub fn shift_placeholders(op: &Op, offset: usize) -> Op {
1280 map_block_ids(op, |v| match crate::changeset::parse_placeholder(v) {
1281 Some((n, i)) => format!("${}.ids[{i}]", n + offset),
1282 None => v.to_owned(),
1283 })
1284}
1285
1286#[must_use]
1291pub fn map_block_ids(op: &Op, f: impl Fn(&str) -> String) -> Op {
1292 let s = |v: &String| -> String { f(v) };
1293 let to = |t: &To| To {
1294 parent: match &t.parent {
1295 Parent::Block(id) => Parent::Block(s(id)),
1296 Parent::Section { heading } => Parent::Section {
1297 heading: s(heading),
1298 },
1299 Parent::Doc => Parent::Doc,
1300 },
1301 at: match &t.at {
1302 At::Before(id) => At::Before(s(id)),
1303 At::After(id) => At::After(s(id)),
1304 other => other.clone(),
1305 },
1306 };
1307 let map = |m: &Option<BTreeMap<String, Expect>>| -> Option<BTreeMap<String, Expect>> {
1308 m.as_ref()
1309 .map(|m| m.iter().map(|(k, e)| (s(k), e.clone())).collect())
1310 };
1311 match op {
1312 Op::Insert {
1313 doc,
1314 to: t,
1315 markdown,
1316 expect,
1317 } => Op::Insert {
1318 doc: doc.as_ref().map(s),
1319 to: to(t),
1320 markdown: markdown.clone(),
1321 expect: expect.clone(),
1322 },
1323 Op::Update {
1324 block,
1325 markdown,
1326 attrs,
1327 expect,
1328 trivia,
1329 child_ids,
1330 } => Op::Update {
1331 block: s(block),
1332 markdown: markdown.clone(),
1333 attrs: attrs.clone(),
1334 expect: expect.clone(),
1335 trivia: trivia.clone(),
1336 child_ids: child_ids.clone(),
1337 },
1338 Op::Move {
1339 blocks,
1340 to: t,
1341 doc,
1342 expect,
1343 } => Op::Move {
1344 blocks: blocks.iter().map(s).collect(),
1345 to: to(t),
1346 doc: doc.as_ref().map(s),
1347 expect: expect.clone(),
1348 },
1349 Op::Remove { blocks, expect } => Op::Remove {
1350 blocks: blocks.iter().map(s).collect(),
1351 expect: map(expect),
1352 },
1353 Op::Split { block, at, expect } => Op::Split {
1354 block: s(block),
1355 at: at.clone(),
1356 expect: expect.clone(),
1357 },
1358 Op::Merge {
1359 blocks,
1360 separator,
1361 expect,
1362 } => Op::Merge {
1363 blocks: blocks.iter().map(s).collect(),
1364 separator: separator.clone(),
1365 expect: map(expect),
1366 },
1367 }
1368}
1369
1370#[derive(Clone, Debug, PartialEq, Eq)]
1374pub struct FocusTarget {
1375 pub kind: &'static str,
1376 pub id: Option<String>,
1377 pub path: Option<String>,
1378}
1379
1380impl FocusTarget {
1381 #[must_use]
1382 pub fn document(id: &str) -> Self {
1383 Self {
1384 kind: "document",
1385 id: Some(id.to_owned()),
1386 path: None,
1387 }
1388 }
1389
1390 #[must_use]
1391 pub fn block(id: &str) -> Self {
1392 Self {
1393 kind: "block",
1394 id: Some(id.to_owned()),
1395 path: None,
1396 }
1397 }
1398
1399 #[must_use]
1400 pub fn node(id: &str) -> Self {
1401 Self {
1402 kind: "node",
1403 id: Some(id.to_owned()),
1404 path: None,
1405 }
1406 }
1407
1408 #[must_use]
1409 pub fn path(path: &str) -> Self {
1410 Self {
1411 kind: "path",
1412 id: None,
1413 path: Some(path.to_owned()),
1414 }
1415 }
1416
1417 #[must_use]
1418 pub fn to_json(&self) -> Value {
1419 let mut m = Map::new();
1420 m.insert("kind".to_owned(), json!(self.kind));
1421 if let Some(id) = &self.id {
1422 m.insert("id".to_owned(), json!(id));
1423 }
1424 if let Some(path) = &self.path {
1425 m.insert("path".to_owned(), json!(path));
1426 }
1427 Value::Object(m)
1428 }
1429}
1430
1431#[derive(Clone, Debug, PartialEq, Eq)]
1433pub struct Focus {
1434 pub kind: &'static str,
1436 pub target: FocusTarget,
1437 pub field: Option<String>,
1438}
1439
1440impl Focus {
1441 #[must_use]
1442 pub fn to_json(&self) -> Value {
1443 let mut m = Map::new();
1444 m.insert("kind".to_owned(), json!(self.kind));
1445 m.insert("target".to_owned(), self.target.to_json());
1446 if let Some(f) = &self.field {
1447 m.insert("field".to_owned(), json!(f));
1448 }
1449 Value::Object(m)
1450 }
1451}
1452
1453#[must_use]
1455pub fn conflicting_effects(i: usize, j: usize, focus: &Focus) -> MutationError {
1456 let who = focus
1457 .target
1458 .id
1459 .clone()
1460 .or_else(|| focus.target.path.clone())
1461 .unwrap_or_default();
1462 let what = match &focus.field {
1463 Some(f) => format!("{} {who} {f}", focus.target.kind),
1464 None => format!("{} {who}", focus.target.kind),
1465 };
1466 MutationError::with_data(
1467 ErrorCode::ConflictingEffects,
1468 format!("effects {i} and {j} contradict each other on {what}"),
1469 json!({ "effects": [i, j], "focus": focus.to_json(), "retriable": false }),
1470 )
1471}
1472
1473#[must_use]
1476pub fn symbol_missing(symbol: &str, kind: Option<NewIdKind>, wanted: &str) -> MutationError {
1477 let message = match kind {
1478 None => format!("{symbol} is not created by an earlier effect"),
1479 Some(k) => format!("{symbol} names a {}, not a {wanted}", k.as_str()),
1480 };
1481 MutationError::with_data(
1482 ErrorCode::TargetMissing,
1483 message,
1484 json!({ "symbol": symbol }),
1485 )
1486}
1487
1488#[derive(Clone, Debug, PartialEq)]
1490pub struct ScalarWrite {
1491 pub key: String,
1493 pub target: FocusTarget,
1494 pub field: String,
1495 pub value: Option<Value>,
1497}
1498
1499#[derive(Clone, Debug, Default, PartialEq)]
1502pub struct FocusInfo {
1503 pub scalar: Option<ScalarWrite>,
1504 pub scalars: Vec<ScalarWrite>,
1506 pub docs: Vec<String>,
1508 pub blocks: Vec<String>,
1509 pub paths: Vec<String>,
1510 pub retires_docs: Vec<String>,
1512 pub retires_blocks: Vec<String>,
1513 pub retires_paths: Vec<String>,
1514 pub whole_doc: Option<String>,
1516}
1517
1518#[derive(Debug, Default)]
1520pub struct ConflictTracker {
1521 scalar: HashMap<String, (usize, ScalarWrite)>,
1522 retired_docs: HashMap<String, usize>,
1523 retired_blocks: HashMap<String, usize>,
1524 retired_paths: HashMap<String, usize>,
1525 touched_docs: HashMap<String, usize>,
1526 pub diagnostics: Vec<String>,
1528}
1529
1530impl ConflictTracker {
1531 pub fn check(&mut self, i: usize, info: &FocusInfo, describe: &str) -> Result<bool> {
1535 for d in &info.docs {
1536 if let Some(&by) = self.retired_docs.get(d) {
1537 return Err(conflicting_effects(
1538 by,
1539 i,
1540 &identity(FocusTarget::document(d)),
1541 ));
1542 }
1543 }
1544 for b in &info.blocks {
1545 if let Some(&by) = self.retired_blocks.get(b) {
1546 return Err(conflicting_effects(by, i, &identity(FocusTarget::block(b))));
1547 }
1548 }
1549 for p in &info.paths {
1550 if let Some(&by) = self.retired_paths.get(p) {
1551 return Err(conflicting_effects(by, i, &identity(FocusTarget::path(p))));
1552 }
1553 }
1554 if let Some(d) = &info.whole_doc {
1555 if let Some(&by) = self.touched_docs.get(d) {
1556 return Err(conflicting_effects(
1557 by,
1558 i,
1559 &identity(FocusTarget::document(d)),
1560 ));
1561 }
1562 }
1563 if let Some(s) = &info.scalar {
1564 if let Some((prev_i, prev)) = self.scalar.get(&s.key) {
1565 let identical = match (&prev.value, &s.value) {
1566 (None, None) => true,
1567 (Some(a), Some(b)) => canonical_json(a) == canonical_json(b),
1568 _ => false,
1569 };
1570 if !identical {
1571 return Err(conflicting_effects(
1572 *prev_i,
1573 i,
1574 &Focus {
1575 kind: "scalar",
1576 target: s.target.clone(),
1577 field: Some(s.field.clone()),
1578 },
1579 ));
1580 }
1581 self.diagnostics.push(format!(
1582 "effect {i} ({describe}) duplicates effect {prev_i}; dropped"
1583 ));
1584 return Ok(false);
1585 }
1586 }
1587 for s in &info.scalars {
1588 if let Some((prev_i, _)) = self.scalar.get(&s.key) {
1589 return Err(conflicting_effects(
1590 *prev_i,
1591 i,
1592 &Focus {
1593 kind: "scalar",
1594 target: s.target.clone(),
1595 field: Some(s.field.clone()),
1596 },
1597 ));
1598 }
1599 }
1600 if let Some(s) = &info.scalar {
1602 self.scalar.insert(s.key.clone(), (i, s.clone()));
1603 }
1604 for s in &info.scalars {
1605 self.scalar.insert(s.key.clone(), (i, s.clone()));
1606 }
1607 for d in &info.docs {
1608 self.touched_docs.entry(d.clone()).or_insert(i);
1609 }
1610 for d in &info.retires_docs {
1611 self.retired_docs.insert(d.clone(), i);
1612 }
1613 for b in &info.retires_blocks {
1614 self.retired_blocks.insert(b.clone(), i);
1615 }
1616 for p in &info.retires_paths {
1617 self.retired_paths.insert(p.clone(), i);
1618 }
1619 Ok(true)
1620 }
1621}
1622
1623fn identity(target: FocusTarget) -> Focus {
1624 Focus {
1625 kind: "identity",
1626 target,
1627 field: None,
1628 }
1629}
1630
1631#[must_use]
1635pub fn canonical_json(v: &Value) -> String {
1636 let mut out = String::new();
1637 write_canonical(v, &mut out);
1638 out
1639}
1640
1641fn write_canonical(v: &Value, out: &mut String) {
1642 match v {
1643 Value::Object(map) => {
1644 let mut keys: Vec<&String> = map.keys().collect();
1645 keys.sort_unstable_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
1646 out.push('{');
1647 for (i, k) in keys.iter().enumerate() {
1648 if i > 0 {
1649 out.push(',');
1650 }
1651 out.push_str(&Value::String((*k).clone()).to_string());
1652 out.push(':');
1653 write_canonical(&map[*k], out);
1654 }
1655 out.push('}');
1656 }
1657 Value::Array(items) => {
1658 out.push('[');
1659 for (i, item) in items.iter().enumerate() {
1660 if i > 0 {
1661 out.push(',');
1662 }
1663 write_canonical(item, out);
1664 }
1665 out.push(']');
1666 }
1667 Value::Number(n) => out.push_str(&canonical_number(n)),
1668 scalar => out.push_str(&scalar.to_string()),
1669 }
1670}
1671
1672fn canonical_number(n: &serde_json::Number) -> String {
1674 if let Some(i) = n.as_i64() {
1675 return i.to_string();
1676 }
1677 if let Some(u) = n.as_u64() {
1678 return u.to_string();
1679 }
1680 let Some(f) = n.as_f64() else {
1681 return "null".to_owned();
1682 };
1683 js_number(f)
1684}
1685
1686#[must_use]
1688pub fn js_number(x: f64) -> String {
1689 const TWO_53: f64 = 9_007_199_254_740_992.0;
1690 if !x.is_finite() {
1691 return "null".to_owned();
1692 }
1693 if x == 0.0 {
1694 return "0".to_owned();
1695 }
1696 if x.fract() == 0.0 && x.abs() < TWO_53 {
1697 #[allow(clippy::cast_possible_truncation)]
1699 return (x as i64).to_string();
1700 }
1701 let sci = format!("{:e}", x.abs());
1704 let (mantissa, exp) = sci.split_once('e').expect("{:e} has an exponent");
1705 let exp: i32 = exp.parse().expect("{:e} exponent is an integer");
1706 let digits: String = mantissa.chars().filter(|c| *c != '.').collect();
1707 let k = digits.len() as i32;
1708 let n = exp + 1; let mut out = String::new();
1710 if x < 0.0 {
1711 out.push('-');
1712 }
1713 if k <= n && n <= 21 {
1714 out.push_str(&digits);
1715 out.push_str(&"0".repeat((n - k) as usize));
1716 } else if 0 < n && n <= 21 {
1717 out.push_str(&digits[..n as usize]);
1718 out.push('.');
1719 out.push_str(&digits[n as usize..]);
1720 } else if -6 < n && n <= 0 {
1721 out.push_str("0.");
1722 out.push_str(&"0".repeat((-n) as usize));
1723 out.push_str(&digits);
1724 } else {
1725 let e = n - 1;
1726 out.push_str(&digits[..1]);
1727 if k > 1 {
1728 out.push('.');
1729 out.push_str(&digits[1..]);
1730 }
1731 out.push('e');
1732 out.push(if e < 0 { '-' } else { '+' });
1733 out.push_str(&e.abs().to_string());
1734 }
1735 out
1736}
1737
1738#[must_use]
1740pub fn digest(core: &Value) -> String {
1741 hex(&sha256(canonical_json(core).as_bytes()))
1742}
1743
1744#[derive(Clone, Debug, PartialEq, Eq)]
1751pub struct PlanCreate {
1752 pub symbol: String,
1753 pub path: String,
1754 pub source: String,
1755 pub before_op: usize,
1756}
1757
1758#[derive(Clone, Debug, PartialEq, Eq)]
1760pub struct PlanMove {
1761 pub id: String,
1762 pub from: String,
1763 pub to: String,
1764}
1765
1766#[derive(Clone, Debug, PartialEq, Eq)]
1768pub struct PlanTombstone {
1769 pub id: String,
1770 pub path: String,
1771}
1772
1773#[derive(Clone, Debug, PartialEq, Eq)]
1777pub struct PlanFrontmatter {
1778 pub doc: String,
1779 pub raw: String,
1780}
1781
1782#[derive(Clone, Debug, Default, PartialEq)]
1785pub struct PlanOpset {
1786 pub creates: Vec<PlanCreate>,
1787 pub moves: Vec<PlanMove>,
1788 pub tombstones: Vec<PlanTombstone>,
1789 pub set_frontmatter: Vec<PlanFrontmatter>,
1790 pub ops: Vec<Op>,
1791}
1792
1793impl PlanOpset {
1794 #[must_use]
1795 pub fn to_json(&self) -> Value {
1796 json!({
1797 "creates": self.creates.iter().map(|c| json!({ "symbol": c.symbol, "path": c.path, "source": c.source, "before_op": c.before_op })).collect::<Vec<_>>(),
1798 "moves": self.moves.iter().map(|m| json!({ "id": m.id, "from": m.from, "to": m.to })).collect::<Vec<_>>(),
1799 "tombstones": self.tombstones.iter().map(|t| json!({ "id": t.id, "path": t.path })).collect::<Vec<_>>(),
1800 "set_frontmatter": self.set_frontmatter.iter().map(|f| json!({ "doc": f.doc, "raw": f.raw })).collect::<Vec<_>>(),
1801 "ops": self.ops.iter().map(Op::to_json).collect::<Vec<_>>(),
1802 })
1803 }
1804
1805 pub fn from_json(v: &Value) -> std::result::Result<Self, String> {
1806 let obj = v.as_object().ok_or("`plan.opset` must be an object")?;
1807 let arr = |k: &str| -> std::result::Result<&Vec<Value>, String> {
1808 obj.get(k)
1809 .and_then(Value::as_array)
1810 .ok_or_else(|| format!("`plan.opset.{k}` must be an array"))
1811 };
1812 let s = |v: &Value, k: &str, here: &str| -> std::result::Result<String, String> {
1813 v.get(k)
1814 .and_then(Value::as_str)
1815 .map(str::to_owned)
1816 .ok_or_else(|| format!("`plan.opset.{here}.{k}` must be a string"))
1817 };
1818 Ok(Self {
1819 creates: arr("creates")?
1820 .iter()
1821 .map(|c| {
1822 let str_of = |k: &str| c.get(k).and_then(Value::as_str).map(str::to_owned);
1823 let before_op = c
1824 .get("before_op")
1825 .and_then(Value::as_u64)
1826 .and_then(|n| usize::try_from(n).ok());
1827 match (str_of("symbol"), str_of("path"), str_of("source"), before_op) {
1828 (Some(symbol), Some(path), Some(source), Some(before_op)) => {
1829 Ok(PlanCreate {
1830 symbol,
1831 path,
1832 source,
1833 before_op,
1834 })
1835 }
1836 _ => Err(
1837 "`plan.opset.creates[]` entries are { symbol, path, source, before_op }"
1838 .to_owned(),
1839 ),
1840 }
1841 })
1842 .collect::<std::result::Result<_, String>>()?,
1843 moves: arr("moves")?
1844 .iter()
1845 .map(|m| {
1846 Ok(PlanMove {
1847 id: s(m, "id", "moves[]")?,
1848 from: s(m, "from", "moves[]")?,
1849 to: s(m, "to", "moves[]")?,
1850 })
1851 })
1852 .collect::<std::result::Result<_, String>>()?,
1853 tombstones: arr("tombstones")?
1854 .iter()
1855 .map(|t| {
1856 Ok(PlanTombstone {
1857 id: s(t, "id", "tombstones[]")?,
1858 path: s(t, "path", "tombstones[]")?,
1859 })
1860 })
1861 .collect::<std::result::Result<_, String>>()?,
1862 set_frontmatter: arr("set_frontmatter")?
1863 .iter()
1864 .map(|f| {
1865 Ok(PlanFrontmatter {
1866 doc: s(f, "doc", "set_frontmatter[]")?,
1867 raw: s(f, "raw", "set_frontmatter[]")?,
1868 })
1869 })
1870 .collect::<std::result::Result<_, String>>()?,
1871 ops: arr("ops")?
1872 .iter()
1873 .map(Op::from_json)
1874 .collect::<std::result::Result<Vec<_>, String>>()?,
1875 })
1876 }
1877}
1878
1879#[derive(Clone, Debug, PartialEq, Eq)]
1882pub struct DocPrecondition {
1883 pub id: String,
1884 pub path: String,
1885 pub base_revision: Option<String>,
1886 pub hash: Option<String>,
1888}
1889
1890#[derive(Clone, Debug, PartialEq, Eq)]
1892pub struct BlockPrecondition {
1893 pub id: String,
1894 pub content_hash: String,
1895}
1896
1897#[derive(Clone, Debug, PartialEq, Eq)]
1900pub struct ParentPrecondition {
1901 pub doc: String,
1902 pub parent: Option<String>,
1903 pub parent_children_hash: String,
1904}
1905
1906#[derive(Clone, Debug, Default, PartialEq, Eq)]
1908pub struct Preconditions {
1909 pub docs: Vec<DocPrecondition>,
1910 pub blocks: Vec<BlockPrecondition>,
1911 pub parents: Vec<ParentPrecondition>,
1912 pub absent_paths: Vec<String>,
1913}
1914
1915impl Preconditions {
1916 #[must_use]
1917 pub fn to_json(&self) -> Value {
1918 json!({
1919 "docs": self.docs.iter().map(|d| json!({ "id": d.id, "path": d.path, "base_revision": d.base_revision, "hash": d.hash })).collect::<Vec<_>>(),
1920 "blocks": self.blocks.iter().map(|b| json!({ "id": b.id, "content_hash": b.content_hash })).collect::<Vec<_>>(),
1921 "parents": self.parents.iter().map(|p| json!({ "doc": p.doc, "parent": p.parent, "parent_children_hash": p.parent_children_hash })).collect::<Vec<_>>(),
1922 "absent_paths": self.absent_paths,
1923 })
1924 }
1925
1926 pub fn from_json(v: &Value) -> std::result::Result<Self, String> {
1927 let obj = v
1928 .as_object()
1929 .ok_or("`plan.preconditions` must be an object")?;
1930 let arr = |k: &str| -> std::result::Result<&Vec<Value>, String> {
1931 obj.get(k)
1932 .and_then(Value::as_array)
1933 .ok_or_else(|| format!("`plan.preconditions.{k}` must be an array"))
1934 };
1935 let s = |v: &Value, k: &str, here: &str| -> std::result::Result<String, String> {
1936 v.get(k)
1937 .and_then(Value::as_str)
1938 .map(str::to_owned)
1939 .ok_or_else(|| format!("`plan.preconditions.{here}.{k}` must be a string"))
1940 };
1941 let opt = |v: &Value, k: &str, here: &str| -> std::result::Result<Option<String>, String> {
1942 match v.get(k) {
1943 None | Some(Value::Null) => Ok(None),
1944 Some(Value::String(x)) => Ok(Some(x.clone())),
1945 Some(_) => Err(format!(
1946 "`plan.preconditions.{here}.{k}` must be a string or null"
1947 )),
1948 }
1949 };
1950 Ok(Self {
1951 docs: arr("docs")?
1952 .iter()
1953 .map(|d| {
1954 Ok(DocPrecondition {
1955 id: s(d, "id", "docs[]")?,
1956 path: s(d, "path", "docs[]")?,
1957 base_revision: opt(d, "base_revision", "docs[]")?,
1958 hash: opt(d, "hash", "docs[]")?,
1959 })
1960 })
1961 .collect::<std::result::Result<_, String>>()?,
1962 blocks: arr("blocks")?
1963 .iter()
1964 .map(|b| {
1965 Ok(BlockPrecondition {
1966 id: s(b, "id", "blocks[]")?,
1967 content_hash: s(b, "content_hash", "blocks[]")?,
1968 })
1969 })
1970 .collect::<std::result::Result<_, String>>()?,
1971 parents: arr("parents")?
1972 .iter()
1973 .map(|p| {
1974 Ok(ParentPrecondition {
1975 doc: s(p, "doc", "parents[]")?,
1976 parent: opt(p, "parent", "parents[]")?,
1977 parent_children_hash: s(p, "parent_children_hash", "parents[]")?,
1978 })
1979 })
1980 .collect::<std::result::Result<_, String>>()?,
1981 absent_paths: arr("absent_paths")?
1982 .iter()
1983 .map(|p| {
1984 p.as_str().map(str::to_owned).ok_or_else(|| {
1985 "`plan.preconditions.absent_paths[]` must be strings".to_owned()
1986 })
1987 })
1988 .collect::<std::result::Result<_, String>>()?,
1989 })
1990 }
1991}
1992
1993#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1995pub enum NewIdKind {
1996 Block,
1997 Document,
1998}
1999
2000impl NewIdKind {
2001 #[must_use]
2002 pub const fn as_str(&self) -> &'static str {
2003 match self {
2004 NewIdKind::Block => "block",
2005 NewIdKind::Document => "document",
2006 }
2007 }
2008
2009 #[must_use]
2011 pub fn from_prefix(prefix: &str) -> Option<Self> {
2012 match prefix {
2013 "b" => Some(NewIdKind::Block),
2014 "d" => Some(NewIdKind::Document),
2015 _ => None,
2016 }
2017 }
2018}
2019
2020#[derive(Clone, Debug, PartialEq, Eq)]
2022pub struct NewId {
2023 pub symbol: String,
2024 pub kind: NewIdKind,
2025}
2026
2027#[derive(Clone, Debug, PartialEq, Eq)]
2030pub struct PlanRevision {
2031 pub id: String,
2032 pub path: String,
2033 pub hash: String,
2034}
2035
2036#[derive(Clone, Debug, PartialEq)]
2040pub struct IdentityOp {
2041 pub op_index: usize,
2042 pub disposition: PlanDisposition,
2043 pub blocks: Vec<String>,
2045 pub confidence: Option<f64>,
2046 pub reason: Option<String>,
2047 pub detail: Option<Value>,
2049}
2050
2051#[derive(Clone, Debug, PartialEq)]
2056pub struct IdentityPreview {
2057 pub matcher_v: String,
2058 pub summary: OpsetSummary,
2059 pub ops: Vec<IdentityOp>,
2060 pub diagnostics: Vec<String>,
2061}
2062
2063impl IdentityPreview {
2064 #[must_use]
2065 pub fn to_json(&self) -> Value {
2066 json!({
2067 "matcher_v": self.matcher_v,
2068 "summary": self.summary.to_json(),
2069 "ops": self.ops.iter().map(|o| {
2070 let mut m = Map::new();
2071 m.insert("op_index".to_owned(), json!(o.op_index));
2072 m.insert("disposition".to_owned(), json!(o.disposition.as_str()));
2073 m.insert("blocks".to_owned(), json!(o.blocks));
2074 m.insert("confidence".to_owned(), json!(o.confidence));
2075 m.insert("reason".to_owned(), json!(o.reason));
2076 if let Some(d) = &o.detail {
2077 m.insert("detail".to_owned(), d.clone());
2078 }
2079 Value::Object(m)
2080 }).collect::<Vec<_>>(),
2081 "diagnostics": self.diagnostics,
2082 })
2083 }
2084
2085 pub fn from_json(v: &Value) -> std::result::Result<Self, String> {
2086 let obj = v
2087 .as_object()
2088 .ok_or("`plan.preview.identity[]` must be an object")?;
2089 let matcher_v = obj
2090 .get("matcher_v")
2091 .and_then(Value::as_str)
2092 .ok_or("`plan.preview.identity[].matcher_v` must be a string")?
2093 .to_owned();
2094 let summary = obj
2095 .get("summary")
2096 .and_then(Value::as_object)
2097 .ok_or("`plan.preview.identity[].summary` must be an object")?;
2098 let count = |k: &str| -> usize {
2099 summary
2100 .get(k)
2101 .and_then(Value::as_u64)
2102 .and_then(|n| usize::try_from(n).ok())
2103 .unwrap_or(0)
2104 };
2105 let summary = OpsetSummary {
2106 preserved: count("preserved"),
2107 updated: count("updated"),
2108 moved: count("moved"),
2109 created: count("created"),
2110 removed: count("removed"),
2111 split: count("split"),
2112 merged: count("merged"),
2113 ambiguous: count("ambiguous"),
2114 };
2115 let ops = obj
2116 .get("ops")
2117 .and_then(Value::as_array)
2118 .ok_or("`plan.preview.identity[].ops` must be an array")?
2119 .iter()
2120 .map(|o| {
2121 let disposition = o
2122 .get("disposition")
2123 .and_then(Value::as_str)
2124 .and_then(PlanDisposition::parse)
2125 .ok_or("`plan.preview.identity[].ops[].disposition` is not a disposition")?;
2126 let op_index = o
2127 .get("op_index")
2128 .and_then(Value::as_u64)
2129 .and_then(|n| usize::try_from(n).ok())
2130 .ok_or("`plan.preview.identity[].ops[].op_index` must be an integer")?;
2131 Ok(IdentityOp {
2132 op_index,
2133 disposition,
2134 blocks: o
2135 .get("blocks")
2136 .and_then(Value::as_array)
2137 .map(|a| {
2138 a.iter()
2139 .filter_map(Value::as_str)
2140 .map(str::to_owned)
2141 .collect()
2142 })
2143 .unwrap_or_default(),
2144 confidence: o.get("confidence").and_then(Value::as_f64),
2145 reason: o.get("reason").and_then(Value::as_str).map(str::to_owned),
2146 detail: o.get("detail").cloned(),
2147 })
2148 })
2149 .collect::<std::result::Result<Vec<_>, String>>()?;
2150 let diagnostics = obj
2151 .get("diagnostics")
2152 .and_then(Value::as_array)
2153 .map(|a| {
2154 a.iter()
2155 .filter_map(Value::as_str)
2156 .map(str::to_owned)
2157 .collect()
2158 })
2159 .unwrap_or_default();
2160 Ok(Self {
2161 matcher_v,
2162 summary,
2163 ops,
2164 diagnostics,
2165 })
2166 }
2167}
2168
2169#[derive(Clone, Debug, PartialEq, Eq)]
2172pub struct RetargetHitPreview {
2173 pub id: String,
2174 pub path: String,
2175 pub old_raw: String,
2176 pub new_raw: String,
2177}
2178
2179#[derive(Clone, Debug, PartialEq, Eq)]
2181pub struct RetargetPairPreview {
2182 pub from: String,
2183 pub to: String,
2184 pub hits: usize,
2185}
2186
2187#[derive(Clone, Debug, PartialEq, Eq)]
2191pub struct RetargetPreview {
2192 pub effect_index: usize,
2193 pub hits: Vec<RetargetHitPreview>,
2194 pub pairs: Vec<RetargetPairPreview>,
2195}
2196
2197impl RetargetPreview {
2198 #[must_use]
2199 pub fn to_json(&self) -> Value {
2200 json!({
2201 "effect_index": self.effect_index,
2202 "hits": self.hits.iter().map(|h| json!({ "id": h.id, "path": h.path, "oldRaw": h.old_raw, "newRaw": h.new_raw })).collect::<Vec<_>>(),
2203 "pairs": self.pairs.iter().map(|p| json!({ "from": p.from, "to": p.to, "hits": p.hits })).collect::<Vec<_>>(),
2204 })
2205 }
2206
2207 pub fn from_json(v: &Value) -> std::result::Result<Self, String> {
2208 let obj = v
2209 .as_object()
2210 .ok_or("`plan.preview.retarget[]` must be an object")?;
2211 let s = |v: &Value, k: &str| -> std::result::Result<String, String> {
2212 v.get(k)
2213 .and_then(Value::as_str)
2214 .map(str::to_owned)
2215 .ok_or_else(|| format!("`plan.preview.retarget[]` {k} must be a string"))
2216 };
2217 let n = |v: &Value, k: &str| -> std::result::Result<usize, String> {
2218 v.get(k)
2219 .and_then(Value::as_u64)
2220 .and_then(|n| usize::try_from(n).ok())
2221 .ok_or_else(|| format!("`plan.preview.retarget[]` {k} must be an integer"))
2222 };
2223 Ok(Self {
2224 effect_index: n(v, "effect_index")?,
2225 hits: obj
2226 .get("hits")
2227 .and_then(Value::as_array)
2228 .ok_or("`plan.preview.retarget[].hits` must be an array")?
2229 .iter()
2230 .map(|h| {
2231 Ok(RetargetHitPreview {
2232 id: s(h, "id")?,
2233 path: s(h, "path")?,
2234 old_raw: s(h, "oldRaw")?,
2235 new_raw: s(h, "newRaw")?,
2236 })
2237 })
2238 .collect::<std::result::Result<_, String>>()?,
2239 pairs: obj
2240 .get("pairs")
2241 .and_then(Value::as_array)
2242 .ok_or("`plan.preview.retarget[].pairs` must be an array")?
2243 .iter()
2244 .map(|p| {
2245 Ok(RetargetPairPreview {
2246 from: s(p, "from")?,
2247 to: s(p, "to")?,
2248 hits: n(p, "hits")?,
2249 })
2250 })
2251 .collect::<std::result::Result<_, String>>()?,
2252 })
2253 }
2254}
2255
2256#[derive(Clone, Debug, Default, PartialEq)]
2260pub struct PlanPreview {
2261 pub results: Vec<OpResult>,
2262 pub revisions: Vec<PlanRevision>,
2263 pub diffs: Vec<(String, (String, String))>,
2265 pub identity: Vec<(String, IdentityPreview)>,
2267 pub retarget: Vec<RetargetPreview>,
2268 pub summary: Vec<String>,
2271}
2272
2273impl PlanPreview {
2274 #[must_use]
2275 pub fn to_json(&self) -> Value {
2276 let mut diffs = Map::new();
2277 for (path, (before, after)) in &self.diffs {
2278 diffs.insert(path.clone(), json!({ "before": before, "after": after }));
2279 }
2280 let mut m = Map::new();
2281 m.insert(
2282 "results".to_owned(),
2283 Value::Array(self.results.iter().map(OpResult::to_json).collect()),
2284 );
2285 m.insert(
2286 "revisions".to_owned(),
2287 Value::Array(
2288 self.revisions
2289 .iter()
2290 .map(|r| json!({ "id": r.id, "path": r.path, "hash": r.hash }))
2291 .collect(),
2292 ),
2293 );
2294 m.insert("diffs".to_owned(), Value::Object(diffs));
2295 m.insert("summary".to_owned(), json!(self.summary));
2296 if !self.identity.is_empty() {
2297 let mut identity = Map::new();
2298 for (doc, i) in &self.identity {
2299 identity.insert(doc.clone(), i.to_json());
2300 }
2301 m.insert("identity".to_owned(), Value::Object(identity));
2302 }
2303 if !self.retarget.is_empty() {
2304 m.insert(
2305 "retarget".to_owned(),
2306 Value::Array(self.retarget.iter().map(RetargetPreview::to_json).collect()),
2307 );
2308 }
2309 Value::Object(m)
2310 }
2311
2312 pub fn from_json(v: &Value) -> std::result::Result<Self, String> {
2313 let obj = v.as_object().ok_or("`plan.preview` must be an object")?;
2314 let identity = obj
2315 .get("identity")
2316 .and_then(Value::as_object)
2317 .map(|m| {
2318 m.iter()
2319 .map(|(doc, i)| Ok((doc.clone(), IdentityPreview::from_json(i)?)))
2320 .collect::<std::result::Result<Vec<_>, String>>()
2321 })
2322 .transpose()?
2323 .unwrap_or_default();
2324 let retarget = obj
2325 .get("retarget")
2326 .and_then(Value::as_array)
2327 .map(|a| {
2328 a.iter()
2329 .map(RetargetPreview::from_json)
2330 .collect::<std::result::Result<Vec<_>, String>>()
2331 })
2332 .transpose()?
2333 .unwrap_or_default();
2334 let strings = |v: Option<&Value>| -> Option<Vec<String>> {
2335 v.and_then(Value::as_array).map(|l| {
2336 l.iter()
2337 .filter_map(Value::as_str)
2338 .map(str::to_owned)
2339 .collect()
2340 })
2341 };
2342 let results = obj
2343 .get("results")
2344 .and_then(Value::as_array)
2345 .map(|a| {
2346 a.iter()
2347 .map(|r| OpResult {
2348 ids: strings(r.get("ids")).unwrap_or_default(),
2349 removed: strings(r.get("removed")),
2350 merged_into: strings(r.get("merged_into")),
2351 })
2352 .collect()
2353 })
2354 .unwrap_or_default();
2355 let str_of = |v: &Value, k: &str| -> std::result::Result<String, String> {
2356 v.get(k)
2357 .and_then(Value::as_str)
2358 .map(str::to_owned)
2359 .ok_or_else(|| format!("`plan.preview` {k} must be a string"))
2360 };
2361 let revisions = obj
2362 .get("revisions")
2363 .and_then(Value::as_array)
2364 .map(|a| {
2365 a.iter()
2366 .map(|r| {
2367 Ok(PlanRevision {
2368 id: str_of(r, "id")?,
2369 path: str_of(r, "path")?,
2370 hash: str_of(r, "hash")?,
2371 })
2372 })
2373 .collect::<std::result::Result<Vec<_>, String>>()
2374 })
2375 .transpose()?
2376 .unwrap_or_default();
2377 let diffs = obj
2378 .get("diffs")
2379 .and_then(Value::as_object)
2380 .map(|d| {
2381 d.iter()
2382 .map(|(p, v)| Ok((p.clone(), (str_of(v, "before")?, str_of(v, "after")?))))
2383 .collect::<std::result::Result<Vec<_>, String>>()
2384 })
2385 .transpose()?
2386 .unwrap_or_default();
2387 let summary = strings(obj.get("summary")).unwrap_or_default();
2388 Ok(Self {
2389 results,
2390 revisions,
2391 diffs,
2392 identity,
2393 retarget,
2394 summary,
2395 })
2396 }
2397}
2398
2399#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2401pub struct SelectionExpect {
2402 pub max: usize,
2403}
2404
2405#[derive(Clone, Debug, PartialEq)]
2412pub struct Selection {
2413 pub query: String,
2414 pub expect: Option<SelectionExpect>,
2415 pub templates: Vec<Value>,
2416 pub cardinality: usize,
2417 pub identities: Vec<String>,
2418}
2419
2420impl Selection {
2421 #[must_use]
2424 pub fn to_json(&self) -> Value {
2425 let mut m = Map::new();
2426 m.insert("query".to_owned(), json!(self.query));
2427 if let Some(e) = &self.expect {
2428 m.insert("expect".to_owned(), json!({ "max": e.max }));
2429 }
2430 m.insert("templates".to_owned(), Value::Array(self.templates.clone()));
2431 m.insert("cardinality".to_owned(), json!(self.cardinality));
2432 m.insert("identities".to_owned(), json!(self.identities));
2433 Value::Object(m)
2434 }
2435
2436 pub fn from_json(v: &Value) -> std::result::Result<Self, String> {
2437 let obj = v.as_object().ok_or("`plan.selection` must be an object")?;
2438 only_keys(
2439 obj,
2440 &["query", "expect", "templates", "cardinality", "identities"],
2441 "`plan.selection`",
2442 )?;
2443 let query = obj
2444 .get("query")
2445 .and_then(Value::as_str)
2446 .ok_or("`plan.selection.query` must be a string")?
2447 .to_owned();
2448 let expect = match obj.get("expect") {
2449 None => None,
2450 Some(e) => {
2451 let e = e
2452 .as_object()
2453 .ok_or("`plan.selection.expect` must be { max }")?;
2454 only_keys(e, &["max"], "`plan.selection.expect`")?;
2455 let max = e
2456 .get("max")
2457 .and_then(Value::as_u64)
2458 .and_then(|n| usize::try_from(n).ok())
2459 .ok_or("`plan.selection.expect.max` must be a non-negative integer")?;
2460 Some(SelectionExpect { max })
2461 }
2462 };
2463 let templates = obj
2464 .get("templates")
2465 .and_then(Value::as_array)
2466 .filter(|t| !t.is_empty())
2467 .ok_or("`plan.selection.templates` must be a non-empty array")?
2468 .clone();
2469 let cardinality = obj
2470 .get("cardinality")
2471 .and_then(Value::as_u64)
2472 .and_then(|n| usize::try_from(n).ok())
2473 .ok_or("`plan.selection.cardinality` must be a non-negative integer")?;
2474 let identities = obj
2475 .get("identities")
2476 .and_then(Value::as_array)
2477 .ok_or("`plan.selection.identities` must be an array of ids")?
2478 .iter()
2479 .map(|id| {
2480 id.as_str()
2481 .map(str::to_owned)
2482 .ok_or_else(|| "`plan.selection.identities[]` must be strings".to_owned())
2483 })
2484 .collect::<std::result::Result<Vec<_>, String>>()?;
2485 if identities.len() != cardinality {
2486 return Err(
2487 "`plan.selection.cardinality` must equal the number of identities".to_owned(),
2488 );
2489 }
2490 Ok(Self {
2491 query,
2492 expect,
2493 templates,
2494 cardinality,
2495 identities,
2496 })
2497 }
2498}
2499
2500#[derive(Clone, Debug, PartialEq)]
2502pub struct Plan {
2503 pub version: u64,
2504 pub planner_v: String,
2505 pub effects: Vec<Effect>,
2508 pub opset: PlanOpset,
2509 pub preconditions: Preconditions,
2510 pub new_ids: Vec<NewId>,
2511 pub selection: Option<Selection>,
2513 pub templates: Option<Vec<Value>>,
2519 pub plan_id: String,
2520 pub diagnostics: Vec<String>,
2521 pub preview: Option<PlanPreview>,
2522}
2523
2524#[derive(Clone, Debug, PartialEq)]
2526pub enum Admission {
2527 Refused(MutationError),
2529 Invalid(String),
2531}
2532
2533impl Plan {
2534 #[must_use]
2538 pub fn core_json(&self) -> Value {
2539 let mut core = json!({
2540 "version": self.version,
2541 "planner_v": self.planner_v,
2542 "effects": self.effects.iter().map(Effect::to_json).collect::<Vec<_>>(),
2543 "opset": self.opset.to_json(),
2544 "preconditions": self.preconditions.to_json(),
2545 "new_ids": self.new_ids.iter().map(|n| json!({ "symbol": n.symbol, "kind": n.kind.as_str() })).collect::<Vec<_>>(),
2546 });
2547 if let Some(sel) = &self.selection {
2548 core["selection"] = sel.to_json();
2549 }
2550 core
2551 }
2552
2553 #[must_use]
2555 pub fn digest(&self) -> String {
2556 digest(&self.core_json())
2557 }
2558
2559 #[must_use]
2561 pub fn sealed(mut self) -> Self {
2562 self.plan_id = self.digest();
2563 self
2564 }
2565
2566 #[must_use]
2568 pub fn to_json(&self, with_preview: bool) -> Value {
2569 let Value::Object(mut m) = self.core_json() else {
2570 unreachable!("core_json is an object")
2571 };
2572 if let Some(t) = &self.templates {
2573 m.insert("templates".to_owned(), Value::Array(t.clone()));
2574 }
2575 m.insert("plan_id".to_owned(), json!(self.plan_id));
2576 m.insert("diagnostics".to_owned(), json!(self.diagnostics));
2577 if with_preview {
2578 if let Some(p) = &self.preview {
2579 m.insert("preview".to_owned(), p.to_json());
2580 }
2581 }
2582 Value::Object(m)
2583 }
2584
2585 pub fn from_json(v: &Value) -> std::result::Result<Plan, String> {
2588 let obj = v.as_object().ok_or("`plan` must be an object")?;
2589 for k in [
2590 "planner_v",
2591 "effects",
2592 "opset",
2593 "preconditions",
2594 "new_ids",
2595 "plan_id",
2596 ] {
2597 if !obj.contains_key(k) {
2598 return Err(format!("`plan.{k}` is required"));
2599 }
2600 }
2601 let version = obj
2602 .get("version")
2603 .and_then(Value::as_u64)
2604 .ok_or("`plan.version` must be an integer")?;
2605 let planner_v = obj["planner_v"]
2606 .as_str()
2607 .ok_or("`plan.planner_v` must be a string")?
2608 .to_owned();
2609 let effects = obj["effects"]
2610 .as_array()
2611 .ok_or("`plan.effects` must be an array")?
2612 .iter()
2613 .enumerate()
2614 .map(|(i, e)| Effect::validate(e, i))
2615 .collect::<std::result::Result<Vec<_>, String>>()?;
2616 let opset = PlanOpset::from_json(&obj["opset"])?;
2617 let preconditions = Preconditions::from_json(&obj["preconditions"])?;
2618 let new_ids = obj["new_ids"]
2619 .as_array()
2620 .ok_or("`plan.new_ids` must be an array")?
2621 .iter()
2622 .map(|n| {
2623 let kind = match n.get("kind").and_then(Value::as_str) {
2624 Some("block") => NewIdKind::Block,
2625 Some("document") => NewIdKind::Document,
2626 _ => return Err("`plan.new_ids[].kind` must be block or document".to_owned()),
2627 };
2628 let symbol = n
2629 .get("symbol")
2630 .and_then(Value::as_str)
2631 .ok_or("`plan.new_ids[].symbol` must be a string")?
2632 .to_owned();
2633 Ok(NewId { symbol, kind })
2634 })
2635 .collect::<std::result::Result<Vec<_>, String>>()?;
2636 let plan_id = obj["plan_id"]
2637 .as_str()
2638 .ok_or("`plan.plan_id` must be a string")?
2639 .to_owned();
2640 let diagnostics = obj
2641 .get("diagnostics")
2642 .and_then(Value::as_array)
2643 .map(|a| {
2644 a.iter()
2645 .filter_map(Value::as_str)
2646 .map(str::to_owned)
2647 .collect()
2648 })
2649 .unwrap_or_default();
2650 let preview = match obj.get("preview") {
2651 None | Some(Value::Null) => None,
2652 Some(p) => Some(PlanPreview::from_json(p)?),
2653 };
2654 let selection = match obj.get("selection") {
2658 None => None,
2659 Some(s) => Some(Selection::from_json(s)?),
2660 };
2661 let templates = match obj.get("templates") {
2664 None | Some(Value::Null) => None,
2665 Some(t) => Some(
2666 t.as_array()
2667 .ok_or("`plan.templates` must be an array of effects as written")?
2668 .clone(),
2669 ),
2670 };
2671 Ok(Plan {
2672 version,
2673 planner_v,
2674 effects,
2675 opset,
2676 preconditions,
2677 new_ids,
2678 selection,
2679 templates,
2680 plan_id,
2681 diagnostics,
2682 preview,
2683 })
2684 }
2685
2686 pub fn admit(v: &Value) -> std::result::Result<Plan, Admission> {
2691 let obj = v
2692 .as_object()
2693 .ok_or_else(|| Admission::Invalid("`plan` must be an object".into()))?;
2694 let version = obj.get("version").cloned().unwrap_or(Value::Null);
2695 if version != json!(PLAN_VERSION) {
2696 return Err(Admission::Refused(unsupported_plan_version(&version)));
2697 }
2698 let plan = Plan::from_json(v).map_err(Admission::Invalid)?;
2699 let actual = plan.digest();
2700 if actual != plan.plan_id {
2701 return Err(Admission::Refused(plan_mismatch(&plan.plan_id, &actual)));
2702 }
2703 Ok(plan)
2704 }
2705}
2706
2707#[must_use]
2709pub fn unsupported_plan_version(version: &Value) -> MutationError {
2710 MutationError::with_data(
2711 ErrorCode::UnsupportedPlanVersion,
2712 format!("plan version {version} is not supported (supported: {PLAN_VERSION})"),
2713 json!({ "version": version, "supported": [PLAN_VERSION] }),
2714 )
2715}
2716
2717#[must_use]
2720pub fn plan_mismatch(expected: &str, actual: &str) -> MutationError {
2721 MutationError::with_data(
2722 ErrorCode::PlanMismatch,
2723 "the plan's digest does not match its plan_id; it was edited or produced by a different planner — re-plan",
2724 json!({ "expected": expected, "actual": actual }),
2725 )
2726}
2727
2728#[must_use]
2732pub fn plan_mismatch_replanned(expected: &str, actual: &str) -> MutationError {
2733 MutationError::with_data(
2734 ErrorCode::PlanMismatch,
2735 "the effects or the documents they target changed since the plan was made; re-plan",
2736 json!({ "expected": expected, "actual": actual }),
2737 )
2738}
2739
2740#[must_use]
2746pub fn intent_record(
2747 plan_id: &str,
2748 effects: &[Effect],
2749 selection: Option<&Selection>,
2750 templates: Option<&[Value]>,
2751) -> String {
2752 let mut record = json!({
2753 "v": 1,
2754 "plan_id": plan_id,
2755 "effects": effects.iter().map(Effect::to_json).collect::<Vec<_>>(),
2756 });
2757 if let Some(sel) = selection {
2758 record["selection"] = sel.to_json();
2759 }
2760 if let Some(t) = templates {
2761 record["templates"] = Value::Array(t.to_vec());
2762 }
2763 canonical_json(&record)
2764}
2765
2766#[cfg(test)]
2767mod tests {
2768 use super::*;
2769
2770 fn doc(id: &str, path: &str) -> Target {
2771 Target::Document {
2772 id: Some(id.into()),
2773 path: Some(path.into()),
2774 }
2775 }
2776
2777 fn fm_set(key: &str, value: Value) -> Effect {
2778 Effect::Set {
2779 target: doc("d_0", "a.md"),
2780 field: format!("frontmatter.{key}"),
2781 value,
2782 expect: None,
2783 }
2784 }
2785
2786 fn scalar(key: &str, target: FocusTarget, field: &str, value: Option<Value>) -> FocusInfo {
2787 FocusInfo {
2788 scalar: Some(ScalarWrite {
2789 key: key.into(),
2790 target,
2791 field: field.into(),
2792 value,
2793 }),
2794 docs: vec!["d_0".into()],
2795 ..Default::default()
2796 }
2797 }
2798
2799 #[test]
2800 fn symbols_parse() {
2801 assert_eq!(parse_new_symbol("$new.0"), Some(0));
2802 assert_eq!(parse_new_symbol("$new.12"), Some(12));
2803 assert_eq!(parse_new_symbol("$new."), None);
2804 assert_eq!(parse_new_symbol("$new.a"), None);
2805 assert_eq!(parse_new_symbol("b_0"), None);
2806 assert_eq!(new_symbol(3), "$new.3");
2807 }
2808
2809 #[test]
2810 fn effects_validate_and_round_trip_through_json() {
2811 let effects = json!([
2812 { "op": "create", "path": "n.md", "markdown": "# N", "frontmatter": { "k": 1 } },
2813 { "op": "insert", "into": { "kind": "section", "heading_id": "b_1" }, "at": "end", "markdown": "x" },
2814 { "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" } },
2815 { "op": "set", "target": { "kind": "document", "path": "a.md" }, "field": "frontmatter.status", "value": "done" },
2816 { "op": "set", "target": { "kind": "block", "id": "b_1" }, "field": "attrs.checked", "value": true, "expect": { "content_hash": "ab" } },
2817 { "op": "set", "target": { "kind": "node", "id": "n_1" }, "field": "value", "value": 3 },
2818 { "op": "unset", "target": { "kind": "document", "id": "d_0" }, "field": "frontmatter.old" },
2819 { "op": "move", "target": { "kind": "document", "id": "d_0" }, "to": "b.md", "retarget_inbound": true },
2820 { "op": "move", "targets": [{ "kind": "block", "id": "b_1" }], "into": { "kind": "block", "id": "b_2" }, "at": "start" },
2821 { "op": "remove", "target": { "kind": "document", "id": "d_0" } },
2822 { "op": "remove", "targets": [{ "kind": "block", "id": "b_1" }], "expect": { "b_1": { "content_hash": "ab" } } },
2823 { "op": "split", "target": { "kind": "block", "id": "b_1" }, "at": [3] },
2824 { "op": "merge", "targets": [{ "kind": "block", "id": "b_1" }, { "kind": "block", "id": "b_2" }], "separator": " " },
2825 { "op": "retarget", "repairs": [{ "from": "a.md", "to": "b.md" }], "path_glob": "notes/*" },
2826 ]);
2827 let parsed = Effect::list_from_json(&effects).unwrap();
2828 assert_eq!(parsed.len(), 14);
2829 for (v, e) in effects.as_array().unwrap().iter().zip(&parsed) {
2830 assert_eq!(e.to_json(), *v, "{}", e.name());
2831 }
2832 let e = Effect::from_json(&json!({ "op": "move", "target": { "kind": "document", "path": "a.md" }, "to": "b.md" })).unwrap();
2834 assert_eq!(e.to_json()["retarget_inbound"], json!(true));
2835 let e = Effect::from_json(
2836 &json!({ "op": "insert", "into": { "kind": "block", "id": "b_1" }, "markdown": "x" }),
2837 )
2838 .unwrap();
2839 assert_eq!(e.to_json()["at"], json!("end"));
2840 let bad = |v: Value| Effect::validate(&v, 2).unwrap_err();
2842 assert!(bad(json!({ "op": "nope" })).starts_with("effects[2]: `op` must be one of"));
2843 assert_eq!(
2844 bad(
2845 json!({ "op": "set", "target": { "kind": "block", "id": "b" }, "field": "markdown" })
2846 ),
2847 "effects[2]: `value` is required"
2848 );
2849 assert!(
2850 bad(json!({ "op": "move", "target": { "kind": "block", "id": "b" }, "to": "x" }))
2851 .contains("`kind` must be one of document")
2852 );
2853 assert!(
2854 bad(json!({ "op": "create", "path": "x.md", "markdown": "", "extra": 1 }))
2855 .contains("unknown key `extra`")
2856 );
2857 assert!(
2858 bad(json!({ "op": "remove", "targets": ["b_1"] }))
2859 .contains("effects[2].targets[0]: a target is")
2860 );
2861 assert!(bad(json!({ "op": "remove", "targets": [] })).contains("non-empty"));
2862 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`"));
2863 assert!(bad(json!({ "op": "unset", "target": { "kind": "document", "path": "a.md" }, "field": "content" })).contains("(got \"content\")"));
2864 assert!(bad(json!({ "op": "set", "target": { "kind": "node", "id": "n" }, "field": "a.b", "value": 1 })).contains("editable properties"));
2865 assert!(bad(json!({ "op": "insert", "into": { "kind": "document", "path": "a.md" }, "at": { "before": "b", "after": "c" }, "markdown": "x" })).contains("`at` must be"));
2866 assert!(Effect::list_from_json(&json!([])).is_err());
2867 assert!(Effect::list_from_json(&json!({})).is_err());
2868 }
2869
2870 #[test]
2871 fn describe_and_classify() {
2872 assert_eq!(
2873 fm_set("status", json!(1)).describe(),
2874 "set frontmatter.status on document d_0"
2875 );
2876 let mv = Effect::MoveBlocks {
2877 targets: vec!["b_1".into(), "b_2".into()],
2878 into: Target::Section {
2879 heading_id: "b_0".into(),
2880 },
2881 at: At::End,
2882 expect: None,
2883 };
2884 assert_eq!(mv.describe(), "move block b_1,block b_2");
2885 let rt = Effect::Retarget {
2886 repairs: vec![Repair {
2887 from: "a".into(),
2888 to: "b".into(),
2889 }],
2890 path_glob: None,
2891 };
2892 assert_eq!(rt.describe(), "retarget a->b");
2893 assert_eq!(
2894 classify_field(&doc("d_0", "a.md"), "frontmatter.status"),
2895 Ok(Field::Frontmatter("status"))
2896 );
2897 assert_eq!(
2898 classify_field(&doc("d_0", "a.md"), "content"),
2899 Ok(Field::Content)
2900 );
2901 assert_eq!(
2902 classify_field(&Target::Block { id: "b".into() }, "attrs.checked"),
2903 Ok(Field::Checked)
2904 );
2905 assert_eq!(
2906 classify_field(&Target::Node { id: "n".into() }, "value"),
2907 Ok(Field::NodeProp("value"))
2908 );
2909 assert!(
2910 classify_field(
2911 &Target::Section {
2912 heading_id: "h".into()
2913 },
2914 "x"
2915 )
2916 .is_err()
2917 );
2918 }
2919
2920 #[test]
2921 fn placeholders_shift() {
2922 let op = Op::Move {
2923 blocks: vec!["$0.ids[1]".into(), "b_3".into()],
2924 to: To {
2925 parent: Parent::Block("$1.ids[0]".into()),
2926 at: At::After("$2.ids[0]".into()),
2927 },
2928 doc: None,
2929 expect: None,
2930 };
2931 assert_eq!(
2932 shift_placeholders(&op, 5).to_json(),
2933 json!({ "op": "move", "blocks": ["$5.ids[1]", "b_3"], "to": { "parent": "$6.ids[0]", "at": { "after": "$7.ids[0]" } } })
2934 );
2935 }
2936
2937 #[test]
2938 fn the_tracker_dedupes_identical_scalars_and_refuses_the_rest() {
2939 let mut t = ConflictTracker::default();
2940 let write = |v: Option<Value>| {
2941 scalar(
2942 "document:d_0:frontmatter.status",
2943 FocusTarget::document("d_0"),
2944 "frontmatter.status",
2945 v,
2946 )
2947 };
2948 assert!(
2949 t.check(
2950 0,
2951 &write(Some(json!("done"))),
2952 "set frontmatter.status on document d_0"
2953 )
2954 .unwrap()
2955 );
2956 assert!(
2957 !t.check(
2958 1,
2959 &write(Some(json!("done"))),
2960 "set frontmatter.status on document d_0"
2961 )
2962 .unwrap()
2963 );
2964 assert_eq!(
2965 t.diagnostics,
2966 ["effect 1 (set frontmatter.status on document d_0) duplicates effect 0; dropped"]
2967 );
2968 let e = t.check(2, &write(Some(json!("open"))), "x").unwrap_err();
2969 assert_eq!(e.code, ErrorCode::ConflictingEffects);
2970 assert_eq!(
2971 e.to_json(),
2972 json!({ "code": "conflicting_effects", "effects": [0, 2], "focus": { "kind": "scalar", "target": { "kind": "document", "id": "d_0" }, "field": "frontmatter.status" }, "retriable": false })
2973 );
2974 let e = t.check(3, &write(None), "x").unwrap_err();
2976 assert_eq!(e.data["effects"], json!([0, 3]));
2977 let mut t = ConflictTracker::default();
2979 let hit = FocusInfo {
2980 scalars: vec![ScalarWrite {
2981 key: "block:b_1:markdown".into(),
2982 target: FocusTarget::block("b_1"),
2983 field: "markdown".into(),
2984 value: None,
2985 }],
2986 blocks: vec!["b_1".into()],
2987 ..Default::default()
2988 };
2989 assert!(t.check(0, &hit, "retarget").unwrap());
2990 let e = t.check(1, &hit, "retarget").unwrap_err();
2991 assert_eq!(
2992 e.data["focus"]["target"],
2993 json!({ "kind": "block", "id": "b_1" })
2994 );
2995 }
2996
2997 #[test]
2998 fn the_tracker_enforces_identity_retirements() {
2999 let mut t = ConflictTracker::default();
3000 let remove = FocusInfo {
3001 docs: vec!["d_0".into()],
3002 blocks: vec!["b_1".into()],
3003 retires_blocks: vec!["b_1".into(), "b_2".into()],
3004 ..Default::default()
3005 };
3006 assert!(t.check(0, &remove, "remove block b_1").unwrap());
3007 let touch_child = FocusInfo {
3008 docs: vec!["d_0".into()],
3009 blocks: vec!["b_2".into()],
3010 ..Default::default()
3011 };
3012 let e = t.check(1, &touch_child, "x").unwrap_err();
3013 assert_eq!(
3014 e.data["focus"],
3015 json!({ "kind": "identity", "target": { "kind": "block", "id": "b_2" } })
3016 );
3017 let mut t = ConflictTracker::default();
3019 let mv = FocusInfo {
3020 docs: vec!["d_0".into()],
3021 paths: vec!["moved/a.md".into()],
3022 retires_paths: vec!["a.md".into()],
3023 ..Default::default()
3024 };
3025 assert!(t.check(0, &mv, "move").unwrap());
3026 let create = FocusInfo {
3027 paths: vec!["a.md".into()],
3028 ..Default::default()
3029 };
3030 let e = t.check(1, &create, "create a.md").unwrap_err();
3031 assert_eq!(
3032 e.data["focus"],
3033 json!({ "kind": "identity", "target": { "kind": "path", "path": "a.md" } })
3034 );
3035 let mut t = ConflictTracker::default();
3037 let insert = FocusInfo {
3038 docs: vec!["d_0".into()],
3039 blocks: vec!["b_2".into()],
3040 ..Default::default()
3041 };
3042 assert!(t.check(0, &insert, "insert").unwrap());
3043 let content = FocusInfo {
3044 scalar: Some(ScalarWrite {
3045 key: "document:d_0:content".into(),
3046 target: FocusTarget::document("d_0"),
3047 field: "content".into(),
3048 value: Some(json!("x")),
3049 }),
3050 docs: vec!["d_0".into()],
3051 whole_doc: Some("d_0".into()),
3052 ..Default::default()
3053 };
3054 let e = t
3055 .check(1, &content, "set content on document d_0")
3056 .unwrap_err();
3057 assert_eq!(
3058 e.data["focus"],
3059 json!({ "kind": "identity", "target": { "kind": "document", "id": "d_0" } })
3060 );
3061 let mut t = ConflictTracker::default();
3063 let rm = FocusInfo {
3064 docs: vec!["d_0".into()],
3065 retires_docs: vec!["d_0".into()],
3066 ..Default::default()
3067 };
3068 assert!(t.check(0, &rm, "remove document d_0").unwrap());
3069 let e = t.check(1, &insert, "insert").unwrap_err();
3070 assert_eq!(
3071 e.data["focus"]["target"],
3072 json!({ "kind": "document", "id": "d_0" })
3073 );
3074 let m = symbol_missing("$new.5", None, "block");
3075 assert_eq!(
3076 m.to_json(),
3077 json!({ "code": "target_missing", "symbol": "$new.5" })
3078 );
3079 let m = symbol_missing("$new.0", Some(NewIdKind::Document), "block");
3080 assert!(m.message.contains("names a document, not a block"));
3081 }
3082
3083 #[test]
3084 fn canonical_json_sorts_keys_and_prints_numbers_like_javascript() {
3085 let v = json!({ "b": [1, 2.0, 2.5, -0.0, 1e21, 1e-7, 123456789012345680000.0, 0.000001, 1.5e300],
3086 "a": { "z": null, "y": "q\"\n\u{1}é" }, "c": true });
3087 assert_eq!(
3088 canonical_json(&v),
3089 "{\"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}"
3090 );
3091 assert_eq!(js_number(9007199254740993.0), "9007199254740992");
3092 assert_eq!(js_number(0.1), "0.1");
3093 assert_eq!(js_number(0.30000000000000004), "0.30000000000000004");
3094 assert_eq!(js_number(-1.5), "-1.5");
3095 assert_eq!(js_number(1e-6), "0.000001");
3096 assert_eq!(js_number(1.2e-7), "1.2e-7");
3097 assert_eq!(js_number(f64::NAN), "null");
3098 assert_eq!(js_number(100.0), "100");
3099 assert_eq!(js_number(1e20), "100000000000000000000");
3100 assert_eq!(js_number(1.7976931348623157e308), "1.7976931348623157e+308");
3101 assert_eq!(js_number(5e-324), "5e-324");
3102 let empty = json!({ "version": 1, "planner_v": "p1", "effects": [], "opset": { "creates": [], "moves": [], "tombstones": [], "set_frontmatter": [], "ops": [] }, "preconditions": { "docs": [], "blocks": [], "parents": [], "absent_paths": [] }, "new_ids": [] });
3105 assert_eq!(
3106 digest(&empty),
3107 "a20097a9c319b59a1313c83e1fd494bbb3cf4895040fbbddd2866e56b16cddd3"
3108 );
3109 let mut current = empty.clone();
3110 current["planner_v"] = json!(PLANNER_V);
3111 assert_eq!(
3112 digest(¤t),
3113 "5051e40051314fa855721d1943b42a9acb867c79a7b49eb4db0fd811559289ff"
3114 );
3115 current["planner_v"] = json!("p2");
3118 assert_eq!(
3119 digest(¤t),
3120 "6f43b302fdcd58ca4fd182b21a5bdab6fee3787ddc074f5e7c5c23b2cc368bf9"
3121 );
3122 let numbers: Value = serde_json::from_str(
3124 "[1, 1.5, 0.30000000000000004, 1e+21, 1e-07, 9007199254740992, 1.7976931348623157e+308, -2.5, 100, 123456789012345680000]",
3125 )
3126 .unwrap();
3127 assert_eq!(
3128 canonical_json(&numbers),
3129 "[1,1.5,0.30000000000000004,1e+21,1e-7,9007199254740992,1.7976931348623157e+308,-2.5,100,123456789012345680000]"
3130 );
3131 assert_eq!(
3133 canonical_json(&json!({ "é": "e", "z": 0 })),
3134 "{\"z\":0,\"é\":\"e\"}"
3135 );
3136 assert_eq!(canonical_json(&json!("a/b\u{1f}é😀")), "\"a/b\\u001fé😀\"");
3137 }
3138
3139 #[test]
3145 fn templates_ride_outside_the_digest() {
3146 let bare = Plan {
3147 version: PLAN_VERSION,
3148 planner_v: PLANNER_V.into(),
3149 effects: vec![fm_set("status", json!("done"))],
3150 opset: PlanOpset::default(),
3151 preconditions: Preconditions::default(),
3152 new_ids: vec![],
3153 selection: None,
3154 templates: None,
3155 plan_id: String::new(),
3156 diagnostics: vec![],
3157 preview: None,
3158 }
3159 .sealed();
3160 let written = vec![
3161 json!({ "op": "set", "target": "a.md", "field": "frontmatter.status", "value": "-> `done`" }),
3162 ];
3163 let sugared = Plan {
3164 templates: Some(written.clone()),
3165 ..bare.clone()
3166 }
3167 .sealed();
3168 assert_eq!(sugared.plan_id, bare.plan_id);
3169 assert!(sugared.core_json().get("templates").is_none());
3170 assert!(bare.to_json(false).get("templates").is_none());
3171 let wire = sugared.to_json(false);
3172 assert_eq!(wire["templates"], Value::Array(written.clone()));
3173 assert_eq!(Plan::admit(&wire).unwrap(), sugared);
3174 let mut nulled = wire.clone();
3175 nulled["templates"] = Value::Null;
3176 assert_eq!(Plan::admit(&nulled).unwrap(), bare);
3177 let mut mistyped = wire;
3178 mistyped["templates"] = json!("x");
3179 assert!(matches!(
3180 Plan::admit(&mistyped),
3181 Err(Admission::Invalid(m)) if m.contains("`plan.templates` must be an array")
3182 ));
3183 let plain = intent_record(&bare.plan_id, &bare.effects, None, None);
3184 assert!(!plain.contains("templates"), "{plain}");
3185 let record = intent_record(
3186 &sugared.plan_id,
3187 &sugared.effects,
3188 None,
3189 sugared.templates.as_deref(),
3190 );
3191 assert!(
3192 record.ends_with(
3193 "\"templates\":[{\"field\":\"frontmatter.status\",\"op\":\"set\",\"target\":\"a.md\",\"value\":\"-> `done`\"}],\"v\":1}"
3194 ),
3195 "{record}"
3196 );
3197 }
3198
3199 #[test]
3200 fn a_plan_digests_its_core_and_round_trips() {
3201 let plan = Plan {
3202 version: PLAN_VERSION,
3203 planner_v: PLANNER_V.into(),
3204 effects: vec![fm_set("status", json!("done"))],
3205 templates: None,
3206 opset: PlanOpset {
3207 set_frontmatter: vec![PlanFrontmatter {
3208 doc: "d_0".into(),
3209 raw: "---\nstatus: done\n---\n\n".into(),
3210 }],
3211 ..Default::default()
3212 },
3213 preconditions: Preconditions {
3214 docs: vec![DocPrecondition {
3215 id: "d_0".into(),
3216 path: "a.md".into(),
3217 base_revision: Some("r_0".into()),
3218 hash: None,
3219 }],
3220 ..Default::default()
3221 },
3222 new_ids: vec![NewId {
3223 symbol: "$new.0".into(),
3224 kind: NewIdKind::Block,
3225 }],
3226 selection: None,
3227 plan_id: String::new(),
3228 diagnostics: vec![],
3229 preview: Some(PlanPreview::default()),
3230 }
3231 .sealed();
3232 assert_eq!(plan.plan_id.len(), 64);
3233 assert_eq!(plan.plan_id, digest(&plan.core_json()));
3234 let wire = plan.to_json(true);
3235 assert!(wire.get("preview").is_some());
3236 assert_eq!(
3237 wire["preconditions"]["docs"][0]["hash"],
3238 Value::Null,
3239 "a null value stays"
3240 );
3241 assert_eq!(wire["opset"]["creates"], json!([]));
3242 let back = Plan::from_json(&wire).unwrap();
3243 assert_eq!(back, plan);
3244 let trimmed = plan.to_json(false);
3245 assert!(trimmed.get("preview").is_none());
3246 assert_eq!(Plan::admit(&trimmed).unwrap().digest(), plan.plan_id);
3247 let mut v2 = trimmed.clone();
3249 v2["version"] = json!(2);
3250 v2["opset"] = json!({});
3251 match Plan::admit(&v2) {
3252 Err(Admission::Refused(m)) => {
3253 assert_eq!(m.code, ErrorCode::UnsupportedPlanVersion);
3254 assert_eq!(m.data["supported"], json!([1]));
3255 assert_eq!(m.data["version"], json!(2));
3256 }
3257 other => panic!("{other:?}"),
3258 }
3259 let mut bare = trimmed.clone();
3260 bare["opset"] = json!({});
3261 assert!(
3262 matches!(Plan::admit(&bare), Err(Admission::Invalid(m)) if m.contains("opset.creates"))
3263 );
3264 let mut tampered = trimmed.clone();
3265 tampered["effects"] = json!([]);
3266 match Plan::admit(&tampered) {
3267 Err(Admission::Refused(m)) => {
3268 assert_eq!(m.code, ErrorCode::PlanMismatch);
3269 assert_eq!(m.data["expected"], json!(plan.plan_id));
3270 assert_ne!(m.data["actual"], json!(plan.plan_id));
3271 }
3272 other => panic!("{other:?}"),
3273 }
3274 let record = intent_record(&plan.plan_id, &plan.effects, None, None);
3275 assert!(
3276 record.starts_with("{\"effects\":[{\"field\":\"frontmatter.status\""),
3277 "{record}"
3278 );
3279 assert!(record.ends_with("\"v\":1}"));
3280 assert!(!record.contains("selection"));
3281 }
3282
3283 #[test]
3288 fn a_selection_is_digested_and_round_trips() {
3289 let selection = Selection {
3290 query: "from docs where status == \"completed\"".into(),
3291 expect: Some(SelectionExpect { max: 10 }),
3292 templates: vec![
3293 json!({ "op": "set", "field": "frontmatter.rolled_up", "value": true }),
3294 ],
3295 cardinality: 2,
3296 identities: vec!["d_1".into(), "d_0".into()],
3297 };
3298 assert_eq!(
3299 canonical_json(&selection.to_json()),
3300 "{\"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}]}"
3301 );
3302 let without = Selection {
3303 expect: None,
3304 ..selection.clone()
3305 };
3306 assert!(!canonical_json(&without.to_json()).contains("expect"));
3307 assert_eq!(
3308 Selection::from_json(&selection.to_json()),
3309 Ok(selection.clone())
3310 );
3311 assert_eq!(Selection::from_json(&without.to_json()), Ok(without));
3312 let bare = Plan {
3313 version: PLAN_VERSION,
3314 planner_v: PLANNER_V.into(),
3315 effects: vec![],
3316 opset: PlanOpset::default(),
3317 preconditions: Preconditions::default(),
3318 new_ids: vec![],
3319 selection: None,
3320 templates: None,
3321 plan_id: String::new(),
3322 diagnostics: vec![],
3323 preview: None,
3324 }
3325 .sealed();
3326 let selected = Plan {
3327 selection: Some(selection.clone()),
3328 ..bare.clone()
3329 }
3330 .sealed();
3331 assert_ne!(bare.plan_id, selected.plan_id);
3332 assert!(bare.core_json().get("selection").is_none());
3333 assert_eq!(selected.core_json()["selection"], selection.to_json());
3334 let zero = Plan {
3337 selection: Some(Selection {
3338 query: "from docs where type == \"nope\"".into(),
3339 expect: None,
3340 templates: vec![json!({ "op": "set", "field": "frontmatter.x", "value": 1 })],
3341 cardinality: 0,
3342 identities: vec![],
3343 }),
3344 ..bare.clone()
3345 }
3346 .sealed();
3347 assert_eq!(
3348 zero.plan_id,
3349 "73ca03407ddf05fb522c4118bd5ef1e88887f8430acbaba7428378a0b1e342c9"
3350 );
3351 let record = intent_record(
3353 &selected.plan_id,
3354 &selected.effects,
3355 selected.selection.as_ref(),
3356 None,
3357 );
3358 assert!(
3359 record.contains("\"selection\":{\"cardinality\":2"),
3360 "{record}"
3361 );
3362 let wire = selected.to_json(false);
3363 assert_eq!(Plan::admit(&wire).unwrap(), selected);
3364 let mut tampered = wire.clone();
3367 tampered["selection"]["identities"] = json!(["d_1", "d_2"]);
3368 assert!(matches!(
3369 Plan::admit(&tampered),
3370 Err(Admission::Refused(m)) if m.code == ErrorCode::PlanMismatch
3371 ));
3372 let mut short = wire.clone();
3373 short["selection"]["identities"] = json!(["d_1"]);
3374 assert!(matches!(
3375 Plan::admit(&short),
3376 Err(Admission::Invalid(m)) if m.contains("cardinality")
3377 ));
3378 let mut nulled = wire.clone();
3379 nulled["selection"] = Value::Null;
3380 assert!(matches!(Plan::admit(&nulled), Err(Admission::Invalid(_))));
3381 let mut empty = wire;
3382 empty["selection"]["templates"] = json!([]);
3383 assert!(matches!(
3384 Plan::admit(&empty),
3385 Err(Admission::Invalid(m)) if m.contains("templates")
3386 ));
3387 }
3388
3389 fn ctx<'a>(
3392 effects: &'a Value,
3393 select: Option<&'a Value>,
3394 expect: Option<&'a Value>,
3395 parent_of: &'a dyn Fn(&str) -> Option<ParentRow>,
3396 ) -> ShapeFixContext<'a> {
3397 ShapeFixContext {
3398 select,
3399 effects,
3400 expect,
3401 parent_of,
3402 }
3403 }
3404
3405 fn rows(id: &str) -> Option<ParentRow> {
3406 match id {
3407 "b_4" => Some(ParentRow {
3408 parent: Some("b_3".to_owned()),
3409 path: "a.md".to_owned(),
3410 }),
3411 "b_1" => Some(ParentRow {
3412 parent: None,
3413 path: "a.md".to_owned(),
3414 }),
3415 _ => None,
3416 }
3417 }
3418
3419 #[test]
3420 fn a_bare_anchor_is_fixed_from_the_blocks_parent() {
3421 let effects = json!([{ "op": "insert", "into": { "kind": "block", "id": "b_4" }, "at": "after", "markdown": "x" }]);
3422 let c = ctx(&effects, None, None, &rows);
3423 let e = Effect::validate_with(&effects[0], 0, Some(&c)).unwrap_err();
3424 assert_eq!(
3425 e.message,
3426 "effects[0]: `at` must be \"start\", \"end\", { before: <block id> } or { after: <block id> } — \"after block X\" is into: X's parent (a block, or the document when X is top-level), at: { after: X }"
3427 );
3428 assert_eq!(
3429 e.fix.as_deref(),
3430 Some(
3431 r#"{"effects":[{"at":{"after":"b_4"},"into":{"id":"b_3","kind":"block"},"markdown":"x","op":"insert"}]}"#
3432 )
3433 );
3434 let effects = json!([{ "op": "move", "targets": [{ "kind": "block", "id": "b_6" }], "into": { "kind": "block", "id": "b_1" }, "at": "before" }]);
3436 let c = ctx(&effects, None, None, &rows);
3437 let e = Effect::validate_with(&effects[0], 0, Some(&c)).unwrap_err();
3438 assert!(e.message.contains("\"before block X\""));
3439 assert_eq!(
3440 e.fix.as_deref(),
3441 Some(
3442 r#"{"effects":[{"at":{"before":"b_1"},"into":"/a.md","op":"move","targets":[{"id":"b_6","kind":"block"}]}]}"#
3443 )
3444 );
3445 let effects = json!([{ "op": "insert", "into": { "kind": "block", "id": "b_99" }, "at": "after", "markdown": "x" }]);
3447 let c = ctx(&effects, None, None, &rows);
3448 assert_eq!(
3449 Effect::validate_with(&effects[0], 0, Some(&c))
3450 .unwrap_err()
3451 .fix,
3452 None
3453 );
3454 let effects = json!([{ "op": "insert", "into": { "kind": "document", "path": "a.md" }, "at": "after", "markdown": "x" }]);
3455 let c = ctx(&effects, None, None, &rows);
3456 assert_eq!(
3457 Effect::validate_with(&effects[0], 0, Some(&c))
3458 .unwrap_err()
3459 .fix,
3460 None
3461 );
3462 assert_eq!(
3463 Effect::validate_with(&effects[0], 0, None).unwrap_err().fix,
3464 None
3465 );
3466 }
3467
3468 #[test]
3469 fn a_guard_first_plural_expect_is_inverted() {
3470 let effects = json!([{ "op": "remove", "targets": [{ "kind": "block", "id": "b_1" }, { "kind": "block", "id": "b_8" }],
3471 "expect": { "content_hash": { "b_1": "h1", "b_8": "h8" }, "parent_children_hash": { "b_1": "p1" } } }]);
3472 let c = ctx(&effects, None, None, &rows);
3473 let e = Effect::validate_with(&effects[0], 0, Some(&c)).unwrap_err();
3474 assert_eq!(
3475 e.message,
3476 "effects[0]: a plural-target `expect` is keyed by block id — { <block id>: { content_hash?, parent_children_hash? }, … }"
3477 );
3478 assert_eq!(
3479 e.fix.as_deref(),
3480 Some(
3481 r#"{"effects":[{"expect":{"b_1":{"content_hash":"h1","parent_children_hash":"p1"},"b_8":{"content_hash":"h8"}},"op":"remove","targets":[{"id":"b_1","kind":"block"},{"id":"b_8","kind":"block"}]}]}"#
3482 )
3483 );
3484 let effects = json!([{ "op": "merge", "targets": [{ "kind": "block", "id": "b_1" }], "expect": { "content_hash": "h1" } }]);
3486 let c = ctx(&effects, None, None, &rows);
3487 let e = Effect::validate_with(&effects[0], 0, Some(&c)).unwrap_err();
3488 assert_eq!(
3489 e.message,
3490 "effects[0].expect[\"content_hash\"]: must be an object"
3491 );
3492 assert_eq!(e.fix, None);
3493 }
3494
3495 #[test]
3496 fn an_effect_level_max_moves_beside_select_or_is_dropped() {
3497 let select = json!("from blocks where type == \"paragraph\"");
3498 let effects =
3499 json!([{ "op": "set", "field": "markdown", "value": "x", "expect": { "max": 5 } }]);
3500 let probe = json!({ "op": "set", "target": { "kind": "block", "id": "b_1" }, "field": "markdown", "value": "x", "expect": { "max": 5 } });
3502 let c = ctx(&effects, Some(&select), None, &rows);
3503 let e = Effect::validate_with(&probe, 0, Some(&c)).unwrap_err();
3504 assert_eq!(
3505 e.message,
3506 "effects[0]: `expect.max` bounds a selection — it is the request's top-level `expect`, beside `select`, not an effect's"
3507 );
3508 assert_eq!(
3509 e.fix.as_deref(),
3510 Some(
3511 r#"{"effects":[{"field":"markdown","op":"set","value":"x"}],"expect":{"max":5},"select":"from blocks where type == \"paragraph\""}"#
3512 )
3513 );
3514 let effects = json!([{ "op": "set", "field": "markdown", "value": "x", "expect": { "max": 9, "revision": "r_0" } }]);
3516 let probe = json!({ "op": "set", "target": { "kind": "block", "id": "b_1" }, "field": "markdown", "value": "x", "expect": { "max": 9, "revision": "r_0" } });
3517 let top = json!({ "max": 2 });
3518 let c = ctx(&effects, Some(&select), Some(&top), &rows);
3519 let e = Effect::validate_with(&probe, 0, Some(&c)).unwrap_err();
3520 assert_eq!(
3521 e.fix.as_deref(),
3522 Some(
3523 r#"{"effects":[{"expect":{"revision":"r_0"},"field":"markdown","op":"set","value":"x"}],"expect":{"max":2},"select":"from blocks where type == \"paragraph\""}"#
3524 )
3525 );
3526 let effects = json!([{ "op": "remove", "targets": [{ "kind": "block", "id": "b_1" }], "expect": { "max": 2 } }]);
3528 let c = ctx(&effects, None, None, &rows);
3529 let e = Effect::validate_with(&effects[0], 0, Some(&c)).unwrap_err();
3530 assert_eq!(
3531 e.fix.as_deref(),
3532 Some(r#"{"effects":[{"op":"remove","targets":[{"id":"b_1","kind":"block"}]}]}"#)
3533 );
3534 }
3535
3536 #[test]
3537 fn a_block_field_alias_is_fixed_to_markdown() {
3538 for alias in ["text", "content", "body", "raw"] {
3539 let effects = json!([{ "op": "set", "target": { "kind": "block", "id": "b_1" }, "field": alias, "value": "x" }]);
3540 let c = ctx(&effects, None, None, &rows);
3541 let e = Effect::validate_with(&effects[0], 0, Some(&c)).unwrap_err();
3542 assert_eq!(
3543 e.message,
3544 format!(
3545 "effects[0]: a block's `field` is `markdown` or `attrs.checked` (got \"{alias}\")"
3546 )
3547 );
3548 assert_eq!(
3549 e.fix.as_deref(),
3550 Some(
3551 r#"{"effects":[{"field":"markdown","op":"set","target":{"id":"b_1","kind":"block"},"value":"x"}]}"#
3552 )
3553 );
3554 }
3555 let effects = json!([{ "op": "set", "target": { "kind": "block", "id": "b_1" }, "field": "title", "value": "x" }]);
3556 let c = ctx(&effects, None, None, &rows);
3557 assert_eq!(
3558 Effect::validate_with(&effects[0], 0, Some(&c))
3559 .unwrap_err()
3560 .fix,
3561 None
3562 );
3563 let effects = json!([{ "op": "set", "target": "b_1", "field": "text", "value": "x" }]);
3565 let probe = json!({ "op": "set", "target": { "kind": "block", "id": "b_1" }, "field": "text", "value": "x" });
3566 let c = ctx(&effects, None, None, &rows);
3567 assert_eq!(
3568 Effect::validate_with(&probe, 0, Some(&c))
3569 .unwrap_err()
3570 .fix
3571 .as_deref(),
3572 Some(r#"{"effects":[{"field":"markdown","op":"set","target":"b_1","value":"x"}]}"#)
3573 );
3574 }
3575}