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 hash: String,
159}
160
161#[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 pub frontmatter: Option<Option<String>>,
172 pub summary: OpsetSummary,
173 pub converges: bool,
174 pub diagnostics: Vec<String>,
175}
176
177impl Opset {
178 #[must_use]
180 pub fn kernel_ops(&self) -> Vec<Op> {
181 self.ops.iter().map(|p| p.op.clone()).collect()
182 }
183
184 #[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 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}