1use crate::diff::catalog::{CatalogItemsDiff, CatalogSchemaDiff};
15use crate::diff::content_block::ContentBlockDiff;
16use crate::diff::custom_attribute::{CustomAttributeDiff, CustomAttributeOp};
17use crate::diff::email_template::EmailTemplateDiff;
18use crate::diff::TextDiffSummary;
19use crate::diff::{DiffOp, DiffSummary, ResourceDiff};
20use crate::resource::CatalogField;
21use serde::Serialize;
22
23pub fn render(summary: &DiffSummary) -> String {
24 let root = JsonRoot::from(summary);
25 let mut s = serde_json::to_string_pretty(&root).expect("internal wire types serialize cleanly");
26 s.push('\n');
31 s
32}
33
34#[derive(Serialize)]
39struct JsonRoot {
40 version: u32,
41 summary: JsonSummary,
42 diffs: Vec<JsonDiffEntry>,
43}
44
45#[derive(Serialize)]
46struct JsonSummary {
47 changed: usize,
48 in_sync: usize,
49 destructive: usize,
50 orphan: usize,
51}
52
53#[derive(Serialize)]
54#[serde(tag = "kind", rename_all = "snake_case")]
55enum JsonDiffEntry {
56 CatalogSchema {
57 name: String,
58 op: JsonOp,
59 field_diffs: Vec<JsonFieldDiff>,
60 },
61 CatalogItems {
62 catalog_name: String,
63 added: usize,
64 modified: usize,
65 removed: usize,
66 unchanged: usize,
67 },
68 ContentBlock {
69 name: String,
70 op: JsonOp,
71 orphan: bool,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 text_diff: Option<JsonTextDiff>,
74 },
75 EmailTemplate {
76 name: String,
77 op: JsonOp,
78 subject_changed: bool,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 body_html_diff: Option<JsonTextDiff>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 body_plaintext_diff: Option<JsonTextDiff>,
83 metadata_changed: bool,
84 orphan: bool,
85 },
86 CustomAttribute {
87 name: String,
88 #[serde(flatten)]
89 change: JsonCustomAttributeChange,
90 #[serde(skip_serializing_if = "Vec::is_empty")]
91 hints: Vec<String>,
92 },
93}
94
95#[derive(Serialize)]
96#[serde(rename_all = "snake_case")]
97enum JsonOp {
98 Added,
99 Removed,
100 Modified,
101 Unchanged,
102}
103
104#[derive(Serialize)]
105struct JsonField {
106 name: String,
107 #[serde(rename = "type")]
108 field_type: &'static str,
109}
110
111#[derive(Serialize)]
112#[serde(tag = "op", rename_all = "snake_case")]
113enum JsonFieldDiff {
114 Added { field: JsonField },
115 Removed { field: JsonField },
116 Modified { from: JsonField, to: JsonField },
117}
118
119#[derive(Serialize)]
120struct JsonTextDiff {
121 additions: usize,
122 deletions: usize,
123}
124
125#[derive(Serialize)]
126#[serde(tag = "op", rename_all = "snake_case")]
127enum JsonCustomAttributeChange {
128 DeprecationToggled { from: bool, to: bool },
129 UnregisteredInGit,
130 PresentInGitOnly,
131 MetadataOnly,
132 Unchanged,
133}
134
135impl From<&DiffSummary> for JsonRoot {
140 fn from(s: &DiffSummary) -> Self {
141 Self {
142 version: 1,
143 summary: JsonSummary {
144 changed: s.changed_count(),
145 in_sync: s.in_sync_count(),
146 destructive: s.destructive_count(),
147 orphan: s.orphan_count(),
148 },
149 diffs: s.diffs.iter().map(JsonDiffEntry::from).collect(),
150 }
151 }
152}
153
154impl From<&ResourceDiff> for JsonDiffEntry {
155 fn from(d: &ResourceDiff) -> Self {
156 match d {
157 ResourceDiff::CatalogSchema(c) => Self::from_catalog_schema(c),
158 ResourceDiff::CatalogItems(c) => Self::from_catalog_items(c),
159 ResourceDiff::ContentBlock(c) => Self::from_content_block(c),
160 ResourceDiff::EmailTemplate(c) => Self::from_email_template(c),
161 ResourceDiff::CustomAttribute(c) => Self::from_custom_attribute(c),
162 }
163 }
164}
165
166impl JsonDiffEntry {
167 fn from_catalog_schema(c: &CatalogSchemaDiff) -> Self {
168 Self::CatalogSchema {
169 name: c.name.clone(),
170 op: top_op(&c.op),
171 field_diffs: c.field_diffs.iter().filter_map(json_field_diff).collect(),
172 }
173 }
174
175 fn from_catalog_items(c: &CatalogItemsDiff) -> Self {
176 Self::CatalogItems {
177 catalog_name: c.catalog_name.clone(),
178 added: c.added_ids.len(),
179 modified: c.modified_ids.len(),
180 removed: c.removed_ids.len(),
181 unchanged: c.unchanged_count,
182 }
183 }
184
185 fn from_content_block(c: &ContentBlockDiff) -> Self {
186 Self::ContentBlock {
187 name: c.name.clone(),
188 op: top_op(&c.op),
189 orphan: c.orphan,
190 text_diff: c.text_diff.as_ref().map(json_text_diff),
191 }
192 }
193
194 fn from_email_template(c: &EmailTemplateDiff) -> Self {
195 Self::EmailTemplate {
196 name: c.name.clone(),
197 op: top_op(&c.op),
198 subject_changed: c.subject_changed,
199 body_html_diff: c.body_html_diff.as_ref().map(json_text_diff),
200 body_plaintext_diff: c.body_plaintext_diff.as_ref().map(json_text_diff),
201 metadata_changed: c.metadata_changed,
202 orphan: c.orphan,
203 }
204 }
205
206 fn from_custom_attribute(c: &CustomAttributeDiff) -> Self {
207 Self::CustomAttribute {
208 name: c.name.clone(),
209 change: json_custom_attribute_change(&c.op),
210 hints: c.hints.clone(),
211 }
212 }
213}
214
215fn top_op<T>(op: &DiffOp<T>) -> JsonOp {
216 match op {
217 DiffOp::Added(_) => JsonOp::Added,
218 DiffOp::Removed(_) => JsonOp::Removed,
219 DiffOp::Modified { .. } => JsonOp::Modified,
220 DiffOp::Unchanged => JsonOp::Unchanged,
221 }
222}
223
224fn json_field(f: &CatalogField) -> JsonField {
225 JsonField {
226 name: f.name.clone(),
227 field_type: f.field_type.as_str(),
228 }
229}
230
231fn json_field_diff(d: &DiffOp<CatalogField>) -> Option<JsonFieldDiff> {
232 Some(match d {
233 DiffOp::Added(f) => JsonFieldDiff::Added {
234 field: json_field(f),
235 },
236 DiffOp::Removed(f) => JsonFieldDiff::Removed {
237 field: json_field(f),
238 },
239 DiffOp::Modified { from, to } => JsonFieldDiff::Modified {
240 from: json_field(from),
241 to: json_field(to),
242 },
243 DiffOp::Unchanged => return None,
244 })
245}
246
247fn json_text_diff(t: &TextDiffSummary) -> JsonTextDiff {
248 JsonTextDiff {
249 additions: t.additions,
250 deletions: t.deletions,
251 }
252}
253
254fn json_custom_attribute_change(op: &CustomAttributeOp) -> JsonCustomAttributeChange {
255 match op {
256 CustomAttributeOp::DeprecationToggled { from, to } => {
257 JsonCustomAttributeChange::DeprecationToggled {
258 from: *from,
259 to: *to,
260 }
261 }
262 CustomAttributeOp::UnregisteredInGit => JsonCustomAttributeChange::UnregisteredInGit,
263 CustomAttributeOp::PresentInGitOnly => JsonCustomAttributeChange::PresentInGitOnly,
264 CustomAttributeOp::MetadataOnly => JsonCustomAttributeChange::MetadataOnly,
265 CustomAttributeOp::Unchanged => JsonCustomAttributeChange::Unchanged,
266 }
267}
268
269#[cfg(test)]
270mod parses_back_tests {
271 use super::render;
274 use crate::diff::DiffSummary;
275
276 #[test]
277 fn empty_summary_renders_valid_json_with_version_1() {
278 let s = render(&DiffSummary::default());
279 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
280 assert_eq!(v["version"], serde_json::json!(1));
281 assert!(v["diffs"].as_array().unwrap().is_empty());
282 }
283}