1use super::reports::{DanglingPointer, DiffReport, ExceptionDrift, ListChange, Mirror};
2use crate::diff::{Change, Drift, FieldChange};
3use crate::exceptions;
4use crate::model::{ExceptionItem, ExceptionList, ListKey, Rule, exception_refs};
5use crate::normalize;
6use elasticctl_core::{Error, ErrorKind, Result, Transport};
7use serde_json::{Map, Value};
8use std::collections::{BTreeMap, BTreeSet};
9use std::path::Path;
10
11#[derive(Debug, Clone)]
12pub(crate) enum ListOp {
13 Create(ExceptionList),
14 Update {
15 before: ExceptionList,
16 after: ExceptionList,
17 },
18}
19
20#[derive(Debug, Clone)]
21pub(crate) enum ItemOp {
22 Create(ExceptionItem),
23 Update {
24 before: ExceptionItem,
25 after: ExceptionItem,
26 },
27 Remove {
28 before: ExceptionItem,
29 namespace_type: String,
30 },
31}
32
33#[derive(Debug)]
34pub(crate) struct ExceptionPlan {
35 pub drift: ExceptionDrift,
36 pub list_ops: Vec<ListOp>,
37 pub item_ops: Vec<ItemOp>,
38 pub resolvable: BTreeSet<ListKey>,
39}
40
41pub async fn diff(
42 t: &Transport,
43 dir: &Path,
44 selectors: &[String],
45 tag: Option<&str>,
46 search: Option<&str>,
47 source: crate::rules::RuleSource,
48) -> Result<DiffReport> {
49 let Mirror {
50 rules: local_all,
51 lists,
52 items,
53 value_lists,
54 has_value_lists,
55 } = super::mirror::read_mirror(dir)?;
56 let scope = super::scope_of(t, selectors, tag, search, source, &local_all, "compare").await?;
57 let (local, out_of_scope) = if scope.is_scoped() {
58 (scope.narrow(local_all), 0)
59 } else {
60 scope.split_by_source(local_all)
61 };
62 let remote = scope.remote(t).await?;
63 let drift = Drift::compute(&local, &remote)?;
64
65 let plan = exception_plan(t, lists.clone(), items.clone(), &local, &remote).await?;
66
67 let changes: Vec<Change> = drift
68 .changes
69 .iter()
70 .filter(|c| !matches!(c, Change::Unchanged { .. }))
71 .cloned()
72 .collect();
73
74 let values = super::values::plan(
75 t,
76 super::values::Inputs {
77 pairs: &value_lists,
78 has_root: has_value_lists,
79 lists: &lists,
80 items: &items,
81 local: &local,
82 remote: &remote,
83 exception_plan: &plan,
84 },
85 false,
86 )
87 .await?;
88 let exceptions = plan.drift;
89 Ok(DiffReport {
90 clean: drift.is_clean()
91 && exceptions.is_clean()
92 && values.drift.as_ref().is_none_or(|v| v.is_clean()),
93 local: local.len(),
94 remote: remote.len(),
95 changes,
96 exceptions,
97 value_lists: values.drift,
98 out_of_scope,
99 selected: scope.is_scoped().then(|| scope.selected()),
100 local_total: scope.is_scoped().then_some(scope.local_total),
101 })
102}
103
104fn map_field_changes(before: &Map<String, Value>, after: &Map<String, Value>) -> Vec<FieldChange> {
105 let mut keys: Vec<&String> = before.keys().chain(after.keys()).collect();
106 keys.sort();
107 keys.dedup();
108 keys.into_iter()
109 .filter_map(|k| {
110 let bv = before.get(k).cloned().unwrap_or(Value::Null);
111 let av = after.get(k).cloned().unwrap_or(Value::Null);
112 (bv != av).then(|| FieldChange {
113 field: k.clone(),
114 before: bv,
115 after: av,
116 })
117 })
118 .collect()
119}
120
121fn list_field_changes(before: &ExceptionList, after: &ExceptionList) -> Vec<FieldChange> {
122 map_field_changes(before.as_map(), after.as_map())
123}
124
125fn index_lists(lists: &[ExceptionList], side: &str) -> Result<BTreeMap<ListKey, ExceptionList>> {
126 let mut map = BTreeMap::new();
127 for (idx, list) in lists.iter().enumerate() {
128 let key = list.key().map_err(|_| {
129 Error::new(
130 ErrorKind::Error,
131 format!("{side} exception list at position {idx} has an unreadable list_id"),
132 )
133 })?;
134 if map
135 .insert(key.clone(), normalize::canonical_list(list))
136 .is_some()
137 {
138 return Err(Error::new(
139 ErrorKind::Conflict,
140 format!(
141 "{side} has two exception lists with list_id \"{}\" in namespace \"{}\"",
142 key.list_id, key.namespace_type
143 ),
144 ));
145 }
146 }
147 Ok(map)
148}
149
150fn list_drift(
151 local: &BTreeMap<ListKey, ExceptionList>,
152 remote: &BTreeMap<ListKey, ExceptionList>,
153) -> Result<(ExceptionDrift, Vec<ListOp>)> {
154 let mut changes = Vec::new();
155 let mut ops = Vec::new();
156 let mut keys: Vec<&ListKey> = local.keys().chain(remote.keys()).collect();
157 keys.sort();
158 keys.dedup();
159
160 for key in keys {
161 match (local.get(key), remote.get(key)) {
162 (Some(local_list), None) => {
163 changes.push(ListChange::Added {
164 list_id: key.list_id.clone(),
165 name: local_list.name().to_string(),
166 });
167 ops.push(ListOp::Create(local_list.clone()));
168 }
169 (None, Some(remote_list)) => {
170 changes.push(ListChange::RemoteOnly {
171 list_id: key.list_id.clone(),
172 name: remote_list.name().to_string(),
173 });
174 }
175 (Some(local_list), Some(remote_list)) => {
176 let fields = list_field_changes(remote_list, local_list);
177 if fields.is_empty() {
178 changes.push(ListChange::Unchanged {
179 list_id: key.list_id.clone(),
180 });
181 } else {
182 changes.push(ListChange::Modified {
183 list_id: key.list_id.clone(),
184 name: local_list.name().to_string(),
185 fields,
186 });
187 ops.push(ListOp::Update {
188 before: remote_list.clone(),
189 after: local_list.clone(),
190 });
191 }
192 }
193 (None, None) => unreachable!("a key came from one of the two maps"),
194 }
195 }
196
197 Ok((
198 ExceptionDrift {
199 local: local.len(),
200 remote: remote.len(),
201 changes,
202 dangling: Vec::new(),
203 },
204 ops,
205 ))
206}
207
208async fn fetch_remote_lists(t: &Transport, keys: &BTreeSet<ListKey>) -> Result<Vec<ExceptionList>> {
209 let mut out = Vec::new();
210 for key in keys {
211 match exceptions::get_list(t, key).await {
212 Ok(list) => out.push(list),
213 Err(e) if e.kind == ErrorKind::NotFound => {}
214 Err(e) => return Err(e),
215 }
216 }
217 Ok(out)
218}
219
220fn dangling_pointers(
221 raw_remote: &[Rule],
222 live: &BTreeMap<ListKey, String>,
223) -> Vec<DanglingPointer> {
224 let mut out = Vec::new();
225 for rule in raw_remote {
226 let Ok(rule_id) = rule.rule_id() else {
227 continue;
228 };
229 for r in exception_refs(rule) {
230 let key = ListKey {
231 list_id: r.list_id.clone(),
232 namespace_type: r.namespace_type.clone(),
233 };
234 let live_id = live.get(&key).cloned();
235 let stored = r.id.clone();
236 if live_id.as_deref() != stored.as_deref() {
237 out.push(DanglingPointer {
238 rule_id: rule_id.to_string(),
239 list_id: r.list_id,
240 stored_id: stored.map(Value::String).unwrap_or(Value::Null),
241 live_id,
242 });
243 }
244 }
245 }
246 out
247}
248
249fn item_reconciliation(
250 both: &BTreeSet<ListKey>,
251 local_items: &BTreeMap<ListKey, Vec<ExceptionItem>>,
252 remote_items: &BTreeMap<ListKey, Vec<ExceptionItem>>,
253) -> Result<(Vec<ListChange>, Vec<ItemOp>)> {
254 let index = |items: &[ExceptionItem], side: &str| -> Result<BTreeMap<String, ExceptionItem>> {
255 let mut m = BTreeMap::new();
256 for i in items {
257 let Some(id) = i.item_id().ok() else { continue };
258 if m.insert(id.to_string(), i.clone()).is_some() {
259 return Err(Error::new(
260 ErrorKind::Conflict,
261 format!("{side} has two exception items with item_id \"{id}\""),
262 ));
263 }
264 }
265 Ok(m)
266 };
267
268 let mut changes = Vec::new();
269 let mut ops = Vec::new();
270
271 for key in both {
272 let local = local_items.get(key).cloned().unwrap_or_default();
273 let remote = remote_items.get(key).cloned().unwrap_or_default();
274 let local_by_id = index(&local, "local")?;
275 let remote_by_id = index(&remote, "remote")?;
276
277 let mut ids: Vec<&String> = local_by_id.keys().chain(remote_by_id.keys()).collect();
278 ids.sort();
279 ids.dedup();
280
281 for item_id in ids {
282 match (local_by_id.get(item_id), remote_by_id.get(item_id)) {
283 (Some(l), None) => {
284 changes.push(ListChange::ItemAdded {
285 list_id: key.list_id.clone(),
286 item_id: item_id.clone(),
287 });
288 ops.push(ItemOp::Create(l.clone()));
289 }
290 (None, Some(remote_item)) => {
291 changes.push(ListChange::ItemRemoved {
292 list_id: key.list_id.clone(),
293 item_id: item_id.clone(),
294 });
295 ops.push(ItemOp::Remove {
296 before: normalize::canonical_item(remote_item),
297 namespace_type: key.namespace_type.clone(),
298 });
299 }
300 (Some(l), Some(r)) => {
301 let local_canon = normalize::canonical_item(l);
302 let remote_canon = normalize::canonical_item(r);
303 if local_canon != remote_canon {
304 let fields = map_field_changes(remote_canon.as_map(), local_canon.as_map());
305 changes.push(ListChange::ItemModified {
306 list_id: key.list_id.clone(),
307 item_id: item_id.clone(),
308 fields,
309 });
310 ops.push(ItemOp::Update {
311 before: remote_canon,
312 after: l.clone(),
313 });
314 }
315 }
316 (None, None) => unreachable!("an item id came from one of the two maps"),
317 }
318 }
319 }
320
321 Ok((changes, ops))
322}
323
324pub(crate) async fn exception_plan(
325 t: &Transport,
326 mirror_lists: Vec<ExceptionList>,
327 mirror_items: Vec<ExceptionItem>,
328 local_rules: &[Rule],
329 remote_rules: &[Rule],
330) -> Result<ExceptionPlan> {
331 let wanted: BTreeSet<ListKey> = super::referenced_keys(local_rules)
332 .into_iter()
333 .chain(super::referenced_keys(remote_rules))
334 .collect();
335
336 let remote_lists = fetch_remote_lists(t, &wanted).await?;
337 let local_lists: Vec<ExceptionList> = mirror_lists
338 .into_iter()
339 .filter(|l| l.key().map(|k| wanted.contains(&k)).unwrap_or(false))
340 .collect();
341
342 let mut live: BTreeMap<ListKey, String> = BTreeMap::new();
343 for list in &remote_lists {
344 if let Ok(key) = list.key()
345 && let Some(id) = list.as_map().get("id").and_then(Value::as_str)
346 {
347 live.insert(key, id.to_string());
348 }
349 }
350
351 let local_indexed = index_lists(&local_lists, "local")?;
352 let remote_indexed = index_lists(&remote_lists, "remote")?;
353 let (mut drift, ops) = list_drift(&local_indexed, &remote_indexed)?;
354
355 let both: BTreeSet<ListKey> = local_indexed
356 .keys()
357 .filter(|k| remote_indexed.contains_key(*k))
358 .cloned()
359 .collect();
360
361 let mut resolvable: BTreeSet<ListKey> =
362 remote_lists.iter().filter_map(|l| l.key().ok()).collect();
363 for op in &ops {
364 if let ListOp::Create(list) = op {
365 resolvable.insert(list.key()?);
366 }
367 }
368
369 let local_items = group_items(mirror_items)?;
370 let mut remote_items: BTreeMap<ListKey, Vec<ExceptionItem>> = BTreeMap::new();
371 for key in &both {
372 remote_items.insert(key.clone(), exceptions::find_items(t, key).await?);
373 }
374
375 let mut item_ops = Vec::new();
376 for op in &ops {
377 if let ListOp::Create(list) = op {
378 let key = list.key()?;
379 if let Some(items) = local_items.get(&key) {
380 item_ops.extend(items.iter().map(|i| ItemOp::Create(i.clone())));
381 }
382 }
383 }
384
385 let (item_changes, reconciled) = item_reconciliation(&both, &local_items, &remote_items)?;
386 item_ops.extend(reconciled);
387
388 drift.dangling = dangling_pointers(remote_rules, &live);
389 drift.changes.extend(item_changes);
390
391 Ok(ExceptionPlan {
392 drift,
393 list_ops: ops,
394 item_ops,
395 resolvable,
396 })
397}
398
399fn group_items(items: Vec<ExceptionItem>) -> Result<BTreeMap<ListKey, Vec<ExceptionItem>>> {
400 let mut map: BTreeMap<ListKey, Vec<ExceptionItem>> = BTreeMap::new();
401 for item in items {
402 validate_grouped_item(&item)?;
403 let key = ListKey {
404 list_id: item.list_id()?.to_string(),
405 namespace_type: item.namespace_type().to_string(),
406 };
407 map.entry(key).or_default().push(item);
408 }
409 for grouped in map.values_mut() {
410 normalize::sort_items(grouped);
411 }
412 Ok(map)
413}
414
415fn validate_grouped_item(item: &ExceptionItem) -> Result<()> {
416 let item_id = item.item_id()?;
417 if item_id.is_empty() {
418 return Err(Error::new(
419 ErrorKind::Error,
420 "exception item field item_id must be a non-empty string",
421 ));
422 }
423 let list_id = item.list_id()?;
424 if list_id.is_empty() {
425 return Err(Error::new(
426 ErrorKind::Error,
427 "exception item field list_id must be a non-empty string",
428 ));
429 }
430 match item.as_map().get("namespace_type") {
431 None => Ok(()),
432 Some(Value::String(value)) if !value.is_empty() => Ok(()),
433 Some(_) => Err(Error::new(
434 ErrorKind::Error,
435 "exception item field namespace_type must be a non-empty string",
436 )),
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443
444 #[test]
445 fn grouping_rejects_an_item_without_a_list_id() {
446 let item = ExceptionItem::from_value(serde_json::json!({
447 "item_id": "orphan",
448 "type": "simple",
449 "entries": [],
450 }))
451 .unwrap();
452
453 let error = group_items(vec![item]).unwrap_err();
454
455 assert_eq!(error.kind, ErrorKind::Error);
456 assert!(error.message.contains("list_id"), "{}", error.message);
457 }
458}