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