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