Skip to main content

elasticctl_api/
diff.rs

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