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 schema_preview: None,
195 };
196 plan.summary = Some(plan.summary());
197 plan
198 }
199
200 fn create_op(uid: u128, type_name: &str, slug: &str) -> Op {
201 Op::Create {
202 uid: Uid::from_u128(uid),
203 type_name: TypeName::new(type_name),
204 desired: make_object(uid, type_name, slug, make_attrs(&[("name", json!("X"))])),
205 }
206 }
207
208 fn update_op(uid: u128, type_name: &str, slug: &str, changes: Vec<FieldChange>) -> Op {
209 Op::Update {
210 uid: Uid::from_u128(uid),
211 type_name: TypeName::new(type_name),
212 desired: make_object(uid, type_name, slug, make_attrs(&[("name", json!("new"))])),
213 changes,
214 backend_id: Some(BackendId::Int(100)),
215 }
216 }
217
218 fn delete_op(uid: u128, type_name: &str, slug: &str) -> Op {
219 Op::Delete {
220 uid: Uid::from_u128(uid),
221 type_name: TypeName::new(type_name),
222 key: make_key(slug),
223 backend_id: Some(BackendId::Int(200)),
224 }
225 }
226
227 fn name_change() -> FieldChange {
228 FieldChange {
229 field: "name".to_string(),
230 from: json!("old"),
231 to: json!("new"),
232 }
233 }
234
235 #[test]
238 fn empty_plan_yields_empty_report() {
239 let report = DriftReport::from_plan(&plan_with(vec![]));
240 assert!(report.is_empty());
241 assert_eq!(report.len(), 0);
242 assert!(report.changed.is_empty());
243 assert!(report.missing.is_empty());
244 assert!(report.extra.is_empty());
245 }
246
247 #[test]
248 fn only_changed() {
249 let report = DriftReport::from_plan(&plan_with(vec![update_op(
250 1,
251 "dcim.site",
252 "fra1",
253 vec![name_change()],
254 )]));
255 assert!(!report.is_empty());
256 assert_eq!(report.len(), 1);
257 assert_eq!(report.changed.len(), 1);
258 assert!(report.missing.is_empty());
259 assert!(report.extra.is_empty());
260
261 let entry = &report.changed[0];
262 assert_eq!(entry.type_name, TypeName::new("dcim.site"));
263 assert_eq!(entry.key, make_key("fra1"));
264 assert_eq!(entry.changes.len(), 1);
265 assert_eq!(entry.changes[0].field, "name");
266 assert_eq!(entry.changes[0].from, json!("old"));
267 assert_eq!(entry.changes[0].to, json!("new"));
268 }
269
270 #[test]
271 fn only_missing() {
272 let report = DriftReport::from_plan(&plan_with(vec![create_op(1, "dcim.site", "ams1")]));
273 assert_eq!(report.len(), 1);
274 assert!(report.changed.is_empty());
275 assert_eq!(report.missing.len(), 1);
276 assert!(report.extra.is_empty());
277
278 let entry = &report.missing[0];
279 assert_eq!(entry.type_name, TypeName::new("dcim.site"));
280 assert_eq!(entry.key, make_key("ams1"));
281 }
282
283 #[test]
284 fn only_extra() {
285 let report =
286 DriftReport::from_plan(&plan_with(vec![delete_op(1, "dcim.device", "leaf01")]));
287 assert_eq!(report.len(), 1);
288 assert!(report.changed.is_empty());
289 assert!(report.missing.is_empty());
290 assert_eq!(report.extra.len(), 1);
291
292 let entry = &report.extra[0];
293 assert_eq!(entry.type_name, TypeName::new("dcim.device"));
294 assert_eq!(entry.key, make_key("leaf01"));
295 }
296
297 #[test]
298 fn mixed_categories() {
299 let report = DriftReport::from_plan(&plan_with(vec![
300 create_op(1, "dcim.site", "ams1"),
301 update_op(2, "dcim.site", "fra1", vec![name_change()]),
302 delete_op(3, "dcim.device", "leaf01"),
303 ]));
304 assert_eq!(report.len(), 3);
305 assert_eq!(report.changed.len(), 1);
306 assert_eq!(report.missing.len(), 1);
307 assert_eq!(report.extra.len(), 1);
308 }
309
310 #[test]
311 fn no_drift_when_plan_has_only_noop_updates() {
312 let report =
315 DriftReport::from_plan(&plan_with(vec![update_op(1, "dcim.site", "fra1", vec![])]));
316 assert_eq!(report.changed.len(), 1);
317 assert!(report.changed[0].changes.is_empty());
318 }
319
320 #[test]
323 fn display_empty_is_human_readable() {
324 let report = DriftReport::default();
325 assert_eq!(
326 report.to_string(),
327 "no drift: observed backend state matches declared intent"
328 );
329 }
330
331 #[test]
332 fn display_groups_by_category() {
333 let report = DriftReport::from_plan(&plan_with(vec![
334 create_op(1, "dcim.site", "ams1"),
335 update_op(2, "dcim.site", "fra1", vec![name_change()]),
336 delete_op(3, "dcim.device", "leaf01"),
337 ]));
338 let text = report.to_string();
339 assert!(text.starts_with("drift report: 1 changed, 1 missing, 1 extra"));
340 assert!(text.contains("changed (present but diverged):"));
341 assert!(text.contains("missing (declared in intent, absent from backend):"));
342 assert!(text.contains("extra (present in backend, not declared in intent):"));
343 assert!(text.contains("name: \"old\" -> \"new\""));
345 assert!(text.contains("dcim.device"));
347 assert!(text.contains("leaf01"));
348 assert!(!text.ends_with('\n'));
350 }
351
352 #[test]
353 fn display_omits_empty_categories() {
354 let report = DriftReport::from_plan(&plan_with(vec![create_op(1, "dcim.site", "ams1")]));
355 let text = report.to_string();
356 assert!(text.contains("missing (declared in intent, absent from backend):"));
357 assert!(!text.contains("changed (present but diverged):"));
358 assert!(!text.contains("extra (present in backend, not declared in intent):"));
359 }
360
361 #[test]
364 fn serializes_to_json() {
365 let report = DriftReport::from_plan(&plan_with(vec![
366 create_op(1, "dcim.site", "ams1"),
367 update_op(2, "dcim.site", "fra1", vec![name_change()]),
368 delete_op(3, "dcim.device", "leaf01"),
369 ]));
370 let value: serde_json::Value = serde_json::to_value(&report).unwrap();
371 assert_eq!(value["changed"].as_array().unwrap().len(), 1);
372 assert_eq!(value["missing"].as_array().unwrap().len(), 1);
373 assert_eq!(value["extra"].as_array().unwrap().len(), 1);
374 assert_eq!(value["changed"][0]["type_name"], "dcim.site");
375 assert_eq!(value["changed"][0]["changes"][0]["field"], "name");
376 assert_eq!(value["changed"][0]["changes"][0]["from"], "old");
377 assert_eq!(value["changed"][0]["changes"][0]["to"], "new");
378 assert_eq!(value["missing"][0]["key"]["slug"], "ams1");
379 assert_eq!(value["extra"][0]["key"]["slug"], "leaf01");
380 }
381}