1use crate::model::{Rule, VOLATILE_FIELDS, server_defaults};
8use serde_json::{Map, Value};
9
10pub fn strip_volatile(rule: &mut Rule) {
12 let map = rule.as_map_mut();
13 for field in VOLATILE_FIELDS {
14 map.remove(field);
15 }
16}
17
18pub fn fill_defaults(rule: &mut Rule) {
20 let map = rule.as_map_mut();
21 for (k, v) in server_defaults() {
22 map.entry(k).or_insert(v);
23 }
24}
25
26fn sort_value(value: &Value) -> Value {
32 match value {
33 Value::Object(m) => {
34 let mut keys: Vec<&String> = m.keys().collect();
35 keys.sort();
36 let mut out = Map::new();
37 for k in keys {
38 out.insert(k.clone(), sort_value(&m[k]));
39 }
40 Value::Object(out)
41 }
42 Value::Array(a) => Value::Array(a.iter().map(sort_value).collect()),
45 other => other.clone(),
46 }
47}
48
49pub fn canonical(rule: &Rule) -> Rule {
51 let mut out = rule.clone();
52 strip_volatile(&mut out);
53 let sorted = sort_value(&Value::Object(out.as_map().clone()));
54 Rule::from_value(sorted).expect("rule_id survives normalization")
55}
56
57pub fn comparable(rule: &Rule) -> Rule {
59 let mut out = rule.clone();
60 strip_volatile(&mut out);
61 fill_defaults(&mut out);
62 let sorted = sort_value(&Value::Object(out.as_map().clone()));
63 Rule::from_value(sorted).expect("rule_id survives normalization")
64}
65
66pub fn sort_rules(rules: &mut [Rule]) {
69 rules.sort_by(|a, b| {
70 let (x, y) = (
71 a.rule_id().unwrap_or("\u{7f}"),
72 b.rule_id().unwrap_or("\u{7f}"),
73 );
74 x.cmp(y)
75 });
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81 use serde_json::json;
82
83 fn pulled() -> Rule {
84 Rule::from_value(json!({
86 "rule_id": "abc", "name": "a rule", "type": "query", "risk_score": 21,
87 "id": "6b796e42-99fa-4296-8dc1-a693dd455dd0",
88 "created_at": "2026-08-12T17:49:01.682Z", "created_by": "key-id",
89 "updated_at": "2026-08-12T17:49:01.682Z", "updated_by": "key-id",
90 "revision": 0, "version": 1,
91 "execution_summary": {"last_execution": {"date": "2026-08-13T03:00:20.804Z"}},
92 "max_signals": 100, "to": "now"
93 }))
94 .unwrap()
95 }
96
97 fn hand_authored() -> Rule {
98 Rule::from_value(json!({
100 "rule_id": "abc", "name": "a rule", "type": "query", "risk_score": 21
101 }))
102 .unwrap()
103 }
104
105 #[test]
106 fn strip_volatile_removes_all_eight_measured_fields() {
107 let mut r = pulled();
108 strip_volatile(&mut r);
109 for f in VOLATILE_FIELDS {
110 assert!(!r.as_map().contains_key(f), "{f} should have been stripped");
111 }
112 assert_eq!(r.rule_id().unwrap(), "abc", "identity must survive");
113 assert_eq!(
114 r.as_map()["max_signals"],
115 json!(100),
116 "non-volatile fields stay"
117 );
118 }
119
120 #[test]
121 fn strip_volatile_is_idempotent() {
122 let mut once = pulled();
123 strip_volatile(&mut once);
124 let mut twice = once.clone();
125 strip_volatile(&mut twice);
126 assert_eq!(once, twice);
127 }
128
129 #[test]
130 fn fill_defaults_adds_only_absent_fields() {
131 let mut r = hand_authored();
132 fill_defaults(&mut r);
133 assert_eq!(r.as_map()["max_signals"], json!(100));
134 assert_eq!(r.as_map()["to"], json!("now"));
135 assert_eq!(
136 r.as_map()["risk_score"],
137 json!(21),
138 "an author's value is never replaced"
139 );
140 }
141
142 #[test]
143 fn fill_defaults_never_overwrites_an_explicit_value() {
144 let mut r = Rule::from_value(json!({"rule_id": "abc", "max_signals": 5000})).unwrap();
145 fill_defaults(&mut r);
146 assert_eq!(r.as_map()["max_signals"], json!(5000));
147 }
148
149 #[test]
150 fn canonical_sorts_keys_so_output_is_deterministic() {
151 let a = Rule::from_value(json!({"rule_id": "x", "zeta": 1, "alpha": 2})).unwrap();
152 let b = Rule::from_value(json!({"rule_id": "x", "alpha": 2, "zeta": 1})).unwrap();
153 let (ca, cb) = (canonical(&a), canonical(&b));
154 assert_eq!(ca, cb, "key order must not affect the canonical form");
155 let keys: Vec<&String> = ca.as_map().keys().collect();
156 let mut sorted = keys.clone();
157 sorted.sort();
158 assert_eq!(keys, sorted);
159 }
160
161 #[test]
162 fn canonical_sorts_nested_object_keys_too() {
163 let r = Rule::from_value(json!({
164 "rule_id": "x", "rule_source": {"zeta": 1, "alpha": 2}
165 }))
166 .unwrap();
167 let c = canonical(&r);
168 let nested = c.as_map()["rule_source"].as_object().unwrap();
169 let keys: Vec<&String> = nested.keys().collect();
170 assert_eq!(keys, vec!["alpha", "zeta"]);
171 }
172
173 #[test]
174 fn canonical_does_not_invent_defaults() {
175 let c = canonical(&hand_authored());
176 assert!(
177 !c.as_map().contains_key("max_signals"),
178 "pull must not bloat files"
179 );
180 }
181
182 #[test]
184 fn a_pulled_rule_and_its_hand_authored_equivalent_compare_equal() {
185 assert_eq!(
186 comparable(&pulled()),
187 comparable(&hand_authored()),
188 "a sparse local file must not read as drift against its remote counterpart"
189 );
190 }
191
192 #[test]
193 fn a_real_difference_still_compares_unequal() {
194 let mut changed = hand_authored();
195 changed.as_map_mut().insert("risk_score".into(), json!(99));
196 assert_ne!(comparable(&pulled()), comparable(&changed));
197 }
198
199 #[test]
200 fn sort_rules_orders_by_rule_id() {
201 let mk = |id: &str| Rule::from_value(json!({"rule_id": id})).unwrap();
202 let mut rules = vec![mk("c"), mk("a"), mk("b")];
203 sort_rules(&mut rules);
204 let ids: Vec<&str> = rules.iter().map(|r| r.rule_id().unwrap()).collect();
205 assert_eq!(ids, vec!["a", "b", "c"]);
206 }
207
208 #[test]
209 fn array_order_is_preserved_not_sorted() {
210 let r = Rule::from_value(json!({
211 "rule_id": "x",
212 "tags": ["zebra", "alpha", "middle"],
213 "index": ["logs-b-*", "logs-a-*"],
214 "threat": [{"z": 1}, {"a": 2}]
215 }))
216 .unwrap();
217 let c = canonical(&r);
218 let tags = c.as_map()["tags"].as_array().unwrap();
219 let tag_strs: Vec<&str> = tags.iter().filter_map(|v| v.as_str()).collect();
220 assert_eq!(
221 tag_strs,
222 vec!["zebra", "alpha", "middle"],
223 "tag order must be preserved"
224 );
225 let index = c.as_map()["index"].as_array().unwrap();
226 let index_strs: Vec<&str> = index.iter().filter_map(|v| v.as_str()).collect();
227 assert_eq!(
228 index_strs,
229 vec!["logs-b-*", "logs-a-*"],
230 "index order must be preserved"
231 );
232 let threat = c.as_map()["threat"].as_array().unwrap();
233 let first_key = threat[0].as_object().unwrap().keys().next().unwrap();
234 assert_eq!(
235 first_key, "z",
236 "array element order preserved, keys sorted within"
237 );
238 }
239
240 #[test]
241 fn sort_rules_puts_unreadable_rule_id_last_without_panicking() {
242 let bad: Rule = serde_json::from_value(json!({"rule_id": 123})).unwrap();
244 let good_c = Rule::from_value(json!({"rule_id": "c"})).unwrap();
245 let good_a = Rule::from_value(json!({"rule_id": "a"})).unwrap();
246 let mut rules = vec![good_c, bad, good_a];
247 sort_rules(&mut rules);
248 assert_eq!(rules[0].rule_id().unwrap(), "a");
249 assert_eq!(rules[1].rule_id().unwrap(), "c");
250 assert!(rules[2].rule_id().is_err(), "unreadable id sorts last");
251 }
252
253 #[test]
254 fn canonical_is_idempotent() {
255 let r = Rule::from_value(json!({
256 "rule_id": "x", "tags": ["z", "a"], "nested": {"z": 1, "a": 2}
257 }))
258 .unwrap();
259 let once = canonical(&r);
260 let twice = canonical(&once);
261 assert_eq!(once, twice);
262 }
263
264 #[test]
265 fn comparable_is_idempotent() {
266 let r = Rule::from_value(json!({
267 "rule_id": "x", "name": "test", "type": "query", "risk_score": 10
268 }))
269 .unwrap();
270 let once = comparable(&r);
271 let twice = comparable(&once);
272 assert_eq!(once, twice);
273 }
274}