Skip to main content

braze_sync/format/
table.rs

1//! Human-readable table formatter for diff results.
2//!
3//! Renders a [`crate::diff::DiffSummary`] as the indented multi-resource
4//! layout shown in IMPLEMENTATION.md ยง7.4. v0.1.0 ships without ANSI
5//! colors; `--no-color` is a no-op until a future cosmetic pass.
6
7use crate::diff::catalog::CatalogSchemaDiff;
8use crate::diff::content_block::ContentBlockDiff;
9use crate::diff::custom_attribute::{CustomAttributeDiff, CustomAttributeOp};
10use crate::diff::email_template::EmailTemplateDiff;
11use crate::diff::{DiffOp, DiffSummary, ResourceDiff};
12use crate::resource::{CatalogField, ResourceKind};
13use std::fmt::Write as _;
14
15pub fn render(summary: &DiffSummary) -> String {
16    let mut out = String::new();
17
18    for diff in &summary.diffs {
19        render_one(&mut out, diff);
20        out.push('\n');
21    }
22
23    let _ = writeln!(
24        out,
25        "Summary: {} changed, {} in sync, {} orphan, {} destructive",
26        summary.changed_count(),
27        summary.in_sync_count(),
28        summary.orphan_count(),
29        summary.destructive_count(),
30    );
31
32    out
33}
34
35fn render_one(out: &mut String, diff: &ResourceDiff) {
36    let unchanged = !diff.has_changes();
37    let icon = if unchanged {
38        "โœ…"
39    } else {
40        kind_icon(diff.kind())
41    };
42    let label = kind_label(diff.kind());
43    let _ = writeln!(out, "{icon} {label}: {}", diff.name());
44
45    if unchanged {
46        out.push_str("   no drift\n");
47        // Custom Attributes may carry informational hints (e.g. type
48        // mismatch) even when unchanged.
49        if let ResourceDiff::CustomAttribute(d) = diff {
50            render_custom_attribute(out, d);
51        }
52        return;
53    }
54
55    match diff {
56        ResourceDiff::CatalogSchema(d) => render_catalog_schema(out, d),
57        ResourceDiff::ContentBlock(d) => render_content_block(out, d),
58        ResourceDiff::EmailTemplate(d) => render_email_template(out, d),
59        ResourceDiff::CustomAttribute(d) => render_custom_attribute(out, d),
60        ResourceDiff::Tag(d) => render_tag(out, d),
61    }
62}
63
64fn kind_icon(kind: ResourceKind) -> &'static str {
65    match kind {
66        ResourceKind::CatalogSchema => "๐Ÿ“‹",
67        ResourceKind::ContentBlock => "๐Ÿ“",
68        ResourceKind::EmailTemplate => "๐Ÿ“ง",
69        ResourceKind::CustomAttribute => "๐Ÿท ",
70        ResourceKind::Tag => "๐Ÿ”–",
71    }
72}
73
74fn kind_label(kind: ResourceKind) -> &'static str {
75    match kind {
76        ResourceKind::CatalogSchema => "Catalog Schema",
77        ResourceKind::ContentBlock => "Content Block",
78        ResourceKind::EmailTemplate => "Email Template",
79        ResourceKind::CustomAttribute => "Custom Attribute",
80        ResourceKind::Tag => "Tag",
81    }
82}
83
84fn fmt_field(f: &CatalogField) -> String {
85    format!("{} ({})", f.name, f.field_type.as_str())
86}
87
88fn render_catalog_schema(out: &mut String, d: &CatalogSchemaDiff) {
89    if matches!(d.op, DiffOp::Added(_)) {
90        out.push_str("   + new catalog\n");
91    } else if matches!(d.op, DiffOp::Removed(_)) {
92        out.push_str("   - removed catalog (destructive)\n");
93    }
94    for fd in &d.field_diffs {
95        match fd {
96            DiffOp::Added(f) => {
97                let _ = writeln!(out, "   + field: {}", fmt_field(f));
98            }
99            DiffOp::Removed(f) => {
100                let _ = writeln!(out, "   - field: {}", fmt_field(f));
101            }
102            DiffOp::Modified { from, to } => {
103                let _ = writeln!(
104                    out,
105                    "   ~ field: {} ({} โ†’ {})",
106                    to.name,
107                    from.field_type.as_str(),
108                    to.field_type.as_str(),
109                );
110            }
111            DiffOp::Unchanged => {}
112        }
113    }
114}
115
116fn render_content_block(out: &mut String, d: &ContentBlockDiff) {
117    if d.orphan {
118        out.push_str("   โš  orphaned (exists in Braze, not in Git)\n");
119        return;
120    }
121    match &d.op {
122        DiffOp::Added(_) => out.push_str("   + new content block\n"),
123        DiffOp::Removed(_) => out.push_str("   - removed content block\n"),
124        DiffOp::Modified { .. } => {
125            if let Some(td) = &d.text_diff {
126                let _ = writeln!(
127                    out,
128                    "   ~ content changed (+{} -{})",
129                    td.additions, td.deletions,
130                );
131            } else {
132                out.push_str("   ~ content changed\n");
133            }
134        }
135        DiffOp::Unchanged => {}
136    }
137}
138
139fn render_email_template(out: &mut String, d: &EmailTemplateDiff) {
140    if d.orphan {
141        out.push_str("   โš  orphaned (exists in Braze, not in Git)\n");
142        return;
143    }
144    if matches!(d.op, DiffOp::Added(_)) {
145        out.push_str("   + new email template\n");
146    } else if matches!(d.op, DiffOp::Removed(_)) {
147        out.push_str("   - removed email template\n");
148    }
149    if d.subject_changed {
150        out.push_str("   ~ subject changed\n");
151    }
152    if let Some(td) = &d.body_html_diff {
153        let _ = writeln!(
154            out,
155            "   ~ body_html changed (+{} -{})",
156            td.additions, td.deletions
157        );
158    }
159    if let Some(td) = &d.body_plaintext_diff {
160        let _ = writeln!(
161            out,
162            "   ~ body_plaintext changed (+{} -{})",
163            td.additions, td.deletions
164        );
165    }
166    if d.metadata_changed {
167        out.push_str("   ~ metadata changed\n");
168    }
169}
170
171fn render_custom_attribute(out: &mut String, d: &CustomAttributeDiff) {
172    match &d.op {
173        CustomAttributeOp::DeprecationToggled { from, to } => {
174            let _ = writeln!(out, "   ~ deprecated: {from} โ†’ {to}");
175        }
176        CustomAttributeOp::UnregisteredInGit => {
177            out.push_str("   โš  exists in Braze but not in Git registry (run export)\n");
178        }
179        CustomAttributeOp::PresentInGitOnly => {
180            out.push_str("   โš  in Git registry but not in Braze (likely a typo)\n");
181        }
182        CustomAttributeOp::MetadataOnly => {
183            out.push_str("   ~ metadata-only change (no API to apply)\n");
184        }
185        CustomAttributeOp::Unchanged => {}
186    }
187    for hint in &d.hints {
188        let _ = writeln!(out, "   โ„น {hint}");
189    }
190}
191
192fn render_tag(out: &mut String, d: &crate::diff::tag::TagDiff) {
193    use crate::diff::tag::TagOp;
194    match &d.op {
195        TagOp::ReferencedButUnregistered => {
196            out.push_str(
197                "   โš  referenced by a resource but not in tags/registry.yaml \
198                 (apply will fail until added + created in Braze dashboard)\n",
199            );
200        }
201        TagOp::RegisteredButUnreferenced => {
202            out.push_str("   โ„น in tags/registry.yaml but no local resource references it\n");
203        }
204        TagOp::Unchanged => {}
205    }
206    for hint in &d.hints {
207        let _ = writeln!(out, "   โ„น {hint}");
208    }
209}