1use elasticctl_core::{Error, ErrorKind, Result};
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value, json};
10
11pub const VOLATILE_FIELDS: [&str; 8] = [
14 "id",
15 "created_at",
16 "created_by",
17 "updated_at",
18 "updated_by",
19 "revision",
20 "version",
21 "execution_summary",
22];
23
24pub fn server_defaults() -> Map<String, Value> {
29 let mut m = Map::new();
30 m.insert("actions".into(), json!([]));
31 m.insert("author".into(), json!([]));
32 m.insert("exceptions_list".into(), json!([]));
33 m.insert("false_positives".into(), json!([]));
34 m.insert("immutable".into(), json!(false));
35 m.insert("max_signals".into(), json!(100));
36 m.insert("output_index".into(), json!(""));
37 m.insert("references".into(), json!([]));
38 m.insert("related_integrations".into(), json!([]));
39 m.insert("required_fields".into(), json!([]));
40 m.insert("risk_score_mapping".into(), json!([]));
41 m.insert("rule_source".into(), json!({"type": "internal"}));
42 m.insert("setup".into(), json!(""));
43 m.insert("severity_mapping".into(), json!([]));
44 m.insert("threat".into(), json!([]));
45 m.insert("to".into(), json!("now"));
46 m
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50#[serde(transparent)]
51pub struct Rule(Map<String, Value>);
52
53impl Rule {
54 pub fn from_value(v: Value) -> Result<Rule> {
55 let map = match v {
56 Value::Object(m) => m,
57 _ => return Err(Error::new(ErrorKind::Error, "a rule must be a JSON object")),
58 };
59 match map.get("rule_id") {
62 Some(Value::String(_)) => {}
63 Some(_) => {
64 return Err(Error::new(
65 ErrorKind::Error,
66 "a rule's rule_id must be a string",
67 ));
68 }
69 None => return Err(Error::new(ErrorKind::Error, "a rule must have a rule_id")),
70 }
71 Ok(Rule(map))
72 }
73
74 pub fn into_value(self) -> Value {
75 Value::Object(self.0)
76 }
77
78 pub fn as_map(&self) -> &Map<String, Value> {
79 &self.0
80 }
81
82 pub fn as_map_mut(&mut self) -> &mut Map<String, Value> {
83 &mut self.0
84 }
85
86 fn str_field(&self, key: &str) -> &str {
87 self.0.get(key).and_then(Value::as_str).unwrap_or("")
88 }
89
90 pub fn rule_id(&self) -> Result<&str> {
96 self.0
97 .get("rule_id")
98 .and_then(Value::as_str)
99 .ok_or_else(|| Error::new(ErrorKind::Error, "rule is missing rule_id"))
100 }
101
102 pub fn name(&self) -> &str {
103 self.str_field("name")
104 }
105
106 pub fn rule_type(&self) -> &str {
107 self.str_field("type")
108 }
109
110 pub fn severity(&self) -> &str {
111 self.str_field("severity")
112 }
113
114 pub fn enabled(&self) -> bool {
115 self.0
116 .get("enabled")
117 .and_then(Value::as_bool)
118 .unwrap_or(false)
119 }
120
121 pub fn risk_score(&self) -> i64 {
122 self.0
123 .get("risk_score")
124 .and_then(Value::as_i64)
125 .unwrap_or(0)
126 }
127
128 pub fn tags(&self) -> Vec<&str> {
129 self.0
130 .get("tags")
131 .and_then(Value::as_array)
132 .map(|a| a.iter().filter_map(Value::as_str).collect())
133 .unwrap_or_default()
134 }
135}
136
137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
140pub struct ExportSummary {
141 #[serde(default)]
142 pub exported_count: u64,
143 #[serde(default)]
144 pub exported_rules_count: u64,
145 #[serde(default)]
146 pub missing_rules_count: u64,
147 #[serde(default)]
150 pub missing_rules: Vec<Value>,
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use serde_json::json;
157
158 fn probe_rule() -> Value {
159 json!({
161 "rule_id": "elasticctl-schema-probe",
162 "name": "elasticctl schema probe",
163 "description": "Temporary rule.",
164 "type": "query",
165 "language": "kuery",
166 "query": "event.category:process",
167 "index": ["logs-*"],
168 "severity": "low",
169 "risk_score": 21,
170 "enabled": false,
171 "from": "now-6m",
172 "interval": "5m",
173 "tags": ["elasticctl", "temporary"],
174 "id": "6b796e42-99fa-4296-8dc1-a693dd455dd0",
175 "created_at": "2026-08-12T17:49:01.682Z",
176 "created_by": "2XTe9p8BLjNicQlhfc9W",
177 "updated_at": "2026-08-12T17:49:01.682Z",
178 "updated_by": "2XTe9p8BLjNicQlhfc9W",
179 "revision": 0,
180 "version": 1,
181 "max_signals": 100,
182 "to": "now",
183 "rule_source": {"type": "internal"}
184 })
185 }
186
187 #[test]
188 fn accessors_read_the_measured_fields() {
189 let r = Rule::from_value(probe_rule()).unwrap();
190 assert_eq!(r.rule_id().unwrap(), "elasticctl-schema-probe");
191 assert_eq!(r.name(), "elasticctl schema probe");
192 assert_eq!(r.rule_type(), "query");
193 assert!(!r.enabled());
194 assert_eq!(r.severity(), "low");
195 assert_eq!(r.risk_score(), 21);
196 assert_eq!(r.tags(), vec!["elasticctl", "temporary"]);
197 }
198
199 #[test]
200 fn every_unknown_field_survives_a_round_trip() {
201 let original = probe_rule();
202 let r = Rule::from_value(original.clone()).unwrap();
203 assert_eq!(r.into_value(), original, "no field may be dropped");
204 }
205
206 #[test]
207 fn a_rule_without_rule_id_is_rejected() {
208 let err = Rule::from_value(json!({"name": "x"})).unwrap_err();
209 assert_eq!(err.kind, elasticctl_core::ErrorKind::Error);
210 assert!(err.message.contains("rule_id"));
211 }
212
213 #[test]
214 fn a_non_string_rule_id_is_rejected() {
215 let err = Rule::from_value(json!({"rule_id": 123, "name": "x"})).unwrap_err();
218 assert_eq!(err.kind, elasticctl_core::ErrorKind::Error);
219 assert!(err.message.contains("string"), "{}", err.message);
220 }
221
222 #[test]
223 fn a_null_rule_id_is_rejected() {
224 assert!(Rule::from_value(json!({"rule_id": null})).is_err());
226 }
227
228 #[test]
231 fn deserialize_bypasses_the_construction_check() {
232 let r: Rule = serde_json::from_value(json!({"rule_id": 123})).unwrap();
233 assert!(r.rule_id().is_err());
234 }
235
236 #[test]
237 fn a_non_object_is_rejected() {
238 assert!(Rule::from_value(json!(["not", "an", "object"])).is_err());
239 }
240
241 #[test]
242 fn missing_optional_fields_read_as_sane_defaults() {
243 let r = Rule::from_value(json!({"rule_id": "x"})).unwrap();
244 assert_eq!(r.name(), "");
245 assert_eq!(r.rule_type(), "");
246 assert!(!r.enabled());
247 assert_eq!(r.risk_score(), 0);
248 assert!(r.tags().is_empty());
249 }
250
251 #[test]
252 fn volatile_field_list_matches_the_measured_set() {
253 let mut got = VOLATILE_FIELDS.to_vec();
254 got.sort_unstable();
255 assert_eq!(
256 got,
257 [
258 "created_at",
259 "created_by",
260 "execution_summary",
261 "id",
262 "revision",
263 "updated_at",
264 "updated_by",
265 "version"
266 ]
267 );
268 }
269
270 #[test]
271 fn server_defaults_cover_the_sixteen_measured_fields() {
272 let d = server_defaults();
273 assert_eq!(d.len(), 16);
274 assert_eq!(d["max_signals"], json!(100));
275 assert_eq!(d["to"], json!("now"));
276 assert_eq!(d["actions"], json!([]));
277 assert_eq!(d["rule_source"], json!({"type": "internal"}));
278 assert_eq!(d["immutable"], json!(false));
279 assert_eq!(d["setup"], json!(""));
280 assert_eq!(d["output_index"], json!(""));
281 }
282
283 #[test]
284 fn volatile_and_default_field_sets_do_not_overlap() {
285 for v in VOLATILE_FIELDS {
287 assert!(!server_defaults().contains_key(v), "{v} is in both sets");
288 }
289 }
290}