1use crate::result::{DiffNode, DiffResult};
5use crate::status::StateStatus;
6use ledvar_core::{Error, Node, Snapshot, validate};
7use std::collections::{HashMap, HashSet};
8
9pub 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 ¤t.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 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 let mut bad = node(&["x"], &[]);
177 bad.content.insert("a".into(), BTreeSet::new()); 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 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}