Skip to main content

elasticctl_api/
ops.rs

1//! Report types shared across command verticals.
2//!
3//! A type lives here because more than one vertical consumes it; per-command
4//! outcome shapes deliberately do not. Field order is the serialized JSON key
5//! order and is contractual: the root `Cargo.toml` enables `serde_json`'s
6//! `preserve_order`, so reordering these fields would silently change rendered
7//! output.
8
9use elasticctl_core::{Error, ErrorKind, Result};
10use serde::Serialize;
11use serde_json::{Value, json};
12
13/// What a guarded mutation will do, shown in the guard banner.
14#[derive(Debug, Clone, PartialEq, Serialize)]
15pub struct MutationPlan {
16    pub preview_action: String,
17    pub preview_details: Vec<String>,
18    /// Object identities the plan will act on: `rule_id` values in the rules
19    /// vertical; `list_id` and `item_id` values in the exceptions vertical.
20    pub targets: Vec<String>,
21}
22
23/// A file-producing export: the encoded body and its counts.
24#[derive(Debug, Clone, PartialEq, Serialize)]
25pub struct ExportOutcome {
26    pub body: String,
27    pub exported: u64,
28    pub missing: Vec<Value>,
29}
30
31/// The report a `delete` apply renders. Shared by the rules and exceptions
32/// verticals: each reports the same applied/deleted/failed/total shape, only
33/// the per-object entries differ (`rule_id` versus `list_id`).
34#[derive(Debug, Clone, PartialEq, Serialize)]
35pub struct DeleteOutcome {
36    pub applied: bool,
37    pub deleted: Vec<Value>,
38    pub failed: Vec<Value>,
39    pub total: usize,
40}
41
42/// What `plan_import` computed and `apply_import` uploads. Shared by the rules
43/// and exceptions verticals: the plan, the re-encoded NDJSON, the in-file
44/// object count, and the objects `--skip-existing` removed.
45#[derive(Debug, Clone, PartialEq)]
46pub struct ImportPlan {
47    pub preview: MutationPlan,
48    /// The file re-encoded as NDJSON, resolved once at plan time so the apply
49    /// never re-reads the file after the guard.
50    pub ndjson: String,
51    /// Every object in the file, before `--skip-existing`.
52    pub total: usize,
53    /// Objects the server already has, with `--skip-existing`.
54    pub skipped: Vec<Value>,
55}
56
57/// The upload half of an import, before the caller adds the plan's totals.
58/// Shared by the rules and exceptions verticals: both normalize Kibana's
59/// import response to a succeeded count and an errors array.
60#[derive(Debug, Clone, PartialEq, Serialize)]
61pub struct ImportReport {
62    pub succeeded: Value,
63    pub failed: Value,
64}
65
66/// Decode an import response into a normalized report, refusing a malformed
67/// success body.
68///
69/// `context` names the vertical for the error message ("rules" or
70/// "exceptions"). A missing or mistyped `success_count` or `errors` must fail
71/// rather than read as "nothing was imported".
72pub(crate) fn decode_import_report(body: &Value, context: &str) -> Result<ImportReport> {
73    let map = body
74        .as_object()
75        .ok_or_else(|| import_error(context, "response", "must be a JSON object"))?;
76    let success_count = map
77        .get("success_count")
78        .and_then(Value::as_u64)
79        .ok_or_else(|| import_error(context, "success_count", "must be an unsigned integer"))?;
80    let errors = map
81        .get("errors")
82        .and_then(Value::as_array)
83        .ok_or_else(|| import_error(context, "errors", "must be an array"))?;
84    Ok(ImportReport {
85        succeeded: json!(success_count),
86        failed: Value::Array(errors.clone()),
87    })
88}
89
90fn import_error(context: &str, field: &str, detail: impl std::fmt::Display) -> Error {
91    Error::new(
92        ErrorKind::Http,
93        format!("decoding {context} import response field {field}: {detail}"),
94    )
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use elasticctl_core::ErrorKind;
101    use serde_json::json;
102
103    #[test]
104    fn import_report_rejects_missing_or_wrongly_typed_fields() {
105        for body in [
106            json!({}),
107            json!({"success_count": "1", "errors": []}),
108            json!({"success_count": 1}),
109            json!({"success_count": 1, "errors": "not-an-array"}),
110            // Negative, floating, and null counters must all refuse.
111            json!({"success_count": -1, "errors": []}),
112            json!({"success_count": 1.5, "errors": []}),
113            json!({"success_count": null, "errors": []}),
114        ] {
115            let error = decode_import_report(&body, "rules").unwrap_err();
116            assert_eq!(error.kind, ErrorKind::Http);
117        }
118    }
119
120    #[test]
121    fn import_report_accepts_a_valid_response() {
122        let report = decode_import_report(
123            &json!({"success_count": 2, "errors": [{"message": "x"}]}),
124            "rules",
125        )
126        .unwrap();
127        assert_eq!(report.succeeded, json!(2));
128        assert_eq!(report.failed, json!([{"message": "x"}]));
129    }
130}