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.
157    pub base_content_hash: String,
158}
159
160/// §7 `Opset`.
161#[derive(Clone, Debug, PartialEq)]
162pub struct Opset {
163    pub target_doc: String,
164    pub target_path: String,
165    pub precondition: OpsetPrecondition,
166    pub matcher_v: String,
167    pub ops: Vec<PlanOp>,
168    /// `Some(raw)` when the proposed content changes the frontmatter (`raw`
169    /// `None` drops it).
170    pub frontmatter: Option<Option<String>>,
171    pub summary: OpsetSummary,
172    pub converges: bool,
173    pub diagnostics: Vec<String>,
174}
175
176impl Opset {
177    /// The bare kernel ops, in order — what `apply` replays.
178    #[must_use]
179    pub fn kernel_ops(&self) -> Vec<Op> {
180        self.ops.iter().map(|p| p.op.clone()).collect()
181    }
182
183    /// The wire form (§7).
184    #[must_use]
185    pub fn to_json(&self) -> Value {
186        let mut m = Map::new();
187        m.insert("version".to_owned(), json!(1));
188        m.insert("kind".to_owned(), json!("doc_update"));
189        m.insert(
190            "target".to_owned(),
191            json!({ "doc": self.target_doc, "path": self.target_path }),
192        );
193        m.insert(
194            "precondition".to_owned(),
195            json!({
196                "doc": self.precondition.doc,
197                "path": self.precondition.path,
198                "base_revision": self.precondition.base_revision,
199                "base_content_hash": self.precondition.base_content_hash,
200            }),
201        );
202        m.insert("matcher_v".to_owned(), json!(self.matcher_v));
203        m.insert(
204            "ops".to_owned(),
205            Value::Array(self.ops.iter().map(PlanOp::to_json).collect()),
206        );
207        if let Some(fm) = &self.frontmatter {
208            m.insert("frontmatter".to_owned(), json!({ "raw": fm }));
209        }
210        m.insert("summary".to_owned(), self.summary.to_json());
211        m.insert("converges".to_owned(), json!(self.converges));
212        m.insert("diagnostics".to_owned(), json!(self.diagnostics));
213        Value::Object(m)
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use crate::ops::{At, Parent, To};
221
222    fn plan(d: PlanDisposition) -> PlanOp {
223        PlanOp {
224            op: Op::Move {
225                blocks: vec!["b_1".into()],
226                to: To {
227                    parent: Parent::Doc,
228                    at: At::Start,
229                },
230                expect: None,
231            },
232            disposition: d,
233            blocks: vec!["b_1".into()],
234            confidence: Some(1.0),
235            reason: Some("exact_hash".into()),
236            detail: None,
237        }
238    }
239
240    #[test]
241    fn summary_buckets() {
242        let ops: Vec<PlanOp> = PlanDisposition::ALL.iter().map(|d| plan(*d)).collect();
243        let s = summarize(&ops, 3, 1);
244        assert_eq!(
245            s,
246            OpsetSummary {
247                preserved: 3,
248                updated: 2,
249                moved: 2,
250                created: 3,
251                removed: 1,
252                split: 1,
253                merged: 1,
254                ambiguous: 1,
255            }
256        );
257        for d in PlanDisposition::ALL {
258            assert_eq!(PlanDisposition::parse(d.as_str()), Some(d));
259        }
260    }
261
262    #[test]
263    fn opset_json_shape() {
264        let opset = Opset {
265            target_doc: "d_0".into(),
266            target_path: "a.md".into(),
267            precondition: OpsetPrecondition {
268                doc: "d_0".into(),
269                path: "a.md".into(),
270                base_revision: Some("r_0".into()),
271                base_content_hash: "ab".into(),
272            },
273            matcher_v: "m2.3".into(),
274            ops: vec![plan(PlanDisposition::Moved)],
275            frontmatter: Some(None),
276            summary: OpsetSummary::default(),
277            converges: true,
278            diagnostics: vec![],
279        };
280        let v = opset.to_json();
281        assert_eq!(v["version"], json!(1));
282        assert_eq!(v["kind"], json!("doc_update"));
283        assert_eq!(v["precondition"]["base_revision"], json!("r_0"));
284        assert_eq!(v["frontmatter"], json!({ "raw": null }));
285        assert_eq!(v["ops"][0]["disposition"], json!("moved"));
286        assert_eq!(v["ops"][0]["blocks"], json!(["b_1"]));
287        assert!(v["ops"][0].get("detail").is_none());
288        assert_eq!(opset.kernel_ops().len(), 1);
289    }
290}