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}