Skip to main content

elasticctl_api/
report.rs

1//! Change evidence for a `push` attached to a change ticket.
2//!
3//! Records the proposed and applied changes, with values on both sides.
4
5use serde::Serialize;
6use serde_json::Value;
7
8#[derive(Debug, Clone, Serialize)]
9pub struct ReportEntry {
10    /// The object this entry describes: a `rule_id` for rules, or a `list_id`
11    /// / `item_id` for exception writes.
12    pub rule_id: String,
13    pub name: String,
14    /// `create`, `update`, or `skipped_remote_only` for rules; `create_list`,
15    /// `update_list`, `create_item`, `update_item`, or `delete_item` for
16    /// exception writes.
17    pub action: String,
18    pub before: Option<Value>,
19    pub after: Option<Value>,
20    pub applied: bool,
21    pub error: Option<String>,
22}
23
24#[derive(Debug, Clone, Serialize)]
25pub struct ChangeReport {
26    pub profile: String,
27    pub host: String,
28    pub space: String,
29    /// `false` for a dry run.
30    pub applied: bool,
31    pub entries: Vec<ReportEntry>,
32}
33
34impl ChangeReport {
35    pub fn counts(&self) -> (usize, usize, usize, usize) {
36        let created = self
37            .entries
38            .iter()
39            .filter(|e| e.action == "create" && e.applied)
40            .count();
41        let updated = self
42            .entries
43            .iter()
44            .filter(|e| e.action == "update" && e.applied)
45            .count();
46        let skipped = self
47            .entries
48            .iter()
49            .filter(|e| e.action == "skipped_remote_only")
50            .count();
51        let failed = self.entries.iter().filter(|e| e.error.is_some()).count();
52        (created, updated, skipped, failed)
53    }
54
55    /// Proposed `create` and `update` entries with neither success nor error.
56    /// A dry run leaves all actionable entries pending. After `push` runs,
57    /// pending is zero: each actionable entry succeeds or fails. Pending means
58    /// it awaits `--yes`, not that it failed.
59    pub fn pending(&self) -> usize {
60        self.entries
61            .iter()
62            .filter(|e| {
63                matches!(e.action.as_str(), "create" | "update") && !e.applied && e.error.is_none()
64            })
65            .count()
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    fn entry(action: &str, applied: bool, error: Option<&str>) -> ReportEntry {
74        ReportEntry {
75            rule_id: "x".into(),
76            name: "X".into(),
77            action: action.into(),
78            before: None,
79            after: None,
80            applied,
81            error: error.map(String::from),
82        }
83    }
84
85    fn report(entries: Vec<ReportEntry>) -> ChangeReport {
86        ChangeReport {
87            profile: "default".into(),
88            host: "kb.example.com".into(),
89            space: "default".into(),
90            applied: false,
91            entries,
92        }
93    }
94
95    #[test]
96    fn pending_counts_unapplied_unfailed_create_and_update_entries() {
97        let r = report(vec![
98            entry("create", false, None),
99            entry("update", false, None),
100            entry("skipped_remote_only", false, None),
101        ]);
102        assert_eq!(r.pending(), 2);
103    }
104
105    #[test]
106    fn pending_is_zero_once_every_actionable_entry_has_an_outcome() {
107        let r = report(vec![
108            entry("create", true, None),
109            entry("update", false, Some("conflict")),
110        ]);
111        assert_eq!(r.pending(), 0, "applied or failed entries are not pending");
112    }
113
114    #[test]
115    fn counts_are_unaffected_by_pending_entries() {
116        let r = report(vec![
117            entry("create", false, None),
118            entry("update", true, None),
119        ]);
120        let (created, updated, skipped, failed) = r.counts();
121        assert_eq!((created, updated, skipped, failed), (0, 1, 0, 0));
122    }
123}