1use 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
17pub 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 #[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#[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 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#[derive(Debug, Default)]
137pub struct PlanOpsDiff {
138 pub missing: Vec<PlanOp>,
140 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
150pub 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 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 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}