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::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    ContentBlock {
62        name: String,
63        op: JsonOp,
64        orphan: bool,
65        #[serde(skip_serializing_if = "Option::is_none")]
66        text_diff: Option<JsonTextDiff>,
67    },
68    EmailTemplate {
69        name: String,
70        op: JsonOp,
71        subject_changed: bool,
72        #[serde(skip_serializing_if = "Option::is_none")]
73        body_html_diff: Option<JsonTextDiff>,
74        #[serde(skip_serializing_if = "Option::is_none")]
75        body_plaintext_diff: Option<JsonTextDiff>,
76        metadata_changed: bool,
77        orphan: bool,
78    },
79    CustomAttribute {
80        name: String,
81        #[serde(flatten)]
82        change: JsonCustomAttributeChange,
83        #[serde(skip_serializing_if = "Vec::is_empty")]
84        hints: Vec<String>,
85    },
86}
87
88#[derive(Serialize)]
89#[serde(rename_all = "snake_case")]
90enum JsonOp {
91    Added,
92    Removed,
93    Modified,
94    Unchanged,
95}
96
97#[derive(Serialize)]
98struct JsonField {
99    name: String,
100    #[serde(rename = "type")]
101    field_type: &'static str,
102}
103
104#[derive(Serialize)]
105#[serde(tag = "op", rename_all = "snake_case")]
106enum JsonFieldDiff {
107    Added { field: JsonField },
108    Removed { field: JsonField },
109    Modified { from: JsonField, to: JsonField },
110}
111
112#[derive(Serialize)]
113struct JsonTextDiff {
114    additions: usize,
115    deletions: usize,
116}
117
118#[derive(Serialize)]
119#[serde(tag = "op", rename_all = "snake_case")]
120enum JsonCustomAttributeChange {
121    DeprecationToggled { from: bool, to: bool },
122    UnregisteredInGit,
123    PresentInGitOnly,
124    MetadataOnly,
125    Unchanged,
126}
127
128// =====================================================================
129// Domain → Wire conversion at the boundary.
130// =====================================================================
131
132impl From<&DiffSummary> for JsonRoot {
133    fn from(s: &DiffSummary) -> Self {
134        Self {
135            version: 1,
136            summary: JsonSummary {
137                changed: s.changed_count(),
138                in_sync: s.in_sync_count(),
139                destructive: s.destructive_count(),
140                orphan: s.orphan_count(),
141            },
142            diffs: s.diffs.iter().map(JsonDiffEntry::from).collect(),
143        }
144    }
145}
146
147impl From<&ResourceDiff> for JsonDiffEntry {
148    fn from(d: &ResourceDiff) -> Self {
149        match d {
150            ResourceDiff::CatalogSchema(c) => Self::from_catalog_schema(c),
151            ResourceDiff::ContentBlock(c) => Self::from_content_block(c),
152            ResourceDiff::EmailTemplate(c) => Self::from_email_template(c),
153            ResourceDiff::CustomAttribute(c) => Self::from_custom_attribute(c),
154        }
155    }
156}
157
158impl JsonDiffEntry {
159    fn from_catalog_schema(c: &CatalogSchemaDiff) -> Self {
160        Self::CatalogSchema {
161            name: c.name.clone(),
162            op: top_op(&c.op),
163            field_diffs: c.field_diffs.iter().filter_map(json_field_diff).collect(),
164        }
165    }
166
167    fn from_content_block(c: &ContentBlockDiff) -> Self {
168        Self::ContentBlock {
169            name: c.name.clone(),
170            op: top_op(&c.op),
171            orphan: c.orphan,
172            text_diff: c.text_diff.as_ref().map(json_text_diff),
173        }
174    }
175
176    fn from_email_template(c: &EmailTemplateDiff) -> Self {
177        Self::EmailTemplate {
178            name: c.name.clone(),
179            op: top_op(&c.op),
180            subject_changed: c.subject_changed,
181            body_html_diff: c.body_html_diff.as_ref().map(json_text_diff),
182            body_plaintext_diff: c.body_plaintext_diff.as_ref().map(json_text_diff),
183            metadata_changed: c.metadata_changed,
184            orphan: c.orphan,
185        }
186    }
187
188    fn from_custom_attribute(c: &CustomAttributeDiff) -> Self {
189        Self::CustomAttribute {
190            name: c.name.clone(),
191            change: json_custom_attribute_change(&c.op),
192            hints: c.hints.clone(),
193        }
194    }
195}
196
197fn top_op<T>(op: &DiffOp<T>) -> JsonOp {
198    match op {
199        DiffOp::Added(_) => JsonOp::Added,
200        DiffOp::Removed(_) => JsonOp::Removed,
201        DiffOp::Modified { .. } => JsonOp::Modified,
202        DiffOp::Unchanged => JsonOp::Unchanged,
203    }
204}
205
206fn json_field(f: &CatalogField) -> JsonField {
207    JsonField {
208        name: f.name.clone(),
209        field_type: f.field_type.as_str(),
210    }
211}
212
213fn json_field_diff(d: &DiffOp<CatalogField>) -> Option<JsonFieldDiff> {
214    Some(match d {
215        DiffOp::Added(f) => JsonFieldDiff::Added {
216            field: json_field(f),
217        },
218        DiffOp::Removed(f) => JsonFieldDiff::Removed {
219            field: json_field(f),
220        },
221        DiffOp::Modified { from, to } => JsonFieldDiff::Modified {
222            from: json_field(from),
223            to: json_field(to),
224        },
225        DiffOp::Unchanged => return None,
226    })
227}
228
229fn json_text_diff(t: &TextDiffSummary) -> JsonTextDiff {
230    JsonTextDiff {
231        additions: t.additions,
232        deletions: t.deletions,
233    }
234}
235
236fn json_custom_attribute_change(op: &CustomAttributeOp) -> JsonCustomAttributeChange {
237    match op {
238        CustomAttributeOp::DeprecationToggled { from, to } => {
239            JsonCustomAttributeChange::DeprecationToggled {
240                from: *from,
241                to: *to,
242            }
243        }
244        CustomAttributeOp::UnregisteredInGit => JsonCustomAttributeChange::UnregisteredInGit,
245        CustomAttributeOp::PresentInGitOnly => JsonCustomAttributeChange::PresentInGitOnly,
246        CustomAttributeOp::MetadataOnly => JsonCustomAttributeChange::MetadataOnly,
247        CustomAttributeOp::Unchanged => JsonCustomAttributeChange::Unchanged,
248    }
249}
250
251#[cfg(test)]
252mod parses_back_tests {
253    //! Sanity that whatever we emit is at least valid JSON. Snapshot
254    //! tests in `snapshot_tests.rs` lock the exact text.
255    use super::render;
256    use crate::diff::DiffSummary;
257
258    #[test]
259    fn empty_summary_renders_valid_json_with_version_1() {
260        let s = render(&DiffSummary::default());
261        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
262        assert_eq!(v["version"], serde_json::json!(1));
263        assert!(v["diffs"].as_array().unwrap().is_empty());
264    }
265}