Skip to main content

ledvar_diff/
compare.rs

1//! The comparison itself (DIFF.md §2): match by `identity_key`, classify by
2//! `content_hash`. Pure set logic — no policy, no storage.
3
4use crate::result::{DiffNode, DiffResult};
5use crate::status::StateStatus;
6use ledvar_core::{Error, Node, Snapshot, validate};
7use std::collections::{HashMap, HashSet};
8
9/// Compare `current` against `previous` (or a cold start if `previous` is `None`).
10///
11/// Both snapshots are validated first — a diff hashes both sides, and an implementation
12/// MUST NOT hash an ill-formed snapshot (SPEC §9) — so an ill-formed input yields an
13/// error, never a result.
14///
15/// Output order is deterministic: every `current` node in its original order,
16/// then any `Removed` nodes in `previous` order.
17pub fn diff(previous: Option<&Snapshot>, current: &Snapshot) -> Result<DiffResult, Error> {
18    if let Some(p) = previous {
19        validate(p)?;
20    }
21    validate(current)?;
22
23    let mut prev_index: HashMap<String, String> = HashMap::new();
24    if let Some(p) = previous {
25        for n in &p.tree {
26            prev_index.insert(n.identity_key()?, n.content_hash()?);
27        }
28    }
29
30    let mut nodes = Vec::with_capacity(current.tree.len());
31    let mut current_ids = HashSet::with_capacity(current.tree.len());
32
33    for node in &current.tree {
34        let id = node.identity_key()?;
35        let content_hash = node.content_hash()?;
36        current_ids.insert(id.clone());
37
38        let status = if previous.is_none() {
39            StateStatus::Baseline
40        } else if let Some(prev_hash) = prev_index.get(&id) {
41            if *prev_hash == content_hash {
42                StateStatus::Unchanged
43            } else {
44                StateStatus::Modified
45            }
46        } else {
47            StateStatus::Added
48        };
49
50        nodes.push(processed(node.clone(), id, content_hash, status));
51    }
52
53    if let Some(prev) = previous {
54        for node in &prev.tree {
55            let id = node.identity_key()?;
56            if !current_ids.contains(&id) {
57                let content_hash = node.content_hash()?;
58                nodes.push(processed(node.clone(), id, content_hash, StateStatus::Removed));
59            }
60        }
61    }
62
63    Ok(DiffResult {
64        protocol_version: current.protocol_version.clone(),
65        origin_id: current.origin_id.clone(),
66        provider_name: current.provider_name.clone(),
67        timestamp: current.timestamp,
68        fingerprint: current.fingerprint.clone(),
69        parent_origin_id: current.parent_origin_id.clone(),
70        labels: current.labels.clone(),
71        nodes,
72    })
73}
74
75fn processed(node: Node, identity_key: String, content_hash: String, state_status: StateStatus) -> DiffNode {
76    DiffNode {
77        node,
78        identity_key,
79        content_hash,
80        state_status,
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use std::collections::{BTreeMap, BTreeSet};
88
89    fn node(path: &[&str], tags: &[&str]) -> Node {
90        let mut content = BTreeMap::new();
91        if !tags.is_empty() {
92            content.insert(
93                "tags".into(),
94                tags.iter().map(|s| s.to_string()).collect::<BTreeSet<_>>(),
95            );
96        }
97        Node {
98            path: path.iter().map(|s| s.to_string()).collect(),
99            content,
100            labels: BTreeMap::new(),
101            refs: Vec::new(),
102        }
103    }
104
105    fn snap(nodes: Vec<Node>) -> Snapshot {
106        Snapshot {
107            protocol_version: "0.1.0".into(),
108            origin_id: "o".into(),
109            provider_name: "p".into(),
110            timestamp: 0,
111            fingerprint: None,
112            parent_origin_id: None,
113            labels: BTreeMap::new(),
114            tree: nodes,
115        }
116    }
117
118    fn status_of<'a>(r: &'a DiffResult, path: &[&str]) -> &'a StateStatus {
119        let want: Vec<String> = path.iter().map(|s| s.to_string()).collect();
120        &r.nodes
121            .iter()
122            .find(|n| n.node.path == want)
123            .expect("node present")
124            .state_status
125    }
126
127    #[test]
128    fn cold_start_is_all_baseline() {
129        let r = diff(None, &snap(vec![node(&["a"], &["X"]), node(&["b"], &[])])).unwrap();
130        assert!(r.nodes.iter().all(|n| n.state_status == StateStatus::Baseline));
131        assert!(!r.has_drift());
132    }
133
134    #[test]
135    fn the_four_outcomes() {
136        let prev = snap(vec![
137            node(&["vm-1"], &["web"]),
138            node(&["vm-2"], &["web"]),
139            node(&["vm-3"], &["edge"]),
140        ]);
141        let curr = snap(vec![
142            node(&["vm-1"], &["web", "prod"]),
143            node(&["vm-3"], &["edge"]),
144            node(&["vm-4"], &["web"]),
145        ]);
146        let r = diff(Some(&prev), &curr).unwrap();
147        assert_eq!(*status_of(&r, &["vm-1"]), StateStatus::Modified);
148        assert_eq!(*status_of(&r, &["vm-3"]), StateStatus::Unchanged);
149        assert_eq!(*status_of(&r, &["vm-4"]), StateStatus::Added);
150        assert_eq!(*status_of(&r, &["vm-2"]), StateStatus::Removed);
151        assert!(r.has_drift());
152    }
153
154    #[test]
155    fn a_label_only_change_is_unchanged() {
156        // content_hash ignores labels/refs (a core property), so a node differing ONLY in its labels
157        // must NOT surface as drift through the diff.
158        let labelled = |env: &str| {
159            let mut n = node(&["vm-1"], &["web"]);
160            n.labels.insert("env".to_string(), env.to_string());
161            n
162        };
163        let r = diff(
164            Some(&snap(vec![labelled("prod")])),
165            &snap(vec![labelled("staging")]),
166        )
167        .unwrap();
168        assert_eq!(*status_of(&r, &["vm-1"]), StateStatus::Unchanged);
169        assert!(!r.has_drift(), "a label-only change is not drift");
170    }
171
172    #[test]
173    fn refuses_an_ill_formed_snapshot() {
174        // SPEC §9: a diff hashes both sides, and ill-formed input must never be hashed —
175        // diff() validates and refuses instead of producing a result.
176        let mut bad = node(&["x"], &[]);
177        bad.content.insert("a".into(), BTreeSet::new()); // empty value set
178        assert!(diff(None, &snap(vec![bad.clone()])).is_err());
179        assert!(diff(Some(&snap(vec![bad])), &snap(vec![node(&["x"], &[])])).is_err());
180    }
181
182    #[test]
183    fn output_order_is_deterministic() {
184        let prev = snap(vec![node(&["a"], &[]), node(&["gone"], &[])]);
185        let curr = snap(vec![node(&["a"], &[]), node(&["new"], &[])]);
186        let paths: Vec<_> = diff(Some(&prev), &curr)
187            .unwrap()
188            .nodes
189            .iter()
190            .map(|n| n.node.path.clone())
191            .collect();
192        // current order first (a, new), then removed (gone)
193        assert_eq!(
194            paths,
195            vec![
196                vec!["a".to_string()],
197                vec!["new".to_string()],
198                vec!["gone".to_string()]
199            ]
200        );
201    }
202}