Skip to main content

alembic_engine/
planner.rs

1//! diff and plan generation.
2
3use crate::state::StateStore;
4use crate::types::{FieldChange, ObservedState, Op, Plan};
5use alembic_core::{
6    key_string, uid_v5, FieldType, JsonMap, Key, Object, Schema, TypeName, TypeSchema, Uid,
7};
8use serde_json::Value;
9use std::cmp::Reverse;
10use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
11
12/// build a deterministic plan from desired and observed state.
13pub fn plan(
14    desired: &[Object],
15    observed: &ObservedState,
16    state: &StateStore,
17    schema: &alembic_core::Schema,
18    allow_delete: bool,
19) -> Plan {
20    let mut ops = Vec::new();
21    let mut matched = BTreeSet::new();
22    let mut backend_to_uid = BTreeMap::new();
23
24    for (type_name, mapping) in state.all_mappings() {
25        for (uid, backend_id) in mapping {
26            backend_to_uid.insert((type_name.clone(), backend_id.clone()), *uid);
27        }
28    }
29
30    let mut desired_sorted: Vec<&Object> = desired.iter().collect();
31    desired_sorted.sort_by_key(|a| op_sort_key(&a.type_name, &a.key));
32
33    for object in desired_sorted {
34        let observed_object = state
35            .backend_id(object.type_name.clone(), object.uid)
36            .and_then(|id| observed.by_backend_id.get(&(object.type_name.clone(), id)))
37            .or_else(|| {
38                observed
39                    .by_key
40                    .get(&(object.type_name.clone(), key_string(&object.key)))
41            });
42
43        if let Some(obs) = observed_object {
44            let type_schema = schema.types.get(object.type_name.as_str());
45            let changes = diff_object(obs, object, type_schema);
46            if !changes.is_empty() {
47                ops.push(Op::Update {
48                    uid: object.uid,
49                    type_name: object.type_name.clone(),
50                    desired: object.clone(),
51                    changes,
52                    backend_id: obs.backend_id.clone(),
53                });
54            }
55            if let Some(backend_id) = &obs.backend_id {
56                matched.insert((object.type_name.clone(), backend_id.clone()));
57            }
58        } else {
59            ops.push(Op::Create {
60                uid: object.uid,
61                type_name: object.type_name.clone(),
62                desired: object.clone(),
63            });
64        }
65    }
66
67    if allow_delete {
68        for ((type_name, backend_id), obs) in &observed.by_backend_id {
69            if matched.contains(&(type_name.clone(), backend_id.clone())) {
70                continue;
71            }
72            let uid = backend_to_uid
73                .get(&(type_name.clone(), backend_id.clone()))
74                .copied()
75                .unwrap_or_else(|| uid_v5(type_name.as_str(), &key_string(&obs.key)));
76            ops.push(Op::Delete {
77                uid,
78                type_name: type_name.clone(),
79                key: obs.key.clone(),
80                backend_id: Some(backend_id.clone()),
81            });
82        }
83    }
84
85    ops.sort_by_key(op_order_key);
86
87    let mut plan = Plan {
88        schema: schema.clone(),
89        ops,
90        summary: None,
91        schema_preview: None,
92    };
93    plan.summary = Some(plan.summary());
94    plan
95}
96
97/// compute field-level diffs for attrs. `type_schema` drives type-aware value
98/// comparison so a field's representation (int vs float, numeric string) does
99/// not produce a spurious update; an unknown field falls back to raw equality.
100fn diff_attrs(
101    existing: &JsonMap,
102    desired: &JsonMap,
103    type_schema: Option<&TypeSchema>,
104) -> Vec<FieldChange> {
105    let mut changes = Vec::new();
106    for (field, to) in desired.iter() {
107        let from = existing.get(field).cloned().unwrap_or(Value::Null);
108        let field_type = type_schema.and_then(|ts| {
109            ts.fields
110                .get(field)
111                .or_else(|| ts.key.get(field))
112                .map(|fs| &fs.r#type)
113        });
114        if !field_values_equal(field_type, &from, to) {
115            changes.push(FieldChange {
116                field: field.clone(),
117                from,
118                to: to.clone(),
119            });
120        }
121    }
122    changes
123}
124
125fn diff_object(
126    existing: &crate::types::ObservedObject,
127    desired: &Object,
128    type_schema: Option<&TypeSchema>,
129) -> Vec<FieldChange> {
130    diff_attrs(&existing.attrs, &desired.attrs, type_schema)
131}
132
133/// whether observed and desired values are equal for a field of the given
134/// declared type. numeric fields compare by value, so a backend that returns
135/// `1.0` (or `"1"`) for an int the inventory writes as `1` does not produce a
136/// perpetual update; list/map fields compare elementwise under the item/value
137/// type. every other type, and an unknown field, compares structurally.
138fn field_values_equal(field_type: Option<&FieldType>, from: &Value, to: &Value) -> bool {
139    match field_type {
140        Some(FieldType::Int) => match (value_as_int(from), value_as_int(to)) {
141            (Some(a), Some(b)) => a == b,
142            _ => from == to,
143        },
144        Some(FieldType::Float) => match (value_as_float(from), value_as_float(to)) {
145            (Some(a), Some(b)) => a == b,
146            _ => from == to,
147        },
148        Some(FieldType::List { item }) => match (from, to) {
149            (Value::Array(a), Value::Array(b)) => {
150                a.len() == b.len()
151                    && a.iter()
152                        .zip(b)
153                        .all(|(x, y)| field_values_equal(Some(item), x, y))
154            }
155            _ => from == to,
156        },
157        Some(FieldType::Map { value }) => match (from, to) {
158            (Value::Object(a), Value::Object(b)) => {
159                a.len() == b.len()
160                    && a.iter().all(|(k, x)| {
161                        b.get(k)
162                            .is_some_and(|y| field_values_equal(Some(value), x, y))
163                    })
164            }
165            _ => from == to,
166        },
167        _ => from == to,
168    }
169}
170
171/// interpret a value as an integer (json number or numeric string). `i128`
172/// covers the full `u64`/`i64` range exactly, unlike an `f64` round-trip.
173fn value_as_int(value: &Value) -> Option<i128> {
174    match value {
175        Value::Number(n) => n
176            .as_u64()
177            .map(i128::from)
178            .or_else(|| n.as_i64().map(i128::from))
179            .or_else(|| {
180                n.as_f64()
181                    .filter(|f| f.is_finite() && f.fract() == 0.0)
182                    .map(|f| f as i128)
183            }),
184        Value::String(s) => s.trim().parse::<i128>().ok(),
185        _ => None,
186    }
187}
188
189/// interpret a value as a float (json number or numeric string).
190fn value_as_float(value: &Value) -> Option<f64> {
191    match value {
192        Value::Number(n) => n.as_f64(),
193        Value::String(s) => s.trim().parse::<f64>().ok(),
194        _ => None,
195    }
196}
197
198/// stable sort key for desired objects.
199fn op_sort_key(type_name: &TypeName, key: &Key) -> (String, String) {
200    (type_name.as_str().to_string(), key_string(key))
201}
202
203/// deterministic ordering key for a plan op: (type name, op weight, key string).
204type OrderKey = (String, u8, String);
205
206/// stable sort key for plan operations.
207fn op_order_key(op: &Op) -> OrderKey {
208    let (type_name, key, weight) = match op {
209        Op::Create {
210            type_name, desired, ..
211        } => (type_name.clone(), key_string(&desired.key), 0u8),
212        Op::Update {
213            type_name, desired, ..
214        } => (type_name.clone(), key_string(&desired.key), 1u8),
215        Op::Delete { type_name, key, .. } => (type_name.clone(), key_string(key), 2u8),
216    };
217    (type_name.as_str().to_string(), weight, key)
218}
219
220/// collect the referenced uids carried by a value, recursing through list and
221/// map containers, using the field's schema to know where refs live.
222fn collect_refs_in_value(field_type: &FieldType, value: &Value, out: &mut BTreeSet<Uid>) {
223    match field_type {
224        FieldType::Ref { .. } => {
225            if let Some(uid) = value.as_str().and_then(|raw| Uid::parse_str(raw).ok()) {
226                out.insert(uid);
227            }
228        }
229        FieldType::ListRef { .. } => {
230            if let Value::Array(items) = value {
231                for item in items {
232                    if let Some(uid) = item.as_str().and_then(|raw| Uid::parse_str(raw).ok()) {
233                        out.insert(uid);
234                    }
235                }
236            }
237        }
238        FieldType::List { item } => {
239            if let Value::Array(items) = value {
240                for item_value in items {
241                    collect_refs_in_value(item, item_value, out);
242                }
243            }
244        }
245        FieldType::Map { value: inner } => {
246            if let Value::Object(map) = value {
247                for entry in map.values() {
248                    collect_refs_in_value(inner, entry, out);
249                }
250            }
251        }
252        _ => {}
253    }
254}
255
256/// collect every uid referenced by an object's attrs (or key) given its schema.
257/// fields are looked up in `fields` first, falling back to `key` so that
258/// reference-typed key components are picked up too.
259fn collect_referenced_uids(
260    type_schema: &TypeSchema,
261    attrs: &BTreeMap<String, Value>,
262) -> BTreeSet<Uid> {
263    let mut uids = BTreeSet::new();
264    for (field, value) in attrs {
265        if let Some(field_schema) = type_schema
266            .fields
267            .get(field)
268            .or_else(|| type_schema.key.get(field))
269        {
270            collect_refs_in_value(&field_schema.r#type, value, &mut uids);
271        }
272    }
273    uids
274}
275
276/// the uids an op depends on. creates/updates draw refs from the desired attrs
277/// and key; deletes only carry a key, so refs come from reference-typed key
278/// components (the op has no attrs to inspect).
279fn op_referenced_uids(op: &Op, schema: &Schema) -> BTreeSet<Uid> {
280    let Some(type_schema) = schema.types.get(op.type_name().as_str()) else {
281        return BTreeSet::new();
282    };
283    match op {
284        Op::Create { desired, .. } | Op::Update { desired, .. } => {
285            let mut uids = collect_referenced_uids(type_schema, &desired.attrs);
286            uids.extend(collect_referenced_uids(type_schema, &desired.key));
287            uids
288        }
289        Op::Delete { key, .. } => collect_referenced_uids(type_schema, key),
290    }
291}
292
293/// Kahn's algorithm with `op_order_key` as a deterministic tie-breaker.
294/// `edges[a]` holds the nodes that must come *after* node `a`. Nodes that
295/// remain in a reference cycle never reach in-degree zero; they are appended in
296/// stable `op_order_key` order so the result is always a total order (the
297/// apply_retry fixpoint resolves any residual ordering for them at apply time).
298fn stable_toposort(ops: &[&Op], edges: &[BTreeSet<usize>]) -> Vec<usize> {
299    let n = ops.len();
300    let keys: Vec<OrderKey> = ops.iter().map(|&op| op_order_key(op)).collect();
301
302    let mut indegree = vec![0usize; n];
303    for succs in edges {
304        for &b in succs {
305            indegree[b] += 1;
306        }
307    }
308
309    let mut ready: BinaryHeap<Reverse<(OrderKey, usize)>> = BinaryHeap::new();
310    for (i, &deg) in indegree.iter().enumerate() {
311        if deg == 0 {
312            ready.push(Reverse((keys[i].clone(), i)));
313        }
314    }
315
316    let mut order = Vec::with_capacity(n);
317    while let Some(Reverse((_, i))) = ready.pop() {
318        order.push(i);
319        for &b in &edges[i] {
320            indegree[b] -= 1;
321            if indegree[b] == 0 {
322                ready.push(Reverse((keys[b].clone(), b)));
323            }
324        }
325    }
326
327    // cyclic remainder: nodes that never reached in-degree zero. fall back to
328    // stable ordering for them so we still emit a total order without panicking.
329    if order.len() < n {
330        let mut placed = vec![false; n];
331        for &i in &order {
332            placed[i] = true;
333        }
334        let mut remaining: Vec<usize> = (0..n).filter(|&i| !placed[i]).collect();
335        remaining.sort_by(|&a, &b| keys[a].cmp(&keys[b]).then(a.cmp(&b)));
336        order.extend(remaining);
337    }
338
339    order
340}
341
342/// order ops by reference dependency. when `reverse` is false (creates/updates)
343/// an op is placed after the ops that create the uids it references; when true
344/// (deletes) an op is placed before the ops it references, so an object is
345/// removed only after everything referencing it.
346fn ordered_by_refs(ops: &[&Op], schema: &Schema, reverse: bool) -> Vec<Op> {
347    let uid_to_node: BTreeMap<Uid, usize> = ops
348        .iter()
349        .enumerate()
350        .map(|(i, op)| (op.uid(), i))
351        .collect();
352
353    let mut edges: Vec<BTreeSet<usize>> = vec![BTreeSet::new(); ops.len()];
354    for (i, &op) in ops.iter().enumerate() {
355        for referenced in op_referenced_uids(op, schema) {
356            let Some(&j) = uid_to_node.get(&referenced) else {
357                continue;
358            };
359            if i == j {
360                continue;
361            }
362            if reverse {
363                edges[i].insert(j); // i references j -> delete i before j
364            } else {
365                edges[j].insert(i); // i references j -> create j before i
366            }
367        }
368    }
369
370    stable_toposort(ops, &edges)
371        .into_iter()
372        .map(|i| ops[i].clone())
373        .collect()
374}
375
376/// order operations for apply: creates/updates first (topologically sorted so
377/// referenced objects are created before the objects that reference them),
378/// then deletes (reverse-toposorted so an object is deleted only after
379/// everything referencing it). reference cycles fall back to a stable order.
380pub fn sort_ops_for_apply(ops: &[Op], schema: &Schema) -> Vec<Op> {
381    let (non_deletes, deletes): (Vec<&Op>, Vec<&Op>) =
382        ops.iter().partition(|op| !matches!(op, Op::Delete { .. }));
383
384    let mut result = ordered_by_refs(&non_deletes, schema, false);
385    result.extend(ordered_by_refs(&deletes, schema, true));
386    result
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::state::{StateData, StateStore};
393    use crate::types::{BackendId, ObservedObject, ObservedState};
394    use alembic_core::{
395        FieldSchema, FieldType, JsonMap, Key, Object, Schema, TypeName, TypeSchema, Uid,
396    };
397    use serde_json::json;
398    use std::collections::BTreeMap;
399
400    fn make_key(slug: &str) -> Key {
401        let mut k = BTreeMap::new();
402        k.insert("slug".to_string(), json!(slug));
403        Key::from(k)
404    }
405
406    fn make_attrs(pairs: &[(&str, serde_json::Value)]) -> JsonMap {
407        let mut m = BTreeMap::new();
408        for (k, v) in pairs {
409            m.insert(k.to_string(), v.clone());
410        }
411        JsonMap::from(m)
412    }
413
414    fn make_object(uid: u128, type_name: &str, slug: &str, attrs: JsonMap) -> Object {
415        Object::new(
416            Uid::from_u128(uid),
417            TypeName::new(type_name),
418            make_key(slug),
419            attrs,
420        )
421        .unwrap()
422    }
423
424    fn empty_schema() -> Schema {
425        Schema {
426            types: BTreeMap::new(),
427        }
428    }
429
430    fn empty_state() -> StateStore {
431        StateStore::new(None, StateData::default())
432    }
433
434    // --- diff_attrs ---
435
436    #[test]
437    fn diff_attrs_identical_maps() {
438        let attrs = make_attrs(&[("name", json!("FRA1"))]);
439        let changes = diff_attrs(&attrs, &attrs, None);
440        assert!(changes.is_empty());
441    }
442
443    fn int_field_schema(name: &str) -> TypeSchema {
444        TypeSchema {
445            key: BTreeMap::new(),
446            fields: BTreeMap::from([(name.to_string(), field(FieldType::Int))]),
447        }
448    }
449
450    #[test]
451    fn diff_attrs_int_field_ignores_int_float_representation() {
452        let existing = make_attrs(&[("count", json!(3.0))]);
453        let desired = make_attrs(&[("count", json!(3))]);
454        let changes = diff_attrs(&existing, &desired, Some(&int_field_schema("count")));
455        assert!(changes.is_empty(), "{changes:?}");
456    }
457
458    #[test]
459    fn diff_attrs_int_field_ignores_numeric_string() {
460        let existing = make_attrs(&[("count", json!("3"))]);
461        let desired = make_attrs(&[("count", json!(3))]);
462        let changes = diff_attrs(&existing, &desired, Some(&int_field_schema("count")));
463        assert!(changes.is_empty(), "{changes:?}");
464    }
465
466    #[test]
467    fn diff_attrs_int_field_still_detects_real_change() {
468        let existing = make_attrs(&[("count", json!(3))]);
469        let desired = make_attrs(&[("count", json!(4))]);
470        let changes = diff_attrs(&existing, &desired, Some(&int_field_schema("count")));
471        assert_eq!(changes.len(), 1);
472    }
473
474    #[test]
475    fn diff_attrs_without_schema_compares_raw() {
476        // no type info: 3.0 vs 3 differ structurally (unchanged behavior).
477        let existing = make_attrs(&[("count", json!(3.0))]);
478        let desired = make_attrs(&[("count", json!(3))]);
479        let changes = diff_attrs(&existing, &desired, None);
480        assert_eq!(changes.len(), 1);
481    }
482
483    #[test]
484    fn diff_attrs_float_field_ignores_int_float_representation() {
485        let ts = TypeSchema {
486            key: BTreeMap::new(),
487            fields: BTreeMap::from([("ratio".to_string(), field(FieldType::Float))]),
488        };
489        let existing = make_attrs(&[("ratio", json!(2))]);
490        let desired = make_attrs(&[("ratio", json!(2.0))]);
491        let changes = diff_attrs(&existing, &desired, Some(&ts));
492        assert!(changes.is_empty(), "{changes:?}");
493    }
494
495    #[test]
496    fn diff_attrs_list_of_int_ignores_representation() {
497        let ts = TypeSchema {
498            key: BTreeMap::new(),
499            fields: BTreeMap::from([(
500                "ports".to_string(),
501                field(FieldType::List {
502                    item: Box::new(FieldType::Int),
503                }),
504            )]),
505        };
506        let existing = make_attrs(&[("ports", json!([1.0, 2.0]))]);
507        let desired = make_attrs(&[("ports", json!([1, 2]))]);
508        let changes = diff_attrs(&existing, &desired, Some(&ts));
509        assert!(changes.is_empty(), "{changes:?}");
510        // a genuine element change is still detected.
511        let desired2 = make_attrs(&[("ports", json!([1, 9]))]);
512        assert_eq!(diff_attrs(&existing, &desired2, Some(&ts)).len(), 1);
513    }
514
515    #[test]
516    fn diff_attrs_field_changed() {
517        let existing = make_attrs(&[("name", json!("FRA1"))]);
518        let desired = make_attrs(&[("name", json!("FRA2"))]);
519        let changes = diff_attrs(&existing, &desired, None);
520        assert_eq!(changes.len(), 1);
521        assert_eq!(changes[0].field, "name");
522        assert_eq!(changes[0].from, json!("FRA1"));
523        assert_eq!(changes[0].to, json!("FRA2"));
524    }
525
526    #[test]
527    fn diff_attrs_field_added() {
528        let existing = make_attrs(&[]);
529        let desired = make_attrs(&[("name", json!("FRA1"))]);
530        let changes = diff_attrs(&existing, &desired, None);
531        assert_eq!(changes.len(), 1);
532        assert_eq!(changes[0].field, "name");
533        assert_eq!(changes[0].from, json!(null));
534        assert_eq!(changes[0].to, json!("FRA1"));
535    }
536
537    #[test]
538    fn diff_attrs_field_removed_in_desired_is_ignored() {
539        let existing = make_attrs(&[("name", json!("FRA1")), ("extra", json!(true))]);
540        let desired = make_attrs(&[("name", json!("FRA1"))]);
541        let changes = diff_attrs(&existing, &desired, None);
542        assert!(changes.is_empty());
543    }
544
545    #[test]
546    fn diff_attrs_multiple_changes() {
547        let existing = make_attrs(&[("a", json!(1)), ("b", json!(2))]);
548        let desired = make_attrs(&[("a", json!(10)), ("b", json!(20))]);
549        let changes = diff_attrs(&existing, &desired, None);
550        assert_eq!(changes.len(), 2);
551        let fields: Vec<&str> = changes.iter().map(|c| c.field.as_str()).collect();
552        assert!(fields.contains(&"a"));
553        assert!(fields.contains(&"b"));
554    }
555
556    // --- plan() ---
557
558    #[test]
559    fn plan_creates_for_new_objects() {
560        let desired = vec![make_object(
561            1,
562            "dcim.site",
563            "fra1",
564            make_attrs(&[("name", json!("FRA1"))]),
565        )];
566        let observed = ObservedState::default();
567        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
568        assert_eq!(result.ops.len(), 1);
569        assert!(matches!(&result.ops[0], Op::Create { uid, type_name, .. }
570            if *uid == Uid::from_u128(1) && type_name.as_str() == "dcim.site"));
571        let summary = result.summary.unwrap();
572        assert_eq!(summary.create, 1);
573        assert_eq!(summary.update, 0);
574        assert_eq!(summary.delete, 0);
575    }
576
577    #[test]
578    fn plan_updates_when_attrs_differ() {
579        let desired = vec![make_object(
580            1,
581            "dcim.site",
582            "fra1",
583            make_attrs(&[("name", json!("FRA2"))]),
584        )];
585        let mut observed = ObservedState::default();
586        observed
587            .insert(ObservedObject {
588                type_name: TypeName::new("dcim.site"),
589                key: make_key("fra1"),
590                attrs: make_attrs(&[("name", json!("FRA1"))]),
591                backend_id: Some(BackendId::Int(100)),
592            })
593            .unwrap();
594        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
595        assert_eq!(result.ops.len(), 1);
596        match &result.ops[0] {
597            Op::Update {
598                changes,
599                backend_id,
600                ..
601            } => {
602                assert_eq!(changes.len(), 1);
603                assert_eq!(changes[0].field, "name");
604                assert_eq!(backend_id, &Some(BackendId::Int(100)));
605            }
606            other => panic!("expected Update, got {:?}", other),
607        }
608    }
609
610    #[test]
611    fn plan_no_op_when_identical() {
612        let desired = vec![make_object(
613            1,
614            "dcim.site",
615            "fra1",
616            make_attrs(&[("name", json!("FRA1"))]),
617        )];
618        let mut observed = ObservedState::default();
619        observed
620            .insert(ObservedObject {
621                type_name: TypeName::new("dcim.site"),
622                key: make_key("fra1"),
623                attrs: make_attrs(&[("name", json!("FRA1"))]),
624                backend_id: Some(BackendId::Int(100)),
625            })
626            .unwrap();
627        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
628        assert!(result.ops.is_empty());
629    }
630
631    #[test]
632    fn plan_deletes_unmatched_when_allowed() {
633        let desired = vec![];
634        let mut observed = ObservedState::default();
635        observed
636            .insert(ObservedObject {
637                type_name: TypeName::new("dcim.site"),
638                key: make_key("fra1"),
639                attrs: make_attrs(&[("name", json!("FRA1"))]),
640                backend_id: Some(BackendId::Int(100)),
641            })
642            .unwrap();
643        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), true);
644        assert_eq!(result.ops.len(), 1);
645        assert!(matches!(
646            &result.ops[0],
647            Op::Delete {
648                backend_id: Some(BackendId::Int(100)),
649                ..
650            }
651        ));
652    }
653
654    #[test]
655    fn plan_no_deletes_when_disallowed() {
656        let desired = vec![];
657        let mut observed = ObservedState::default();
658        observed
659            .insert(ObservedObject {
660                type_name: TypeName::new("dcim.site"),
661                key: make_key("fra1"),
662                attrs: make_attrs(&[("name", json!("FRA1"))]),
663                backend_id: Some(BackendId::Int(100)),
664            })
665            .unwrap();
666        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), false);
667        assert!(result.ops.is_empty());
668    }
669
670    #[test]
671    fn plan_matched_objects_not_deleted() {
672        let desired = vec![make_object(
673            1,
674            "dcim.site",
675            "fra1",
676            make_attrs(&[("name", json!("FRA1"))]),
677        )];
678        let mut observed = ObservedState::default();
679        observed
680            .insert(ObservedObject {
681                type_name: TypeName::new("dcim.site"),
682                key: make_key("fra1"),
683                attrs: make_attrs(&[("name", json!("FRA1"))]),
684                backend_id: Some(BackendId::Int(100)),
685            })
686            .unwrap();
687        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), true);
688        assert!(result.ops.is_empty());
689    }
690
691    #[test]
692    fn plan_deletes_cross_type_id_collision() {
693        // both types carry backend id 100; declaring only the site must not
694        // suppress the undeclared device's delete.
695        let desired = vec![make_object(
696            1,
697            "dcim.site",
698            "fra1",
699            make_attrs(&[("name", json!("FRA1"))]),
700        )];
701        let mut observed = ObservedState::default();
702        observed
703            .insert(ObservedObject {
704                type_name: TypeName::new("dcim.site"),
705                key: make_key("fra1"),
706                attrs: make_attrs(&[("name", json!("FRA1"))]),
707                backend_id: Some(BackendId::Int(100)),
708            })
709            .unwrap();
710        observed
711            .insert(ObservedObject {
712                type_name: TypeName::new("dcim.device"),
713                key: make_key("leaf01"),
714                attrs: make_attrs(&[]),
715                backend_id: Some(BackendId::Int(100)),
716            })
717            .unwrap();
718        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), true);
719        let deletes: Vec<&Op> = result
720            .ops
721            .iter()
722            .filter(|op| matches!(op, Op::Delete { .. }))
723            .collect();
724        assert_eq!(deletes.len(), 1);
725        assert!(matches!(
726            deletes[0],
727            Op::Delete { type_name, .. } if type_name.as_str() == "dcim.device"
728        ));
729    }
730
731    #[test]
732    fn plan_mixed_create_update_delete() {
733        let desired = vec![
734            make_object(
735                1,
736                "dcim.site",
737                "fra1",
738                make_attrs(&[("name", json!("FRA1-new"))]),
739            ),
740            make_object(
741                2,
742                "dcim.site",
743                "ams1",
744                make_attrs(&[("name", json!("AMS1"))]),
745            ),
746        ];
747        let mut observed = ObservedState::default();
748        observed
749            .insert(ObservedObject {
750                type_name: TypeName::new("dcim.site"),
751                key: make_key("fra1"),
752                attrs: make_attrs(&[("name", json!("FRA1"))]),
753                backend_id: Some(BackendId::Int(100)),
754            })
755            .unwrap();
756        observed
757            .insert(ObservedObject {
758                type_name: TypeName::new("dcim.site"),
759                key: make_key("lhr1"),
760                attrs: make_attrs(&[("name", json!("LHR1"))]),
761                backend_id: Some(BackendId::Int(200)),
762            })
763            .unwrap();
764        let result = plan(&desired, &observed, &empty_state(), &empty_schema(), true);
765
766        let creates: Vec<_> = result
767            .ops
768            .iter()
769            .filter(|op| matches!(op, Op::Create { .. }))
770            .collect();
771        let updates: Vec<_> = result
772            .ops
773            .iter()
774            .filter(|op| matches!(op, Op::Update { .. }))
775            .collect();
776        let deletes: Vec<_> = result
777            .ops
778            .iter()
779            .filter(|op| matches!(op, Op::Delete { .. }))
780            .collect();
781        assert_eq!(creates.len(), 1);
782        assert_eq!(updates.len(), 1);
783        assert_eq!(deletes.len(), 1);
784
785        let summary = result.summary.unwrap();
786        assert_eq!(summary.create, 1);
787        assert_eq!(summary.update, 1);
788        assert_eq!(summary.delete, 1);
789    }
790
791    #[test]
792    fn plan_uses_state_mapping_for_lookup() {
793        let mut state_data = StateData::default();
794        state_data
795            .mappings
796            .entry(TypeName::new("dcim.site"))
797            .or_default()
798            .insert(Uid::from_u128(1), BackendId::Int(100));
799        let state = StateStore::new(None, state_data);
800
801        let desired = vec![make_object(
802            1,
803            "dcim.site",
804            "fra1",
805            make_attrs(&[("name", json!("FRA2"))]),
806        )];
807        let mut observed = ObservedState::default();
808        observed
809            .insert(ObservedObject {
810                type_name: TypeName::new("dcim.site"),
811                key: make_key("fra1"),
812                attrs: make_attrs(&[("name", json!("FRA1"))]),
813                backend_id: Some(BackendId::Int(100)),
814            })
815            .unwrap();
816        let result = plan(&desired, &observed, &state, &empty_schema(), false);
817        assert_eq!(result.ops.len(), 1);
818        assert!(matches!(&result.ops[0], Op::Update { .. }));
819    }
820
821    #[test]
822    fn plan_matches_renamed_object_by_backend_id() {
823        let mut state_data = StateData::default();
824        state_data
825            .mappings
826            .entry(TypeName::new("dcim.site"))
827            .or_default()
828            .insert(Uid::from_u128(1), BackendId::Int(100));
829        let state = StateStore::new(None, state_data);
830
831        let desired = vec![make_object(
832            1,
833            "dcim.site",
834            "fra1-renamed",
835            make_attrs(&[("name", json!("FRA2"))]),
836        )];
837        let mut observed = ObservedState::default();
838        observed
839            .insert(ObservedObject {
840                type_name: TypeName::new("dcim.site"),
841                key: make_key("fra1-old"),
842                attrs: make_attrs(&[("name", json!("FRA1"))]),
843                backend_id: Some(BackendId::Int(100)),
844            })
845            .unwrap();
846        let result = plan(&desired, &observed, &state, &empty_schema(), true);
847        assert_eq!(result.ops.len(), 1);
848        assert!(matches!(
849            &result.ops[0],
850            Op::Update {
851                backend_id: Some(BackendId::Int(100)),
852                ..
853            }
854        ));
855    }
856
857    #[test]
858    fn plan_delete_uses_tracked_uid() {
859        let mut state_data = StateData::default();
860        state_data
861            .mappings
862            .entry(TypeName::new("dcim.site"))
863            .or_default()
864            .insert(Uid::from_u128(42), BackendId::Int(100));
865        let state = StateStore::new(None, state_data);
866
867        let mut observed = ObservedState::default();
868        observed
869            .insert(ObservedObject {
870                type_name: TypeName::new("dcim.site"),
871                key: make_key("fra1"),
872                attrs: make_attrs(&[]),
873                backend_id: Some(BackendId::Int(100)),
874            })
875            .unwrap();
876
877        let result = plan(&[], &observed, &state, &empty_schema(), true);
878        assert_eq!(result.ops.len(), 1);
879        match &result.ops[0] {
880            Op::Delete { uid, .. } => {
881                assert_eq!(*uid, Uid::from_u128(42));
882                assert_ne!(*uid, uid_v5("dcim.site", &key_string(&make_key("fra1"))));
883            }
884            other => panic!("expected delete, got {other:?}"),
885        }
886    }
887
888    // --- sort_ops_for_apply ---
889
890    #[test]
891    fn sort_ops_creates_before_deletes() {
892        let ops = vec![
893            Op::Delete {
894                uid: Uid::from_u128(1),
895                type_name: TypeName::new("dcim.site"),
896                key: make_key("fra1"),
897                backend_id: Some(BackendId::Int(100)),
898            },
899            Op::Create {
900                uid: Uid::from_u128(2),
901                type_name: TypeName::new("dcim.site"),
902                desired: make_object(2, "dcim.site", "ams1", make_attrs(&[])),
903            },
904        ];
905        let sorted = sort_ops_for_apply(&ops, &empty_schema());
906        assert!(matches!(&sorted[0], Op::Create { .. }));
907        assert!(matches!(&sorted[1], Op::Delete { .. }));
908    }
909
910    #[test]
911    fn sort_ops_deletes_last() {
912        let ops = vec![
913            Op::Delete {
914                uid: Uid::from_u128(1),
915                type_name: TypeName::new("a.type"),
916                key: make_key("a"),
917                backend_id: None,
918            },
919            Op::Delete {
920                uid: Uid::from_u128(2),
921                type_name: TypeName::new("z.type"),
922                key: make_key("z"),
923                backend_id: None,
924            },
925        ];
926        let sorted = sort_ops_for_apply(&ops, &empty_schema());
927        // both deletes come last, sorted alphabetically by type
928        assert!(
929            matches!(&sorted[0], Op::Delete { type_name, .. } if type_name.as_str() == "a.type")
930        );
931        assert!(
932            matches!(&sorted[1], Op::Delete { type_name, .. } if type_name.as_str() == "z.type")
933        );
934    }
935
936    #[test]
937    fn sort_ops_empty_input() {
938        let sorted = sort_ops_for_apply(&[], &empty_schema());
939        assert!(sorted.is_empty());
940    }
941
942    #[test]
943    fn sort_ops_preserves_create_update_order() {
944        let ops = vec![
945            Op::Update {
946                uid: Uid::from_u128(2),
947                type_name: TypeName::new("dcim.site"),
948                desired: make_object(2, "dcim.site", "ams1", make_attrs(&[])),
949                changes: vec![],
950                backend_id: None,
951            },
952            Op::Create {
953                uid: Uid::from_u128(1),
954                type_name: TypeName::new("dcim.site"),
955                desired: make_object(1, "dcim.site", "aaa1", make_attrs(&[])),
956            },
957        ];
958        let sorted = sort_ops_for_apply(&ops, &empty_schema());
959        assert!(matches!(&sorted[0], Op::Create { .. }));
960        assert!(matches!(&sorted[1], Op::Update { .. }));
961    }
962
963    // --- sort_ops_for_apply: topological ordering by reference (issue #47) ---
964
965    fn field(r#type: FieldType) -> FieldSchema {
966        FieldSchema {
967            r#type,
968            required: false,
969            nullable: true,
970            format: None,
971            pattern: None,
972            description: None,
973        }
974    }
975
976    fn ref_t() -> FieldType {
977        FieldType::Ref {
978            target: "node".to_string(),
979        }
980    }
981
982    fn list_ref_t() -> FieldType {
983        FieldType::ListRef {
984            target: "node".to_string(),
985        }
986    }
987
988    /// schema with a single type `node` keyed by `slug` plus the given fields.
989    fn node_schema(fields: &[(&str, FieldType)]) -> Schema {
990        let mut field_map = BTreeMap::new();
991        for (name, ty) in fields {
992            field_map.insert(name.to_string(), field(ty.clone()));
993        }
994        let mut key = BTreeMap::new();
995        key.insert("slug".to_string(), field(FieldType::Slug));
996        let mut types = BTreeMap::new();
997        types.insert(
998            "node".to_string(),
999            TypeSchema {
1000                key,
1001                fields: field_map,
1002            },
1003        );
1004        Schema { types }
1005    }
1006
1007    /// a uuid string ref value for the given uid.
1008    fn uref(uid: u128) -> serde_json::Value {
1009        json!(Uid::from_u128(uid).to_string())
1010    }
1011
1012    fn create_node(uid: u128, slug: &str, attrs: JsonMap) -> Op {
1013        Op::Create {
1014            uid: Uid::from_u128(uid),
1015            type_name: TypeName::new("node"),
1016            desired: make_object(uid, "node", slug, attrs),
1017        }
1018    }
1019
1020    /// position of the op carrying `uid` in the ordered output.
1021    fn order_index(ops: &[Op], uid: u128) -> usize {
1022        ops.iter()
1023            .position(|op| op.uid().as_u128() == uid)
1024            .expect("uid present in ordered ops")
1025    }
1026
1027    #[test]
1028    fn toposort_linear_ref_chain() {
1029        let schema = node_schema(&[("next", ref_t())]);
1030        let ops = vec![
1031            create_node(1, "a", make_attrs(&[("next", uref(2))])),
1032            create_node(2, "b", make_attrs(&[("next", uref(3))])),
1033            create_node(3, "c", make_attrs(&[])),
1034        ];
1035        let sorted = sort_ops_for_apply(&ops, &schema);
1036        // referenced objects are created before the objects referencing them.
1037        assert!(order_index(&sorted, 3) < order_index(&sorted, 2));
1038        assert!(order_index(&sorted, 2) < order_index(&sorted, 1));
1039    }
1040
1041    #[test]
1042    fn toposort_diamond_shared_dep() {
1043        let schema = node_schema(&[("parent", ref_t()), ("left", ref_t()), ("right", ref_t())]);
1044        let ops = vec![
1045            create_node(1, "d", make_attrs(&[("left", uref(2)), ("right", uref(3))])),
1046            create_node(2, "b", make_attrs(&[("parent", uref(4))])),
1047            create_node(3, "c", make_attrs(&[("parent", uref(4))])),
1048            create_node(4, "a", make_attrs(&[])),
1049        ];
1050        let sorted = sort_ops_for_apply(&ops, &schema);
1051        let (a, b, c, d) = (
1052            order_index(&sorted, 4),
1053            order_index(&sorted, 2),
1054            order_index(&sorted, 3),
1055            order_index(&sorted, 1),
1056        );
1057        assert!(a < b && a < c, "shared dep created first");
1058        assert!(b < d && c < d, "both branches created before the joiner");
1059    }
1060
1061    #[test]
1062    fn toposort_list_ref_fan_out() {
1063        let schema = node_schema(&[("members", list_ref_t())]);
1064        let ops = vec![
1065            create_node(
1066                1,
1067                "a",
1068                make_attrs(&[("members", json!([uref(2), uref(3), uref(4)]))]),
1069            ),
1070            create_node(2, "b", make_attrs(&[])),
1071            create_node(3, "c", make_attrs(&[])),
1072            create_node(4, "d", make_attrs(&[])),
1073        ];
1074        let sorted = sort_ops_for_apply(&ops, &schema);
1075        let a = order_index(&sorted, 1);
1076        assert!(order_index(&sorted, 2) < a);
1077        assert!(order_index(&sorted, 3) < a);
1078        assert!(order_index(&sorted, 4) < a);
1079    }
1080
1081    #[test]
1082    fn toposort_refs_nested_in_list_and_map() {
1083        let schema = node_schema(&[
1084            (
1085                "group",
1086                FieldType::List {
1087                    item: Box::new(ref_t()),
1088                },
1089            ),
1090            (
1091                "lookup",
1092                FieldType::Map {
1093                    value: Box::new(ref_t()),
1094                },
1095            ),
1096        ]);
1097        let ops = vec![
1098            create_node(
1099                1,
1100                "a",
1101                make_attrs(&[
1102                    ("group", json!([uref(2)])),
1103                    ("lookup", json!({ "k": uref(3) })),
1104                ]),
1105            ),
1106            create_node(2, "b", make_attrs(&[])),
1107            create_node(3, "c", make_attrs(&[])),
1108        ];
1109        let sorted = sort_ops_for_apply(&ops, &schema);
1110        let a = order_index(&sorted, 1);
1111        assert!(order_index(&sorted, 2) < a, "ref nested in list");
1112        assert!(order_index(&sorted, 3) < a, "ref nested in map");
1113    }
1114
1115    #[test]
1116    fn toposort_reference_cycle_falls_back_to_stable_order() {
1117        let schema = node_schema(&[("next", ref_t())]);
1118        let ops = vec![
1119            create_node(1, "aaa", make_attrs(&[("next", uref(2))])),
1120            create_node(2, "bbb", make_attrs(&[("next", uref(1))])),
1121        ];
1122        let sorted = sort_ops_for_apply(&ops, &schema);
1123        // a cycle still yields a total order without panicking or dropping ops.
1124        assert_eq!(sorted.len(), 2);
1125        // the cyclic remainder is emitted in stable op_order_key (slug) order.
1126        assert_eq!(order_index(&sorted, 1), 0);
1127        assert_eq!(order_index(&sorted, 2), 1);
1128    }
1129
1130    #[test]
1131    fn reverse_toposort_deletes_child_before_parent() {
1132        // `child` is keyed by a reference to `node`, so deleting the parent
1133        // node must wait until the referencing child is gone.
1134        let mut node_key = BTreeMap::new();
1135        node_key.insert("slug".to_string(), field(FieldType::Slug));
1136        let mut child_key = BTreeMap::new();
1137        child_key.insert("parent".to_string(), field(ref_t()));
1138        child_key.insert("slug".to_string(), field(FieldType::Slug));
1139        let mut types = BTreeMap::new();
1140        types.insert(
1141            "node".to_string(),
1142            TypeSchema {
1143                key: node_key,
1144                fields: BTreeMap::new(),
1145            },
1146        );
1147        types.insert(
1148            "child".to_string(),
1149            TypeSchema {
1150                key: child_key,
1151                fields: BTreeMap::new(),
1152            },
1153        );
1154        let schema = Schema { types };
1155
1156        let parent_key = Key::from(BTreeMap::from([("slug".to_string(), json!("p"))]));
1157        let child_key_val = Key::from(BTreeMap::from([
1158            ("parent".to_string(), uref(1)),
1159            ("slug".to_string(), json!("c")),
1160        ]));
1161        let ops = vec![
1162            Op::Delete {
1163                uid: Uid::from_u128(1),
1164                type_name: TypeName::new("node"),
1165                key: parent_key,
1166                backend_id: None,
1167            },
1168            Op::Delete {
1169                uid: Uid::from_u128(2),
1170                type_name: TypeName::new("child"),
1171                key: child_key_val,
1172                backend_id: None,
1173            },
1174        ];
1175        let sorted = sort_ops_for_apply(&ops, &schema);
1176        assert!(
1177            order_index(&sorted, 2) < order_index(&sorted, 1),
1178            "child deleted before the parent it references"
1179        );
1180        assert!(sorted.iter().all(|op| matches!(op, Op::Delete { .. })));
1181    }
1182
1183    #[test]
1184    fn toposort_keeps_deletes_after_creates_and_updates() {
1185        let schema = node_schema(&[("next", ref_t())]);
1186        let ops = vec![
1187            Op::Delete {
1188                uid: Uid::from_u128(9),
1189                type_name: TypeName::new("node"),
1190                key: make_key("z"),
1191                backend_id: None,
1192            },
1193            create_node(1, "a", make_attrs(&[("next", uref(2))])),
1194            create_node(2, "b", make_attrs(&[])),
1195        ];
1196        let sorted = sort_ops_for_apply(&ops, &schema);
1197        let non_delete_count = sorted
1198            .iter()
1199            .filter(|op| !matches!(op, Op::Delete { .. }))
1200            .count();
1201        let delete_pos = sorted
1202            .iter()
1203            .position(|op| matches!(op, Op::Delete { .. }))
1204            .unwrap();
1205        // every create/update precedes the single delete.
1206        assert_eq!(delete_pos, non_delete_count);
1207        // and the create chain is still toposorted within its block.
1208        assert!(order_index(&sorted, 2) < order_index(&sorted, 1));
1209    }
1210}