1use 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
18pub 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 #[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 #[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#[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 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#[derive(Debug, Default)]
154pub struct PlanOpsDiff {
155 pub missing: Vec<PlanOp>,
157 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
167pub 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 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 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}