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