Skip to main content

braze_sync/diff/
plan.rs

1//! Plan file schema and matching for `diff --plan-out` / `apply --plan`.
2//!
3//! The plan file freezes the set of actionable ops produced by `diff` so
4//! that `apply` can refuse to run when the live Braze state has drifted
5//! since the plan was generated (Terraform-style plan/apply lock).
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10
11use crate::diff::custom_attribute::CustomAttributeOp;
12use crate::diff::{DiffOp, DiffSummary, ResourceDiff};
13use crate::resource::ResourceKind;
14
15pub const CURRENT_PLAN_VERSION: u32 = 1;
16
17/// Warn at apply time when the saved plan is older than this.
18pub const STALE_PLAN_WARN_THRESHOLD: chrono::TimeDelta = chrono::TimeDelta::hours(24);
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PlanFile {
22    pub version: u32,
23    pub generated_at: DateTime<Utc>,
24    pub braze_sync_version: String,
25    pub scope: PlanScope,
26    pub ops: Vec<PlanOp>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30pub struct PlanScope {
31    pub environment: String,
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub resource: Option<ResourceKind>,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub name: Option<String>,
36    /// Whether the plan was generated with the intent to archive orphans
37    /// at apply time. `Orphan` ops only actually write when `apply
38    /// --archive-orphans` is set, so the apply flag must match the plan
39    /// scope or the frozen op set would not reflect the real write set.
40    #[serde(default)]
41    pub archive_orphans: bool,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
45pub struct PlanOp {
46    pub kind: ResourceKind,
47    pub name: String,
48    pub op: PlanOpType,
49}
50
51/// The coarse op classification used for plan locking. Field-level
52/// payloads are deliberately excluded so the plan file stays safe to
53/// publish as a CI artifact and so the apply-time comparison tolerates
54/// benign content edits made between plan and apply.
55#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
56#[serde(rename_all = "snake_case")]
57pub enum PlanOpType {
58    Add,
59    Modify,
60    DestructiveDelete,
61    Orphan,
62    Deprecate,
63    Reactivate,
64}
65
66impl PlanFile {
67    pub fn from_summary(
68        summary: &DiffSummary,
69        environment: impl Into<String>,
70        resource: Option<ResourceKind>,
71        name: Option<String>,
72        archive_orphans: bool,
73    ) -> Self {
74        Self {
75            version: CURRENT_PLAN_VERSION,
76            generated_at: Utc::now(),
77            braze_sync_version: env!("CARGO_PKG_VERSION").to_string(),
78            scope: PlanScope {
79                environment: environment.into(),
80                resource,
81                name,
82                archive_orphans,
83            },
84            ops: collect_ops(summary),
85        }
86    }
87
88    pub fn read_from(path: &Path) -> std::io::Result<Self> {
89        let bytes = std::fs::read(path)?;
90        serde_json::from_slice(&bytes)
91            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
92    }
93
94    pub fn write_to(&self, path: &Path) -> std::io::Result<()> {
95        let json = serde_json::to_vec_pretty(self)
96            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
97        std::fs::write(path, json)
98    }
99
100    /// Compare saved ops against `fresh` as a multiset over `(kind, name,
101    /// op_type)`, order-independent. `collect_ops` is invariant-by-design
102    /// unique per `(kind, name)` today, but we use a merge walk rather
103    /// than a set so duplicates would surface as drift instead of being
104    /// silently collapsed.
105    pub fn diff_ops(&self, fresh: &[PlanOp]) -> PlanOpsDiff {
106        let mut saved: Vec<&PlanOp> = self.ops.iter().collect();
107        let mut fresh_sorted: Vec<&PlanOp> = fresh.iter().collect();
108        saved.sort();
109        fresh_sorted.sort();
110        let (mut i, mut j) = (0, 0);
111        let mut missing = Vec::new();
112        let mut extra = Vec::new();
113        while i < saved.len() && j < fresh_sorted.len() {
114            match saved[i].cmp(fresh_sorted[j]) {
115                std::cmp::Ordering::Less => {
116                    missing.push(saved[i].clone());
117                    i += 1;
118                }
119                std::cmp::Ordering::Greater => {
120                    extra.push(fresh_sorted[j].clone());
121                    j += 1;
122                }
123                std::cmp::Ordering::Equal => {
124                    i += 1;
125                    j += 1;
126                }
127            }
128        }
129        missing.extend(saved[i..].iter().map(|&op| op.clone()));
130        extra.extend(fresh_sorted[j..].iter().map(|&op| op.clone()));
131        PlanOpsDiff { missing, extra }
132    }
133}
134
135/// Result of comparing a saved plan's ops against a freshly-computed list.
136#[derive(Debug, Default)]
137pub struct PlanOpsDiff {
138    /// In saved plan but not in fresh (resolved or absorbed remotely).
139    pub missing: Vec<PlanOp>,
140    /// In fresh but not in saved plan (new drift since plan).
141    pub extra: Vec<PlanOp>,
142}
143
144impl PlanOpsDiff {
145    pub fn is_match(&self) -> bool {
146        self.missing.is_empty() && self.extra.is_empty()
147    }
148}
149
150/// Convert a `DiffSummary` into the coarse plan-op list. Skips non-actionable
151/// diffs (Tag drift, Custom Attribute metadata-only, Unchanged) so the
152/// plan-lock vocabulary matches the set of operations apply can actually
153/// perform.
154pub fn collect_ops(summary: &DiffSummary) -> Vec<PlanOp> {
155    let mut out = Vec::new();
156    for diff in &summary.diffs {
157        let Some(op) = classify(diff) else { continue };
158        out.push(PlanOp {
159            kind: diff.kind(),
160            name: diff.name().to_string(),
161            op,
162        });
163    }
164    out.sort();
165    out
166}
167
168fn classify(diff: &ResourceDiff) -> Option<PlanOpType> {
169    match diff {
170        ResourceDiff::CatalogSchema(d) => match &d.op {
171            DiffOp::Added(_) => Some(PlanOpType::Add),
172            DiffOp::Removed(_) => Some(PlanOpType::DestructiveDelete),
173            DiffOp::Modified { .. } => Some(PlanOpType::Modify),
174            DiffOp::Unchanged => {
175                // Field-level edits with the catalog itself "unchanged".
176                if d.field_diffs.iter().any(|f| f.is_destructive()) {
177                    Some(PlanOpType::DestructiveDelete)
178                } else if d.field_diffs.iter().any(|f| f.is_change()) {
179                    Some(PlanOpType::Modify)
180                } else {
181                    None
182                }
183            }
184        },
185        ResourceDiff::ContentBlock(d) => classify_orphanable(d.orphan, &d.op),
186        ResourceDiff::EmailTemplate(d) => classify_orphanable(d.orphan, &d.op),
187        ResourceDiff::CustomAttribute(d) => match &d.op {
188            CustomAttributeOp::DeprecationToggled { to: true, .. } => Some(PlanOpType::Deprecate),
189            CustomAttributeOp::DeprecationToggled { to: false, .. } => Some(PlanOpType::Reactivate),
190            _ => None,
191        },
192        ResourceDiff::Tag(_) => None,
193    }
194}
195
196fn classify_orphanable<T>(orphan: bool, op: &DiffOp<T>) -> Option<PlanOpType> {
197    if orphan {
198        return Some(PlanOpType::Orphan);
199    }
200    match op {
201        DiffOp::Added(_) => Some(PlanOpType::Add),
202        DiffOp::Modified { .. } => Some(PlanOpType::Modify),
203        DiffOp::Removed(_) | DiffOp::Unchanged => None,
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    fn op(kind: ResourceKind, name: &str, op: PlanOpType) -> PlanOp {
212        PlanOp {
213            kind,
214            name: name.to_string(),
215            op,
216        }
217    }
218
219    #[test]
220    fn ops_match_is_order_independent() {
221        let plan = PlanFile {
222            version: 1,
223            generated_at: Utc::now(),
224            braze_sync_version: "test".into(),
225            scope: PlanScope {
226                environment: "dev".into(),
227                resource: None,
228                name: None,
229                archive_orphans: false,
230            },
231            ops: vec![
232                op(ResourceKind::ContentBlock, "a", PlanOpType::Modify),
233                op(ResourceKind::CatalogSchema, "x", PlanOpType::Add),
234            ],
235        };
236        let fresh = vec![
237            op(ResourceKind::CatalogSchema, "x", PlanOpType::Add),
238            op(ResourceKind::ContentBlock, "a", PlanOpType::Modify),
239        ];
240        assert!(plan.diff_ops(&fresh).is_match());
241    }
242
243    #[test]
244    fn ops_mismatch_when_op_kind_changes() {
245        let plan = PlanFile {
246            version: 1,
247            generated_at: Utc::now(),
248            braze_sync_version: "test".into(),
249            scope: PlanScope {
250                environment: "dev".into(),
251                resource: None,
252                name: None,
253                archive_orphans: false,
254            },
255            ops: vec![op(ResourceKind::ContentBlock, "a", PlanOpType::Modify)],
256        };
257        let fresh = vec![op(ResourceKind::ContentBlock, "a", PlanOpType::Orphan)];
258        let diff = plan.diff_ops(&fresh);
259        assert!(!diff.is_match());
260        assert_eq!(diff.missing.len(), 1);
261        assert_eq!(diff.extra.len(), 1);
262    }
263
264    #[test]
265    fn duplicate_ops_are_treated_as_multiset() {
266        // Hand-crafted: two identical ops on either side should match,
267        // but `n` on one side and `n+1` on the other should surface as
268        // a single extra (set semantics would collapse to a match).
269        let plan = PlanFile {
270            version: 1,
271            generated_at: Utc::now(),
272            braze_sync_version: "test".into(),
273            scope: PlanScope {
274                environment: "dev".into(),
275                resource: None,
276                name: None,
277                archive_orphans: false,
278            },
279            ops: vec![
280                op(ResourceKind::ContentBlock, "a", PlanOpType::Modify),
281                op(ResourceKind::ContentBlock, "a", PlanOpType::Modify),
282            ],
283        };
284        let fresh_dup = vec![
285            op(ResourceKind::ContentBlock, "a", PlanOpType::Modify),
286            op(ResourceKind::ContentBlock, "a", PlanOpType::Modify),
287        ];
288        assert!(plan.diff_ops(&fresh_dup).is_match());
289
290        let fresh_one = vec![op(ResourceKind::ContentBlock, "a", PlanOpType::Modify)];
291        let diff = plan.diff_ops(&fresh_one);
292        assert!(!diff.is_match(), "should detect missing duplicate");
293        assert_eq!(diff.missing.len(), 1);
294        assert!(diff.extra.is_empty());
295    }
296
297    #[test]
298    fn round_trip_json() {
299        let plan = PlanFile {
300            version: 1,
301            generated_at: "2026-05-18T12:34:56Z".parse().unwrap(),
302            braze_sync_version: "0.12.0".into(),
303            scope: PlanScope {
304                environment: "dev".into(),
305                resource: Some(ResourceKind::ContentBlock),
306                name: None,
307                archive_orphans: true,
308            },
309            ops: vec![op(ResourceKind::ContentBlock, "hero", PlanOpType::Add)],
310        };
311        let json = serde_json::to_string(&plan).unwrap();
312        let round: PlanFile = serde_json::from_str(&json).unwrap();
313        assert!(plan.diff_ops(&round.ops).is_match());
314        assert_eq!(round.scope, plan.scope);
315    }
316}