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::{CatalogItemsDiff, 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        return;
48    }
49
50    match diff {
51        ResourceDiff::CatalogSchema(d) => render_catalog_schema(out, d),
52        ResourceDiff::CatalogItems(d) => render_catalog_items(out, d),
53        ResourceDiff::ContentBlock(d) => render_content_block(out, d),
54        ResourceDiff::EmailTemplate(d) => render_email_template(out, d),
55        ResourceDiff::CustomAttribute(d) => render_custom_attribute(out, d),
56    }
57}
58
59fn kind_icon(kind: ResourceKind) -> &'static str {
60    match kind {
61        ResourceKind::CatalogSchema => "๐Ÿ“‹",
62        ResourceKind::CatalogItems => "๐Ÿ“ฆ",
63        ResourceKind::ContentBlock => "๐Ÿ“",
64        ResourceKind::EmailTemplate => "๐Ÿ“ง",
65        ResourceKind::CustomAttribute => "๐Ÿท ",
66    }
67}
68
69fn kind_label(kind: ResourceKind) -> &'static str {
70    match kind {
71        ResourceKind::CatalogSchema => "Catalog Schema",
72        ResourceKind::CatalogItems => "Catalog Items",
73        ResourceKind::ContentBlock => "Content Block",
74        ResourceKind::EmailTemplate => "Email Template",
75        ResourceKind::CustomAttribute => "Custom Attribute",
76    }
77}
78
79fn fmt_field(f: &CatalogField) -> String {
80    format!("{} ({})", f.name, f.field_type.as_str())
81}
82
83fn render_catalog_schema(out: &mut String, d: &CatalogSchemaDiff) {
84    if matches!(d.op, DiffOp::Added(_)) {
85        out.push_str("   + new catalog\n");
86    } else if matches!(d.op, DiffOp::Removed(_)) {
87        out.push_str("   - removed catalog (destructive)\n");
88    }
89    for fd in &d.field_diffs {
90        match fd {
91            DiffOp::Added(f) => {
92                let _ = writeln!(out, "   + field: {}", fmt_field(f));
93            }
94            DiffOp::Removed(f) => {
95                let _ = writeln!(out, "   - field: {}", fmt_field(f));
96            }
97            DiffOp::Modified { from, to } => {
98                let _ = writeln!(
99                    out,
100                    "   ~ field: {} ({} โ†’ {})",
101                    to.name,
102                    from.field_type.as_str(),
103                    to.field_type.as_str(),
104                );
105            }
106            DiffOp::Unchanged => {}
107        }
108    }
109}
110
111fn render_catalog_items(out: &mut String, d: &CatalogItemsDiff) {
112    let total = d.added_ids.len() + d.modified_ids.len() + d.removed_ids.len() + d.unchanged_count;
113    let _ = writeln!(
114        out,
115        "   + {} added, ~ {} modified, - {} removed (in {} total)",
116        d.added_ids.len(),
117        d.modified_ids.len(),
118        d.removed_ids.len(),
119        total,
120    );
121}
122
123fn render_content_block(out: &mut String, d: &ContentBlockDiff) {
124    if d.orphan {
125        out.push_str("   โš  orphaned (exists in Braze, not in Git)\n");
126        return;
127    }
128    match &d.op {
129        DiffOp::Added(_) => out.push_str("   + new content block\n"),
130        DiffOp::Removed(_) => out.push_str("   - removed content block\n"),
131        DiffOp::Modified { .. } => {
132            if let Some(td) = &d.text_diff {
133                let _ = writeln!(
134                    out,
135                    "   ~ content changed (+{} -{})",
136                    td.additions, td.deletions,
137                );
138            } else {
139                out.push_str("   ~ content changed\n");
140            }
141        }
142        DiffOp::Unchanged => {}
143    }
144}
145
146fn render_email_template(out: &mut String, d: &EmailTemplateDiff) {
147    if d.orphan {
148        out.push_str("   โš  orphaned (exists in Braze, not in Git)\n");
149        return;
150    }
151    if matches!(d.op, DiffOp::Added(_)) {
152        out.push_str("   + new email template\n");
153    } else if matches!(d.op, DiffOp::Removed(_)) {
154        out.push_str("   - removed email template\n");
155    }
156    if d.subject_changed {
157        out.push_str("   ~ subject changed\n");
158    }
159    if let Some(td) = &d.body_html_diff {
160        let _ = writeln!(
161            out,
162            "   ~ body_html changed (+{} -{})",
163            td.additions, td.deletions
164        );
165    }
166    if let Some(td) = &d.body_plaintext_diff {
167        let _ = writeln!(
168            out,
169            "   ~ body_plaintext changed (+{} -{})",
170            td.additions, td.deletions
171        );
172    }
173    if d.metadata_changed {
174        out.push_str("   ~ metadata changed\n");
175    }
176}
177
178fn render_custom_attribute(out: &mut String, d: &CustomAttributeDiff) {
179    match &d.op {
180        CustomAttributeOp::DeprecationToggled { from, to } => {
181            let _ = writeln!(out, "   ~ deprecated: {from} โ†’ {to}");
182        }
183        CustomAttributeOp::UnregisteredInGit => {
184            out.push_str("   โš  exists in Braze but not in Git registry (run export)\n");
185        }
186        CustomAttributeOp::PresentInGitOnly => {
187            out.push_str("   โš  in Git registry but not in Braze (likely a typo)\n");
188        }
189        CustomAttributeOp::MetadataOnly => {
190            out.push_str("   ~ metadata-only change (no API to apply)\n");
191        }
192        CustomAttributeOp::Unchanged => {}
193    }
194}