Skip to main content

elasticctl_api/
diff.rs

1use crate::model::Rule;
2use crate::normalize::comparable;
3use elasticctl_core::{Error, ErrorKind, Result};
4use serde::Serialize;
5use serde_json::Value;
6use std::collections::BTreeMap;
7
8#[derive(Debug, Clone, PartialEq, Serialize)]
9pub struct FieldChange {
10    pub field: String,
11    pub before: Value,
12    pub after: Value,
13}
14
15#[derive(Debug, Clone, PartialEq, Serialize)]
16#[serde(tag = "change", rename_all = "snake_case")]
17pub enum Change {
18    Added {
19        rule_id: String,
20        name: String,
21    },
22    Modified {
23        rule_id: String,
24        name: String,
25        fields: Vec<FieldChange>,
26    },
27    Unchanged {
28        rule_id: String,
29    },
30    RemoteOnly {
31        rule_id: String,
32        name: String,
33    },
34}
35
36impl Change {
37    pub fn rule_id(&self) -> &str {
38        match self {
39            Change::Added { rule_id, .. }
40            | Change::Modified { rule_id, .. }
41            | Change::Unchanged { rule_id }
42            | Change::RemoteOnly { rule_id, .. } => rule_id,
43        }
44    }
45}
46
47#[derive(Debug, Clone, PartialEq, Serialize)]
48pub struct Drift {
49    pub changes: Vec<Change>,
50}
51
52fn field_changes(before: &Rule, after: &Rule) -> Vec<FieldChange> {
53    let (b, a) = (before.as_map(), after.as_map());
54    let mut keys: Vec<&String> = b.keys().chain(a.keys()).collect();
55    keys.sort();
56    keys.dedup();
57
58    keys.into_iter()
59        .filter_map(|k| {
60            let bv = b.get(k).cloned().unwrap_or(Value::Null);
61            let av = a.get(k).cloned().unwrap_or(Value::Null);
62            (bv != av).then(|| FieldChange {
63                field: k.clone(),
64                before: bv,
65                after: av,
66            })
67        })
68        .collect()
69}
70
71impl Drift {
72    pub fn compute(local: &[Rule], remote: &[Rule]) -> Result<Drift> {
73        let index = |rules: &[Rule], side: &str| -> Result<BTreeMap<String, Rule>> {
74            let mut map = BTreeMap::new();
75            for (idx, r) in rules.iter().enumerate() {
76                let id = r.rule_id().map_err(|_| {
77                    Error::new(
78                        ErrorKind::Error,
79                        format!(
80                            "{} rule at position {} has an unreadable rule_id",
81                            side, idx
82                        ),
83                    )
84                })?;
85                if map.insert(id.to_string(), comparable(r)).is_some() {
86                    return Err(Error::new(
87                        ErrorKind::Conflict,
88                        format!(
89                            "{} has two rules with rule_id \"{}\"; rule_id must be unique",
90                            side, id
91                        ),
92                    ));
93                }
94            }
95            Ok(map)
96        };
97        let (local, remote) = (index(local, "local")?, index(remote, "remote")?);
98
99        let mut changes = Vec::new();
100        let mut ids: Vec<&String> = local.keys().chain(remote.keys()).collect();
101        ids.sort();
102        ids.dedup();
103
104        for id in ids {
105            match (local.get(id), remote.get(id)) {
106                (Some(l), None) => changes.push(Change::Added {
107                    rule_id: id.clone(),
108                    name: l.name().to_string(),
109                }),
110                (None, Some(r)) => changes.push(Change::RemoteOnly {
111                    rule_id: id.clone(),
112                    name: r.name().to_string(),
113                }),
114                (Some(l), Some(r)) => {
115                    let fields = field_changes(r, l);
116                    if fields.is_empty() {
117                        changes.push(Change::Unchanged {
118                            rule_id: id.clone(),
119                        });
120                    } else {
121                        changes.push(Change::Modified {
122                            rule_id: id.clone(),
123                            name: l.name().to_string(),
124                            fields,
125                        });
126                    }
127                }
128                (None, None) => unreachable!("an id came from one of the two maps"),
129            }
130        }
131
132        Ok(Drift { changes })
133    }
134
135    pub fn actionable(&self) -> Vec<&Change> {
136        self.changes
137            .iter()
138            .filter(|c| matches!(c, Change::Added { .. } | Change::Modified { .. }))
139            .collect()
140    }
141
142    pub fn is_clean(&self) -> bool {
143        self.changes
144            .iter()
145            .all(|c| matches!(c, Change::Unchanged { .. }))
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use serde_json::json;
153
154    fn rule(id: &str, name: &str, risk: i64) -> Rule {
155        Rule::from_value(json!({
156            "rule_id": id, "name": name, "type": "query", "risk_score": risk,
157            "severity": "low"
158        }))
159        .unwrap()
160    }
161
162    #[test]
163    fn identical_sets_are_clean() {
164        let a = vec![rule("x", "X", 21)];
165        let d = Drift::compute(&a, &a).unwrap();
166        assert!(d.is_clean());
167        assert!(d.actionable().is_empty());
168    }
169
170    #[test]
171    fn a_local_only_rule_is_added() {
172        let d = Drift::compute(&[rule("x", "X", 21)], &[]).unwrap();
173        assert!(
174            matches!(&d.changes[0], Change::Added { rule_id, name } if rule_id == "x" && name == "X")
175        );
176        assert_eq!(d.actionable().len(), 1);
177    }
178
179    #[test]
180    fn a_remote_only_rule_is_reported_but_not_actionable() {
181        let d = Drift::compute(&[], &[rule("x", "X", 21)]).unwrap();
182        assert!(matches!(&d.changes[0], Change::RemoteOnly { rule_id, .. } if rule_id == "x"));
183        assert!(
184            d.actionable().is_empty(),
185            "push must never delete a remote rule"
186        );
187        assert!(
188            !d.is_clean(),
189            "drift exists even though nothing will be applied"
190        );
191    }
192
193    #[test]
194    fn a_changed_field_is_reported_with_before_and_after() {
195        let d = Drift::compute(&[rule("x", "X", 99)], &[rule("x", "X", 21)]).unwrap();
196        let Change::Modified { fields, .. } = &d.changes[0] else {
197            panic!("expected Modified, got {:?}", d.changes[0]);
198        };
199        assert_eq!(fields.len(), 1, "only the field that changed is reported");
200        assert_eq!(fields[0].field, "risk_score");
201        assert_eq!(fields[0].before, json!(21));
202        assert_eq!(fields[0].after, json!(99));
203    }
204
205    #[test]
206    fn multiple_changed_fields_are_reported_in_key_order() {
207        let d = Drift::compute(&[rule("x", "Renamed", 99)], &[rule("x", "X", 21)]).unwrap();
208        let Change::Modified { fields, .. } = &d.changes[0] else {
209            panic!("expected Modified")
210        };
211        let names: Vec<&str> = fields.iter().map(|f| f.field.as_str()).collect();
212        assert_eq!(names, vec!["name", "risk_score"]);
213    }
214
215    #[test]
216    fn a_field_added_locally_shows_null_as_its_before_value() {
217        let mut local = rule("x", "X", 21);
218        local.as_map_mut().insert("note".into(), json!("hello"));
219        let d = Drift::compute(&[local], &[rule("x", "X", 21)]).unwrap();
220        let Change::Modified { fields, .. } = &d.changes[0] else {
221            panic!("expected Modified")
222        };
223        assert_eq!(fields[0].field, "note");
224        assert_eq!(fields[0].before, json!(null));
225        assert_eq!(fields[0].after, json!("hello"));
226    }
227
228    #[test]
229    fn volatile_fields_never_produce_drift() {
230        let mut remote = rule("x", "X", 21);
231        remote
232            .as_map_mut()
233            .insert("id".into(), json!("server-uuid"));
234        remote
235            .as_map_mut()
236            .insert("updated_at".into(), json!("2026-08-12T00:00:00Z"));
237        remote.as_map_mut().insert("version".into(), json!(7));
238        assert!(
239            Drift::compute(&[rule("x", "X", 21)], &[remote])
240                .unwrap()
241                .is_clean()
242        );
243    }
244
245    #[test]
246    fn omitted_server_defaults_never_produce_drift() {
247        let mut remote = rule("x", "X", 21);
248        remote.as_map_mut().insert("max_signals".into(), json!(100));
249        remote.as_map_mut().insert("to".into(), json!("now"));
250        assert!(
251            Drift::compute(&[rule("x", "X", 21)], &[remote])
252                .unwrap()
253                .is_clean()
254        );
255    }
256
257    #[test]
258    fn changes_are_ordered_by_rule_id_so_reports_are_stable() {
259        let local = vec![rule("c", "C", 1), rule("a", "A", 1), rule("b", "B", 1)];
260        let d = Drift::compute(&local, &[]).unwrap();
261        let ids: Vec<&str> = d.changes.iter().map(Change::rule_id).collect();
262        assert_eq!(ids, vec!["a", "b", "c"]);
263    }
264
265    #[test]
266    fn a_local_rule_with_non_string_rule_id_produces_an_error() {
267        let err = Drift::compute(
268            &[serde_json::from_value::<Rule>(json!({
269                "rule_id": 123, "name": "X", "type": "query", "risk_score": 21,
270                "severity": "low"
271            }))
272            .unwrap()],
273            &[],
274        );
275        assert!(err.is_err());
276        let err = err.unwrap_err();
277        assert_eq!(err.kind, ErrorKind::Error);
278        assert!(err.message.contains("local"));
279        assert!(err.message.contains("position"));
280        assert!(err.message.contains("unreadable rule_id"));
281    }
282
283    #[test]
284    fn a_remote_rule_with_non_string_rule_id_produces_an_error() {
285        let err = Drift::compute(
286            &[],
287            &[serde_json::from_value::<Rule>(json!({
288                "rule_id": 123, "name": "X", "type": "query", "risk_score": 21,
289                "severity": "low"
290            }))
291            .unwrap()],
292        );
293        assert!(err.is_err());
294        let err = err.unwrap_err();
295        assert_eq!(err.kind, ErrorKind::Error);
296        assert!(err.message.contains("remote"));
297        assert!(err.message.contains("position"));
298        assert!(err.message.contains("unreadable rule_id"));
299    }
300
301    #[test]
302    fn duplicate_local_rule_ids_produce_a_conflict_error() {
303        let err = Drift::compute(&[rule("x", "X", 21), rule("x", "X", 99)], &[]);
304        assert!(err.is_err());
305        let err = err.unwrap_err();
306        assert_eq!(err.kind, ErrorKind::Conflict);
307        assert!(err.message.contains("local"));
308        assert!(err.message.contains("\"x\""));
309        assert!(err.message.contains("rule_id must be unique"));
310    }
311
312    #[test]
313    fn duplicate_remote_rule_ids_produce_a_conflict_error() {
314        let err = Drift::compute(&[], &[rule("x", "X", 21), rule("x", "X", 99)]);
315        assert!(err.is_err());
316        let err = err.unwrap_err();
317        assert_eq!(err.kind, ErrorKind::Conflict);
318        assert!(err.message.contains("remote"));
319        assert!(err.message.contains("\"x\""));
320        assert!(err.message.contains("rule_id must be unique"));
321    }
322}