Skip to main content

omgbase_mutate/
effects.rs

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