Skip to main content

braze_sync/format/
json.rs

1//! JSON formatter for diff results.
2//!
3//! The JSON shape here is **frozen at v1.0** with an explicit
4//! `version: 1` field on the root. New fields may be added (additive
5//! only); existing fields cannot be renamed or removed without bumping
6//! `version`. CI consumers can branch on `version` to support a future
7//! v2 schema. See IMPLEMENTATION.md §12 / §2.5.
8//!
9//! Wire types are deliberately separate from `crate::diff` /
10//! `crate::resource` types so refactoring a domain type cannot
11//! accidentally change the public JSON contract. Conversion happens at
12//! the boundary in [`From`] impls.
13
14use crate::diff::catalog::CatalogSchemaDiff;
15use crate::diff::content_block::ContentBlockDiff;
16use crate::diff::custom_attribute::{CustomAttributeDiff, CustomAttributeOp};
17use crate::diff::email_template::EmailTemplateDiff;
18use crate::diff::tag::{TagDiff, TagOp};
19use crate::diff::TextDiffSummary;
20use crate::diff::{DiffOp, DiffSummary, ResourceDiff};
21use crate::resource::CatalogField;
22use serde::Serialize;
23
24pub fn render(summary: &DiffSummary) -> String {
25    let root = JsonRoot::from(summary);
26    let mut s = serde_json::to_string_pretty(&root).expect("internal wire types serialize cleanly");
27    // Formatter contract: render returns a display-ready string ending
28    // with exactly one newline. table::render already does; this matches.
29    // insta strips trailing newlines, so the existing snapshots are
30    // unaffected.
31    s.push('\n');
32    s
33}
34
35// =====================================================================
36// Wire types — public JSON contract, frozen at v1.0.
37// =====================================================================
38
39#[derive(Serialize)]
40struct JsonRoot {
41    version: u32,
42    summary: JsonSummary,
43    diffs: Vec<JsonDiffEntry>,
44}
45
46#[derive(Serialize)]
47struct JsonSummary {
48    changed: usize,
49    in_sync: usize,
50    destructive: usize,
51    orphan: usize,
52}
53
54#[derive(Serialize)]
55#[serde(tag = "kind", rename_all = "snake_case")]
56enum JsonDiffEntry {
57    CatalogSchema {
58        name: String,
59        op: JsonOp,
60        field_diffs: Vec<JsonFieldDiff>,
61    },
62    ContentBlock {
63        name: String,
64        op: JsonOp,
65        orphan: bool,
66        #[serde(skip_serializing_if = "Option::is_none")]
67        text_diff: Option<JsonTextDiff>,
68    },
69    EmailTemplate {
70        name: String,
71        op: JsonOp,
72        subject_changed: bool,
73        #[serde(skip_serializing_if = "Option::is_none")]
74        body_html_diff: Option<JsonTextDiff>,
75        #[serde(skip_serializing_if = "Option::is_none")]
76        body_plaintext_diff: Option<JsonTextDiff>,
77        metadata_changed: bool,
78        orphan: bool,
79    },
80    CustomAttribute {
81        name: String,
82        #[serde(flatten)]
83        change: JsonCustomAttributeChange,
84        #[serde(skip_serializing_if = "Vec::is_empty")]
85        hints: Vec<String>,
86    },
87    Tag {
88        name: String,
89        #[serde(flatten)]
90        change: JsonTagChange,
91        #[serde(skip_serializing_if = "Vec::is_empty")]
92        hints: Vec<String>,
93    },
94}
95
96#[derive(Serialize)]
97#[serde(rename_all = "snake_case")]
98enum JsonOp {
99    Added,
100    Removed,
101    Modified,
102    Unchanged,
103}
104
105#[derive(Serialize)]
106struct JsonField {
107    name: String,
108    #[serde(rename = "type")]
109    field_type: &'static str,
110}
111
112#[derive(Serialize)]
113#[serde(tag = "op", rename_all = "snake_case")]
114enum JsonFieldDiff {
115    Added { field: JsonField },
116    Removed { field: JsonField },
117    Modified { from: JsonField, to: JsonField },
118}
119
120#[derive(Serialize)]
121struct JsonTextDiff {
122    additions: usize,
123    deletions: usize,
124}
125
126#[derive(Serialize)]
127#[serde(tag = "op", rename_all = "snake_case")]
128enum JsonCustomAttributeChange {
129    DeprecationToggled { from: bool, to: bool },
130    UnregisteredInGit,
131    PresentInGitOnly,
132    MetadataOnly,
133    Unchanged,
134}
135
136#[derive(Serialize)]
137#[serde(tag = "op", rename_all = "snake_case")]
138enum JsonTagChange {
139    ReferencedButUnregistered,
140    RegisteredButUnreferenced,
141    Unchanged,
142}
143
144// =====================================================================
145// Domain → Wire conversion at the boundary.
146// =====================================================================
147
148impl From<&DiffSummary> for JsonRoot {
149    fn from(s: &DiffSummary) -> Self {
150        Self {
151            version: 1,
152            summary: JsonSummary {
153                changed: s.changed_count(),
154                in_sync: s.in_sync_count(),
155                destructive: s.destructive_count(),
156                orphan: s.orphan_count(),
157            },
158            diffs: s.diffs.iter().map(JsonDiffEntry::from).collect(),
159        }
160    }
161}
162
163impl From<&ResourceDiff> for JsonDiffEntry {
164    fn from(d: &ResourceDiff) -> Self {
165        match d {
166            ResourceDiff::CatalogSchema(c) => Self::from_catalog_schema(c),
167            ResourceDiff::ContentBlock(c) => Self::from_content_block(c),
168            ResourceDiff::EmailTemplate(c) => Self::from_email_template(c),
169            ResourceDiff::CustomAttribute(c) => Self::from_custom_attribute(c),
170            ResourceDiff::Tag(c) => Self::from_tag(c),
171        }
172    }
173}
174
175impl JsonDiffEntry {
176    fn from_catalog_schema(c: &CatalogSchemaDiff) -> Self {
177        Self::CatalogSchema {
178            name: c.name.clone(),
179            op: top_op(&c.op),
180            field_diffs: c.field_diffs.iter().filter_map(json_field_diff).collect(),
181        }
182    }
183
184    fn from_content_block(c: &ContentBlockDiff) -> Self {
185        Self::ContentBlock {
186            name: c.name.clone(),
187            op: top_op(&c.op),
188            orphan: c.orphan,
189            text_diff: c.text_diff.as_ref().map(json_text_diff),
190        }
191    }
192
193    fn from_email_template(c: &EmailTemplateDiff) -> Self {
194        Self::EmailTemplate {
195            name: c.name.clone(),
196            op: top_op(&c.op),
197            subject_changed: c.subject_changed,
198            body_html_diff: c.body_html_diff.as_ref().map(json_text_diff),
199            body_plaintext_diff: c.body_plaintext_diff.as_ref().map(json_text_diff),
200            metadata_changed: c.metadata_changed,
201            orphan: c.orphan,
202        }
203    }
204
205    fn from_custom_attribute(c: &CustomAttributeDiff) -> Self {
206        Self::CustomAttribute {
207            name: c.name.clone(),
208            change: json_custom_attribute_change(&c.op),
209            hints: c.hints.clone(),
210        }
211    }
212
213    fn from_tag(c: &TagDiff) -> Self {
214        Self::Tag {
215            name: c.name.clone(),
216            change: json_tag_change(&c.op),
217            hints: c.hints.clone(),
218        }
219    }
220}
221
222fn json_tag_change(op: &TagOp) -> JsonTagChange {
223    match op {
224        TagOp::ReferencedButUnregistered => JsonTagChange::ReferencedButUnregistered,
225        TagOp::RegisteredButUnreferenced => JsonTagChange::RegisteredButUnreferenced,
226        TagOp::Unchanged => JsonTagChange::Unchanged,
227    }
228}
229
230fn top_op<T>(op: &DiffOp<T>) -> JsonOp {
231    match op {
232        DiffOp::Added(_) => JsonOp::Added,
233        DiffOp::Removed(_) => JsonOp::Removed,
234        DiffOp::Modified { .. } => JsonOp::Modified,
235        DiffOp::Unchanged => JsonOp::Unchanged,
236    }
237}
238
239fn json_field(f: &CatalogField) -> JsonField {
240    JsonField {
241        name: f.name.clone(),
242        field_type: f.field_type.as_str(),
243    }
244}
245
246fn json_field_diff(d: &DiffOp<CatalogField>) -> Option<JsonFieldDiff> {
247    Some(match d {
248        DiffOp::Added(f) => JsonFieldDiff::Added {
249            field: json_field(f),
250        },
251        DiffOp::Removed(f) => JsonFieldDiff::Removed {
252            field: json_field(f),
253        },
254        DiffOp::Modified { from, to } => JsonFieldDiff::Modified {
255            from: json_field(from),
256            to: json_field(to),
257        },
258        DiffOp::Unchanged => return None,
259    })
260}
261
262fn json_text_diff(t: &TextDiffSummary) -> JsonTextDiff {
263    JsonTextDiff {
264        additions: t.additions,
265        deletions: t.deletions,
266    }
267}
268
269fn json_custom_attribute_change(op: &CustomAttributeOp) -> JsonCustomAttributeChange {
270    match op {
271        CustomAttributeOp::DeprecationToggled { from, to } => {
272            JsonCustomAttributeChange::DeprecationToggled {
273                from: *from,
274                to: *to,
275            }
276        }
277        CustomAttributeOp::UnregisteredInGit => JsonCustomAttributeChange::UnregisteredInGit,
278        CustomAttributeOp::PresentInGitOnly => JsonCustomAttributeChange::PresentInGitOnly,
279        CustomAttributeOp::MetadataOnly => JsonCustomAttributeChange::MetadataOnly,
280        CustomAttributeOp::Unchanged => JsonCustomAttributeChange::Unchanged,
281    }
282}
283
284#[cfg(test)]
285mod parses_back_tests {
286    //! Sanity that whatever we emit is at least valid JSON. Snapshot
287    //! tests in `snapshot_tests.rs` lock the exact text.
288    use super::render;
289    use crate::diff::DiffSummary;
290
291    #[test]
292    fn empty_summary_renders_valid_json_with_version_1() {
293        let s = render(&DiffSummary::default());
294        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
295        assert_eq!(v["version"], serde_json::json!(1));
296        assert!(v["diffs"].as_array().unwrap().is_empty());
297    }
298}