1use serde_json::{Map, Value, json};
6
7use crate::changeset::Op;
8
9#[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#[derive(Clone, Debug, PartialEq)]
69pub struct PlanOp {
70 pub op: Op,
71 pub disposition: PlanDisposition,
72 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#[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#[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#[derive(Clone, Debug, PartialEq, Eq)]
151pub struct OpsetPrecondition {
152 pub doc: String,
153 pub path: String,
154 pub base_revision: Option<String>,
156 pub base_content_hash: String,
158}
159
160#[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 pub frontmatter: Option<Option<String>>,
171 pub summary: OpsetSummary,
172 pub converges: bool,
173 pub diagnostics: Vec<String>,
174}
175
176impl Opset {
177 #[must_use]
179 pub fn kernel_ops(&self) -> Vec<Op> {
180 self.ops.iter().map(|p| p.op.clone()).collect()
181 }
182
183 #[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}