Skip to main content

omgbase_mutate/
opset.rs

1//! The opset (`spec/mutate/README.md` §7): an inspectable plan — kernel ops
2//! annotated with the identity consequence of each — pinned to the state it
3//! was planned against, plus the summary.
4
5use serde_json::{Map, Value, json};
6
7use crate::changeset::Op;
8
9/// The identity consequence of one planned op (the matcher's vocabulary plus
10/// the synthetic `retiled`).
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
12pub enum PlanDisposition {
13    Same,
14    Edited,
15    Moved,
16    EditedMoved,
17    Inserted,
18    Deleted,
19    SplitFrom,
20    MergedInto,
21    CopiedFrom,
22    Resurrected,
23    BulkRewrite,
24    Retiled,
25}
26
27impl PlanDisposition {
28    pub const ALL: [PlanDisposition; 12] = [
29        PlanDisposition::Same,
30        PlanDisposition::Edited,
31        PlanDisposition::Moved,
32        PlanDisposition::EditedMoved,
33        PlanDisposition::Inserted,
34        PlanDisposition::Deleted,
35        PlanDisposition::SplitFrom,
36        PlanDisposition::MergedInto,
37        PlanDisposition::CopiedFrom,
38        PlanDisposition::Resurrected,
39        PlanDisposition::BulkRewrite,
40        PlanDisposition::Retiled,
41    ];
42
43    #[must_use]
44    pub const fn as_str(&self) -> &'static str {
45        match self {
46            PlanDisposition::Same => "same",
47            PlanDisposition::Edited => "edited",
48            PlanDisposition::Moved => "moved",
49            PlanDisposition::EditedMoved => "edited_moved",
50            PlanDisposition::Inserted => "inserted",
51            PlanDisposition::Deleted => "deleted",
52            PlanDisposition::SplitFrom => "split_from",
53            PlanDisposition::MergedInto => "merged_into",
54            PlanDisposition::CopiedFrom => "copied_from",
55            PlanDisposition::Resurrected => "resurrected",
56            PlanDisposition::BulkRewrite => "bulk_rewrite",
57            PlanDisposition::Retiled => "retiled",
58        }
59    }
60
61    #[must_use]
62    pub fn parse(s: &str) -> Option<Self> {
63        Self::ALL.iter().copied().find(|d| d.as_str() == s)
64    }
65}
66
67/// One kernel op plus the identity consequence the planner attributes to it.
68#[derive(Clone, Debug, PartialEq)]
69pub struct PlanOp {
70    pub op: Op,
71    pub disposition: PlanDisposition,
72    /// The carried id(s) the op acts on; empty for an insert of new structure.
73    pub blocks: Vec<String>,
74    pub confidence: Option<f64>,
75    pub reason: Option<String>,
76    pub detail: Option<Value>,
77}
78
79impl PlanOp {
80    #[must_use]
81    pub fn to_json(&self) -> Value {
82        let mut m = Map::new();
83        m.insert("op".to_owned(), self.op.to_json());
84        m.insert("disposition".to_owned(), json!(self.disposition.as_str()));
85        m.insert("blocks".to_owned(), json!(self.blocks));
86        m.insert("confidence".to_owned(), json!(self.confidence));
87        m.insert("reason".to_owned(), json!(self.reason));
88        if let Some(d) = &self.detail {
89            m.insert("detail".to_owned(), d.clone());
90        }
91        Value::Object(m)
92    }
93}
94
95/// The identity accounting over the reconciliation (§7 `summary`).
96#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
97pub struct OpsetSummary {
98    pub preserved: usize,
99    pub updated: usize,
100    pub moved: usize,
101    pub created: usize,
102    pub removed: usize,
103    pub split: usize,
104    pub merged: usize,
105    pub ambiguous: usize,
106}
107
108impl OpsetSummary {
109    #[must_use]
110    pub fn to_json(&self) -> Value {
111        json!({
112            "preserved": self.preserved, "updated": self.updated, "moved": self.moved,
113            "created": self.created, "removed": self.removed, "split": self.split,
114            "merged": self.merged, "ambiguous": self.ambiguous,
115        })
116    }
117}
118
119/// §7: the summary from the planned ops and the lowering's counts.
120#[must_use]
121pub fn summarize(ops: &[PlanOp], preserved: usize, ambiguous: usize) -> OpsetSummary {
122    let mut s = OpsetSummary {
123        preserved,
124        ambiguous,
125        ..OpsetSummary::default()
126    };
127    for p in ops {
128        match p.disposition {
129            PlanDisposition::Edited => s.updated += 1,
130            PlanDisposition::Moved => s.moved += 1,
131            PlanDisposition::EditedMoved => {
132                s.updated += 1;
133                s.moved += 1;
134            }
135            PlanDisposition::Inserted
136            | PlanDisposition::CopiedFrom
137            | PlanDisposition::Resurrected => {
138                s.created += 1;
139            }
140            PlanDisposition::Deleted => s.removed += 1,
141            PlanDisposition::SplitFrom => s.split += 1,
142            PlanDisposition::MergedInto => s.merged += 1,
143            PlanDisposition::Same | PlanDisposition::BulkRewrite | PlanDisposition::Retiled => {}
144        }
145    }
146    s
147}
148
149/// State the opset was planned against (§7 `precondition`).
150#[derive(Clone, Debug, PartialEq, Eq)]
151pub struct OpsetPrecondition {
152    pub doc: String,
153    pub path: String,
154    /// The current revision at plan time; `None` for a doc with no revision.
155    pub base_revision: Option<String>,
156    /// `hex(sha256(render(doc)))` at plan time — the document's `hash`
157    /// (`spec/surface` §1.2, 3.0; `base_content_hash` before mutate 2.0).
158    pub hash: String,
159}
160
161/// §7 `Opset`.
162#[derive(Clone, Debug, PartialEq)]
163pub struct Opset {
164    pub target_doc: String,
165    pub target_path: String,
166    pub precondition: OpsetPrecondition,
167    pub matcher_v: String,
168    pub ops: Vec<PlanOp>,
169    /// `Some(raw)` when the proposed content changes the frontmatter (`raw`
170    /// `None` drops it).
171    pub frontmatter: Option<Option<String>>,
172    pub summary: OpsetSummary,
173    pub converges: bool,
174    pub diagnostics: Vec<String>,
175}
176
177impl Opset {
178    /// The bare kernel ops, in order — what `apply` replays.
179    #[must_use]
180    pub fn kernel_ops(&self) -> Vec<Op> {
181        self.ops.iter().map(|p| p.op.clone()).collect()
182    }
183
184    /// The wire form (§7).
185    #[must_use]
186    pub fn to_json(&self) -> Value {
187        let mut m = Map::new();
188        m.insert("version".to_owned(), json!(1));
189        m.insert("kind".to_owned(), json!("doc_update"));
190        // The document under the surface's row rule (`spec/surface` §1.2, 3.0):
191        // identity `id`/`path`, its hash `hash`; `base_revision` names a role.
192        m.insert(
193            "target".to_owned(),
194            json!({ "id": self.target_doc, "path": self.target_path }),
195        );
196        m.insert(
197            "precondition".to_owned(),
198            json!({
199                "id": self.precondition.doc,
200                "path": self.precondition.path,
201                "base_revision": self.precondition.base_revision,
202                "hash": self.precondition.hash,
203            }),
204        );
205        m.insert("matcher_v".to_owned(), json!(self.matcher_v));
206        m.insert(
207            "ops".to_owned(),
208            Value::Array(self.ops.iter().map(PlanOp::to_json).collect()),
209        );
210        if let Some(fm) = &self.frontmatter {
211            m.insert("frontmatter".to_owned(), json!({ "raw": fm }));
212        }
213        m.insert("summary".to_owned(), self.summary.to_json());
214        m.insert("converges".to_owned(), json!(self.converges));
215        m.insert("diagnostics".to_owned(), json!(self.diagnostics));
216        Value::Object(m)
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::ops::{At, Parent, To};
224
225    fn plan(d: PlanDisposition) -> PlanOp {
226        PlanOp {
227            op: Op::Move {
228                blocks: vec!["b_1".into()],
229                to: To {
230                    parent: Parent::Doc,
231                    at: At::Start,
232                },
233                doc: None,
234                expect: None,
235            },
236            disposition: d,
237            blocks: vec!["b_1".into()],
238            confidence: Some(1.0),
239            reason: Some("exact_hash".into()),
240            detail: None,
241        }
242    }
243
244    #[test]
245    fn summary_buckets() {
246        let ops: Vec<PlanOp> = PlanDisposition::ALL.iter().map(|d| plan(*d)).collect();
247        let s = summarize(&ops, 3, 1);
248        assert_eq!(
249            s,
250            OpsetSummary {
251                preserved: 3,
252                updated: 2,
253                moved: 2,
254                created: 3,
255                removed: 1,
256                split: 1,
257                merged: 1,
258                ambiguous: 1,
259            }
260        );
261        for d in PlanDisposition::ALL {
262            assert_eq!(PlanDisposition::parse(d.as_str()), Some(d));
263        }
264    }
265
266    #[test]
267    fn opset_json_shape() {
268        let opset = Opset {
269            target_doc: "d_0".into(),
270            target_path: "a.md".into(),
271            precondition: OpsetPrecondition {
272                doc: "d_0".into(),
273                path: "a.md".into(),
274                base_revision: Some("r_0".into()),
275                hash: "ab".into(),
276            },
277            matcher_v: "m2.3".into(),
278            ops: vec![plan(PlanDisposition::Moved)],
279            frontmatter: Some(None),
280            summary: OpsetSummary::default(),
281            converges: true,
282            diagnostics: vec![],
283        };
284        let v = opset.to_json();
285        assert_eq!(v["version"], json!(1));
286        assert_eq!(v["kind"], json!("doc_update"));
287        assert_eq!(v["precondition"]["base_revision"], json!("r_0"));
288        assert_eq!(v["frontmatter"], json!({ "raw": null }));
289        assert_eq!(v["ops"][0]["disposition"], json!("moved"));
290        assert_eq!(v["ops"][0]["blocks"], json!(["b_1"]));
291        assert!(v["ops"][0].get("detail").is_none());
292        assert_eq!(opset.kernel_ops().len(), 1);
293    }
294}