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    // The property Task 7 exists to guarantee, asserted end to end.
243    #[test]
244    fn volatile_fields_never_produce_drift() {
245        let mut remote = rule("x", "X", 21);
246        remote
247            .as_map_mut()
248            .insert("id".into(), json!("server-uuid"));
249        remote
250            .as_map_mut()
251            .insert("updated_at".into(), json!("2026-08-12T00:00:00Z"));
252        remote.as_map_mut().insert("version".into(), json!(7));
253        assert!(
254            Drift::compute(&[rule("x", "X", 21)], &[remote])
255                .unwrap()
256                .is_clean()
257        );
258    }
259
260    #[test]
261    fn omitted_server_defaults_never_produce_drift() {
262        let mut remote = rule("x", "X", 21);
263        remote.as_map_mut().insert("max_signals".into(), json!(100));
264        remote.as_map_mut().insert("to".into(), json!("now"));
265        assert!(
266            Drift::compute(&[rule("x", "X", 21)], &[remote])
267                .unwrap()
268                .is_clean()
269        );
270    }
271
272    #[test]
273    fn changes_are_ordered_by_rule_id_so_reports_are_stable() {
274        let local = vec![rule("c", "C", 1), rule("a", "A", 1), rule("b", "B", 1)];
275        let d = Drift::compute(&local, &[]).unwrap();
276        let ids: Vec<&str> = d.changes.iter().map(Change::rule_id).collect();
277        assert_eq!(ids, vec!["a", "b", "c"]);
278    }
279
280    #[test]
281    fn a_local_rule_with_non_string_rule_id_produces_an_error() {
282        let err = Drift::compute(
283            // `from_value` rejects this, but transparent `Deserialize` does
284            // not. This reproduces the case `Drift::compute` must handle.
285            &[serde_json::from_value::<Rule>(json!({
286                "rule_id": 123, "name": "X", "type": "query", "risk_score": 21,
287                "severity": "low"
288            }))
289            .unwrap()],
290            &[],
291        );
292        assert!(err.is_err());
293        let err = err.unwrap_err();
294        assert_eq!(err.kind, ErrorKind::Error);
295        assert!(err.message.contains("local"));
296        assert!(err.message.contains("position"));
297        assert!(err.message.contains("unreadable rule_id"));
298    }
299
300    #[test]
301    fn a_remote_rule_with_non_string_rule_id_produces_an_error() {
302        let err = Drift::compute(
303            &[],
304            // `from_value` rejects this, but transparent `Deserialize` does
305            // not. This reproduces the case `Drift::compute` must handle.
306            &[serde_json::from_value::<Rule>(json!({
307                "rule_id": 123, "name": "X", "type": "query", "risk_score": 21,
308                "severity": "low"
309            }))
310            .unwrap()],
311        );
312        assert!(err.is_err());
313        let err = err.unwrap_err();
314        assert_eq!(err.kind, ErrorKind::Error);
315        assert!(err.message.contains("remote"));
316        assert!(err.message.contains("position"));
317        assert!(err.message.contains("unreadable rule_id"));
318    }
319
320    #[test]
321    fn duplicate_local_rule_ids_produce_a_conflict_error() {
322        let err = Drift::compute(&[rule("x", "X", 21), rule("x", "X", 99)], &[]);
323        assert!(err.is_err());
324        let err = err.unwrap_err();
325        assert_eq!(err.kind, ErrorKind::Conflict);
326        assert!(err.message.contains("local"));
327        assert!(err.message.contains("\"x\""));
328        assert!(err.message.contains("rule_id must be unique"));
329    }
330
331    #[test]
332    fn duplicate_remote_rule_ids_produce_a_conflict_error() {
333        let err = Drift::compute(&[], &[rule("x", "X", 21), rule("x", "X", 99)]);
334        assert!(err.is_err());
335        let err = err.unwrap_err();
336        assert_eq!(err.kind, ErrorKind::Conflict);
337        assert!(err.message.contains("remote"));
338        assert!(err.message.contains("\"x\""));
339        assert!(err.message.contains("rule_id must be unique"));
340    }
341}