1use crate::types::{FieldChange, Op, Plan};
10use alembic_core::{key_string, Key, TypeName};
11use serde::Serialize;
12use std::fmt;
13
14#[derive(Debug, Clone, PartialEq, Serialize)]
16pub struct ChangedEntry {
17 pub type_name: TypeName,
19 pub key: Key,
21 pub changes: Vec<FieldChange>,
23}
24
25#[derive(Debug, Clone, PartialEq, Serialize)]
27pub struct DriftEntry {
28 pub type_name: TypeName,
30 pub key: Key,
32}
33
34#[derive(Debug, Clone, Default, PartialEq, Serialize)]
42pub struct DriftReport {
43 pub changed: Vec<ChangedEntry>,
45 pub missing: Vec<DriftEntry>,
47 pub extra: Vec<DriftEntry>,
49}
50
51impl DriftReport {
52 pub fn from_plan(plan: &Plan) -> Self {
57 let mut report = DriftReport::default();
58 for op in &plan.ops {
59 match op {
60 Op::Update {
61 type_name,
62 desired,
63 changes,
64 ..
65 } => report.changed.push(ChangedEntry {
66 type_name: type_name.clone(),
67 key: desired.key.clone(),
68 changes: changes.clone(),
69 }),
70 Op::Create {
71 type_name, desired, ..
72 } => report.missing.push(DriftEntry {
73 type_name: type_name.clone(),
74 key: desired.key.clone(),
75 }),
76 Op::Delete { type_name, key, .. } => report.extra.push(DriftEntry {
77 type_name: type_name.clone(),
78 key: key.clone(),
79 }),
80 }
81 }
82 report
83 }
84
85 pub fn len(&self) -> usize {
87 self.changed.len() + self.missing.len() + self.extra.len()
88 }
89
90 pub fn is_empty(&self) -> bool {
92 self.changed.is_empty() && self.missing.is_empty() && self.extra.is_empty()
93 }
94}
95
96impl From<&Plan> for DriftReport {
97 fn from(plan: &Plan) -> Self {
98 DriftReport::from_plan(plan)
99 }
100}
101
102impl fmt::Display for DriftReport {
103 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104 if self.is_empty() {
105 return write!(
106 f,
107 "no drift: observed backend state matches declared intent"
108 );
109 }
110
111 write!(
112 f,
113 "drift report: {} changed, {} missing, {} extra",
114 self.changed.len(),
115 self.missing.len(),
116 self.extra.len()
117 )?;
118
119 if !self.changed.is_empty() {
120 write!(f, "\n\nchanged (present but diverged):")?;
121 for entry in &self.changed {
122 write!(f, "\n {} {}", entry.type_name, key_string(&entry.key))?;
123 for change in &entry.changes {
124 write!(
125 f,
126 "\n {}: {} -> {}",
127 change.field, change.from, change.to
128 )?;
129 }
130 }
131 }
132
133 if !self.missing.is_empty() {
134 write!(f, "\n\nmissing (declared in intent, absent from backend):")?;
135 for entry in &self.missing {
136 write!(f, "\n {} {}", entry.type_name, key_string(&entry.key))?;
137 }
138 }
139
140 if !self.extra.is_empty() {
141 write!(f, "\n\nextra (present in backend, not declared in intent):")?;
142 for entry in &self.extra {
143 write!(f, "\n {} {}", entry.type_name, key_string(&entry.key))?;
144 }
145 }
146
147 Ok(())
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use crate::types::{BackendId, Op, Plan};
155 use alembic_core::{JsonMap, Key, Object, Schema, TypeName, Uid};
156 use serde_json::json;
157 use std::collections::BTreeMap;
158
159 fn make_key(slug: &str) -> Key {
160 let mut k = BTreeMap::new();
161 k.insert("slug".to_string(), json!(slug));
162 Key::from(k)
163 }
164
165 fn make_attrs(pairs: &[(&str, serde_json::Value)]) -> JsonMap {
166 let mut m = BTreeMap::new();
167 for (k, v) in pairs {
168 m.insert(k.to_string(), v.clone());
169 }
170 JsonMap::from(m)
171 }
172
173 fn make_object(uid: u128, type_name: &str, slug: &str, attrs: JsonMap) -> Object {
174 Object::new(
175 Uid::from_u128(uid),
176 TypeName::new(type_name),
177 make_key(slug),
178 attrs,
179 )
180 .unwrap()
181 }
182
183 fn empty_schema() -> Schema {
184 Schema {
185 types: BTreeMap::new(),
186 }
187 }
188
189 fn plan_with(ops: Vec<Op>) -> Plan {
190 let mut plan = Plan {
191 schema: empty_schema(),
192 ops,
193 summary: None,
194 };
195 plan.summary = Some(plan.summary());
196 plan
197 }
198
199 fn create_op(uid: u128, type_name: &str, slug: &str) -> Op {
200 Op::Create {
201 uid: Uid::from_u128(uid),
202 type_name: TypeName::new(type_name),
203 desired: make_object(uid, type_name, slug, make_attrs(&[("name", json!("X"))])),
204 }
205 }
206
207 fn update_op(uid: u128, type_name: &str, slug: &str, changes: Vec<FieldChange>) -> Op {
208 Op::Update {
209 uid: Uid::from_u128(uid),
210 type_name: TypeName::new(type_name),
211 desired: make_object(uid, type_name, slug, make_attrs(&[("name", json!("new"))])),
212 changes,
213 backend_id: Some(BackendId::Int(100)),
214 }
215 }
216
217 fn delete_op(uid: u128, type_name: &str, slug: &str) -> Op {
218 Op::Delete {
219 uid: Uid::from_u128(uid),
220 type_name: TypeName::new(type_name),
221 key: make_key(slug),
222 backend_id: Some(BackendId::Int(200)),
223 }
224 }
225
226 fn name_change() -> FieldChange {
227 FieldChange {
228 field: "name".to_string(),
229 from: json!("old"),
230 to: json!("new"),
231 }
232 }
233
234 #[test]
237 fn empty_plan_yields_empty_report() {
238 let report = DriftReport::from_plan(&plan_with(vec![]));
239 assert!(report.is_empty());
240 assert_eq!(report.len(), 0);
241 assert!(report.changed.is_empty());
242 assert!(report.missing.is_empty());
243 assert!(report.extra.is_empty());
244 }
245
246 #[test]
247 fn only_changed() {
248 let report = DriftReport::from_plan(&plan_with(vec![update_op(
249 1,
250 "dcim.site",
251 "fra1",
252 vec![name_change()],
253 )]));
254 assert!(!report.is_empty());
255 assert_eq!(report.len(), 1);
256 assert_eq!(report.changed.len(), 1);
257 assert!(report.missing.is_empty());
258 assert!(report.extra.is_empty());
259
260 let entry = &report.changed[0];
261 assert_eq!(entry.type_name, TypeName::new("dcim.site"));
262 assert_eq!(entry.key, make_key("fra1"));
263 assert_eq!(entry.changes.len(), 1);
264 assert_eq!(entry.changes[0].field, "name");
265 assert_eq!(entry.changes[0].from, json!("old"));
266 assert_eq!(entry.changes[0].to, json!("new"));
267 }
268
269 #[test]
270 fn only_missing() {
271 let report = DriftReport::from_plan(&plan_with(vec![create_op(1, "dcim.site", "ams1")]));
272 assert_eq!(report.len(), 1);
273 assert!(report.changed.is_empty());
274 assert_eq!(report.missing.len(), 1);
275 assert!(report.extra.is_empty());
276
277 let entry = &report.missing[0];
278 assert_eq!(entry.type_name, TypeName::new("dcim.site"));
279 assert_eq!(entry.key, make_key("ams1"));
280 }
281
282 #[test]
283 fn only_extra() {
284 let report =
285 DriftReport::from_plan(&plan_with(vec![delete_op(1, "dcim.device", "leaf01")]));
286 assert_eq!(report.len(), 1);
287 assert!(report.changed.is_empty());
288 assert!(report.missing.is_empty());
289 assert_eq!(report.extra.len(), 1);
290
291 let entry = &report.extra[0];
292 assert_eq!(entry.type_name, TypeName::new("dcim.device"));
293 assert_eq!(entry.key, make_key("leaf01"));
294 }
295
296 #[test]
297 fn mixed_categories() {
298 let report = DriftReport::from_plan(&plan_with(vec![
299 create_op(1, "dcim.site", "ams1"),
300 update_op(2, "dcim.site", "fra1", vec![name_change()]),
301 delete_op(3, "dcim.device", "leaf01"),
302 ]));
303 assert_eq!(report.len(), 3);
304 assert_eq!(report.changed.len(), 1);
305 assert_eq!(report.missing.len(), 1);
306 assert_eq!(report.extra.len(), 1);
307 }
308
309 #[test]
310 fn no_drift_when_plan_has_only_noop_updates() {
311 let report =
314 DriftReport::from_plan(&plan_with(vec![update_op(1, "dcim.site", "fra1", vec![])]));
315 assert_eq!(report.changed.len(), 1);
316 assert!(report.changed[0].changes.is_empty());
317 }
318
319 #[test]
320 fn from_ref_matches_from_plan() {
321 let plan = plan_with(vec![create_op(1, "dcim.site", "ams1")]);
322 let via_from: DriftReport = (&plan).into();
323 assert_eq!(via_from, DriftReport::from_plan(&plan));
324 }
325
326 #[test]
329 fn display_empty_is_human_readable() {
330 let report = DriftReport::default();
331 assert_eq!(
332 report.to_string(),
333 "no drift: observed backend state matches declared intent"
334 );
335 }
336
337 #[test]
338 fn display_groups_by_category() {
339 let report = DriftReport::from_plan(&plan_with(vec![
340 create_op(1, "dcim.site", "ams1"),
341 update_op(2, "dcim.site", "fra1", vec![name_change()]),
342 delete_op(3, "dcim.device", "leaf01"),
343 ]));
344 let text = report.to_string();
345 assert!(text.starts_with("drift report: 1 changed, 1 missing, 1 extra"));
346 assert!(text.contains("changed (present but diverged):"));
347 assert!(text.contains("missing (declared in intent, absent from backend):"));
348 assert!(text.contains("extra (present in backend, not declared in intent):"));
349 assert!(text.contains("name: \"old\" -> \"new\""));
351 assert!(text.contains("dcim.device"));
353 assert!(text.contains("leaf01"));
354 assert!(!text.ends_with('\n'));
356 }
357
358 #[test]
359 fn display_omits_empty_categories() {
360 let report = DriftReport::from_plan(&plan_with(vec![create_op(1, "dcim.site", "ams1")]));
361 let text = report.to_string();
362 assert!(text.contains("missing (declared in intent, absent from backend):"));
363 assert!(!text.contains("changed (present but diverged):"));
364 assert!(!text.contains("extra (present in backend, not declared in intent):"));
365 }
366
367 #[test]
370 fn serializes_to_json() {
371 let report = DriftReport::from_plan(&plan_with(vec![
372 create_op(1, "dcim.site", "ams1"),
373 update_op(2, "dcim.site", "fra1", vec![name_change()]),
374 delete_op(3, "dcim.device", "leaf01"),
375 ]));
376 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
377 assert_eq!(value["changed"].as_array().unwrap().len(), 1);
378 assert_eq!(value["missing"].as_array().unwrap().len(), 1);
379 assert_eq!(value["extra"].as_array().unwrap().len(), 1);
380 assert_eq!(value["changed"][0]["type_name"], "dcim.site");
381 assert_eq!(value["changed"][0]["changes"][0]["field"], "name");
382 assert_eq!(value["changed"][0]["changes"][0]["from"], "old");
383 assert_eq!(value["changed"][0]["changes"][0]["to"], "new");
384 assert_eq!(value["missing"][0]["key"]["slug"], "ams1");
385 assert_eq!(value["extra"][0]["key"]["slug"], "leaf01");
386 }
387}