Skip to main content

elasticctl_api/state/
reports.rs

1//! Report and plan types for the state engine.
2//!
3//! Field order is the serialized JSON key order and is contractual: the root
4//! `Cargo.toml` enables `serde_json`'s `preserve_order`, so reordering these
5//! fields would silently change rendered output.
6
7use crate::diff::{Change, FieldChange};
8use crate::model::{ExceptionItem, ExceptionList, Rule};
9use serde::Serialize;
10use serde_json::Value;
11
12/// The resolved deployment the change report records as its target.
13///
14/// Plain values, not `Context` or clap types, so `-api` may take them directly.
15/// The caller builds this from its resolved profile.
16#[derive(Debug, Clone, PartialEq)]
17pub struct StackIdentity {
18    pub profile: String,
19    pub host: String,
20    pub space: String,
21}
22
23/// The report `pull` renders.
24#[derive(Debug, Clone, PartialEq, Serialize)]
25pub struct PullReport {
26    pub pulled: usize,
27    pub exception_lists: usize,
28    pub exception_items: usize,
29    pub dir: String,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub selected: Option<usize>,
32}
33
34/// The report `diff` renders.
35///
36/// Field order is the serialized JSON key order and is contractual: the root
37/// `Cargo.toml` enables `serde_json`'s `preserve_order`, so reordering these
38/// fields would silently change rendered output.
39#[derive(Debug, Clone, PartialEq, Serialize)]
40pub struct DiffReport {
41    pub clean: bool,
42    pub local: usize,
43    pub remote: usize,
44    pub changes: Vec<Change>,
45    pub exceptions: ExceptionDrift,
46    /// Local rule files outside the active `--source` scope. Reported instead
47    /// of `local_only` so a 0.1 mirror of prebuilt rules does not read as
48    /// drift (spec 5.5). Omitted when zero, so a clean scope keeps its 0.1
49    /// output shape.
50    #[serde(skip_serializing_if = "is_zero")]
51    pub out_of_scope: usize,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub selected: Option<usize>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub local_total: Option<usize>,
56}
57
58/// The summary `push` renders.
59///
60/// `created`, `updated`, `skipped_remote_only`, and `pending` count rules.
61/// `failed` counts every failed write, rules and exceptions alike, so a failed
62/// list or item write still exits nonzero. The `*_lists`/`items_*` fields name
63/// the exception writes separately, so a run that creates, updates, or removes
64/// only containers and items never reads as "nothing happened". `items_removed`
65/// is the one deletion the state engine performs.
66#[derive(Debug, Clone, PartialEq, Serialize)]
67pub struct PushReport {
68    pub applied: bool,
69    pub created: usize,
70    pub updated: usize,
71    pub skipped_remote_only: usize,
72    pub failed: usize,
73    pub pending: usize,
74    pub lists_created: usize,
75    pub lists_updated: usize,
76    pub items_created: usize,
77    pub items_updated: usize,
78    pub items_removed: usize,
79    /// Local rule files outside the active `--source` scope, never planned as
80    /// creates (spec 5.5). Omitted when zero.
81    #[serde(skip_serializing_if = "is_zero")]
82    pub out_of_scope: usize,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub selected: Option<usize>,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub local_total: Option<usize>,
87}
88
89fn is_zero(n: &usize) -> bool {
90    *n == 0
91}
92
93/// The mirror `read_mirror` reads: every rule and exception-list file under
94/// `dir`, with each list's `items` array split out.
95///
96/// It does not apply the reference closure itself; the state command consuming
97/// the mirror narrows `lists`/`items` to what the in-scope rules reference.
98#[derive(Debug)]
99pub struct Mirror {
100    pub rules: Vec<Rule>,
101    pub lists: Vec<ExceptionList>,
102    pub items: Vec<ExceptionItem>,
103}
104
105/// Exception-list drift, mirroring the rules block of `DiffReport`.
106#[derive(Debug, Clone, PartialEq, Serialize)]
107pub struct ExceptionDrift {
108    pub local: usize,
109    pub remote: usize,
110    pub changes: Vec<ListChange>,
111    pub dangling: Vec<DanglingPointer>,
112}
113
114impl ExceptionDrift {
115    /// No container drift and no dangling pointer.
116    pub fn is_clean(&self) -> bool {
117        self.changes
118            .iter()
119            .all(|c| matches!(c, ListChange::Unchanged { .. }))
120            && self.dangling.is_empty()
121    }
122}
123
124#[derive(Debug, Clone, PartialEq, Serialize)]
125#[serde(tag = "change", rename_all = "snake_case")]
126pub enum ListChange {
127    Added {
128        list_id: String,
129        name: String,
130    },
131    Modified {
132        list_id: String,
133        name: String,
134        fields: Vec<FieldChange>,
135    },
136    Unchanged {
137        list_id: String,
138    },
139    RemoteOnly {
140        list_id: String,
141        name: String,
142    },
143    ItemAdded {
144        list_id: String,
145        item_id: String,
146    },
147    ItemModified {
148        list_id: String,
149        item_id: String,
150        fields: Vec<FieldChange>,
151    },
152    ItemRemoved {
153        list_id: String,
154        item_id: String,
155    },
156}
157
158/// A rule whose stored exception pointer does not match the live container.
159///
160/// `live_id` is `None` when no container with that `list_id` exists on this
161/// stack.
162#[derive(Debug, Clone, PartialEq, Serialize)]
163pub struct DanglingPointer {
164    pub rule_id: String,
165    pub list_id: String,
166    pub stored_id: Value,
167    pub live_id: Option<String>,
168}