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