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