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