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