Skip to main content

elasticctl_api/
normalize.rs

1//! Deterministic rule forms.
2//!
3//! `canonical` is what `pull` writes: volatile fields removed and keys sorted.
4//! `comparable` is what `diff` compares: `canonical` plus server defaults.
5//! They remain separate so `pull` does not write defaults the author omitted.
6
7use crate::model::{
8    COMMENT_VOLATILE_FIELDS, ExceptionItem, ExceptionList, ITEM_VOLATILE_FIELDS,
9    LIST_VOLATILE_FIELDS, Rule, VOLATILE_FIELDS, server_defaults,
10};
11use serde_json::{Map, Value};
12
13/// The tag Serverless appends to a rule that a project-scoped Elasticsearch
14/// API key enables (spec 7.2). It is server-applied state, so normalization
15/// drops it (spec 5.1).
16const CLOUD_API_KEY_TAG: &str = "Missing Elastic Cloud API Key";
17
18/// Remove the server-owned fields that change on every write, and every
19/// `tags` element equal to `CLOUD_API_KEY_TAG`.
20pub fn strip_volatile(rule: &mut Rule) {
21    let map = rule.as_map_mut();
22    for field in VOLATILE_FIELDS {
23        map.remove(field);
24    }
25    if let Some(Value::Array(tags)) = map.get_mut("tags") {
26        tags.retain(|tag| tag.as_str() != Some(CLOUD_API_KEY_TAG));
27    }
28}
29
30/// Remove the volatile pointer from every exception reference.
31///
32/// Spec 4.5: `id` is required on create and validated by nothing, while export
33/// and import both match on `list_id`. Identity is `list_id` plus
34/// `namespace_type`; `push` re-resolves `id` against the target stack.
35pub fn strip_exception_ids(rule: &mut Rule) {
36    if let Some(Value::Array(refs)) = rule.as_map_mut().get_mut("exceptions_list") {
37        for r in refs.iter_mut() {
38            if let Value::Object(m) = r {
39                m.remove("id");
40            }
41        }
42    }
43}
44
45/// Add fields the server fills on create without overwriting explicit values.
46pub fn fill_defaults(rule: &mut Rule) {
47    let map = rule.as_map_mut();
48    for (k, v) in server_defaults() {
49        map.entry(k).or_insert(v);
50    }
51}
52
53/// Recursively sort object keys for deterministic output.
54///
55/// `serde_json::Map` uses `BTreeMap` by default. This workspace enables
56/// `preserve_order`, which retains insertion order, so rebuild the map to make
57/// the output order explicit.
58fn sort_value(value: &Value) -> Value {
59    match value {
60        Value::Object(m) => {
61            let mut keys: Vec<&String> = m.keys().collect();
62            keys.sort();
63            let mut out = Map::new();
64            for k in keys {
65                out.insert(k.clone(), sort_value(&m[k]));
66            }
67            Value::Object(out)
68        }
69        // Array order is meaningful for tags, index patterns, and threat
70        // mappings, so preserve it.
71        Value::Array(a) => Value::Array(a.iter().map(sort_value).collect()),
72        other => other.clone(),
73    }
74}
75
76/// The on-disk form.
77pub fn canonical(rule: &Rule) -> Rule {
78    let mut out = rule.clone();
79    strip_volatile(&mut out);
80    strip_exception_ids(&mut out);
81    let sorted = sort_value(&Value::Object(out.as_map().clone()));
82    Rule::from_value(sorted).expect("rule_id survives normalization")
83}
84
85/// The comparison form.
86pub fn comparable(rule: &Rule) -> Rule {
87    let mut out = rule.clone();
88    strip_volatile(&mut out);
89    strip_exception_ids(&mut out);
90    fill_defaults(&mut out);
91    // A rule left with no tags compares equal whether `tags` is empty or
92    // absent, so dropping the server-applied tag never shows as drift.
93    out.as_map_mut()
94        .entry("tags")
95        .or_insert_with(|| Value::Array(Vec::new()));
96    let sorted = sort_value(&Value::Object(out.as_map().clone()));
97    Rule::from_value(sorted).expect("rule_id survives normalization")
98}
99
100/// The on-disk form of an exception-list container.
101pub fn canonical_list(list: &ExceptionList) -> ExceptionList {
102    let mut out = list.clone();
103    for field in LIST_VOLATILE_FIELDS {
104        out.as_map_mut().remove(field);
105    }
106    let sorted = sort_value(&Value::Object(out.as_map().clone()));
107    ExceptionList::from_value(sorted).expect("list_id survives normalization")
108}
109
110/// The on-disk form of an exception item.
111pub fn canonical_item(item: &ExceptionItem) -> ExceptionItem {
112    let mut out = item.clone();
113    for field in ITEM_VOLATILE_FIELDS {
114        out.as_map_mut().remove(field);
115    }
116    // Comments carry server-minted fields too (spec 7.7). Skip non-object
117    // entries the way `exception_refs` skips malformed reference entries.
118    if let Some(Value::Array(comments)) = out.as_map_mut().get_mut("comments") {
119        for c in comments.iter_mut() {
120            if let Value::Object(m) = c {
121                for field in COMMENT_VOLATILE_FIELDS {
122                    m.remove(field);
123                }
124            }
125        }
126    }
127    let sorted = sort_value(&Value::Object(out.as_map().clone()));
128    ExceptionItem::from_value(sorted).expect("item_id survives normalization")
129}
130
131/// Sort rules for stable file output and diff reports. Rules with unreadable
132/// `rule_id` values sort last.
133pub fn sort_rules(rules: &mut [Rule]) {
134    rules.sort_by(|a, b| {
135        let (x, y) = (
136            a.rule_id().unwrap_or("\u{7f}"),
137            b.rule_id().unwrap_or("\u{7f}"),
138        );
139        x.cmp(y)
140    });
141}
142
143/// Sort exception-list containers for stable file output and diff reports.
144/// Lists with unreadable `list_id` values sort last.
145pub fn sort_lists(lists: &mut [ExceptionList]) {
146    lists.sort_by(|a, b| {
147        let (x, y) = (
148            (a.namespace_type(), a.list_id().unwrap_or("\u{7f}")),
149            (b.namespace_type(), b.list_id().unwrap_or("\u{7f}")),
150        );
151        x.cmp(&y)
152    });
153}
154
155/// Sort exception items for stable file output and diff reports. Items with
156/// unreadable identities sort last.
157pub fn sort_items(items: &mut [ExceptionItem]) {
158    items.sort_by(|a, b| {
159        let (x, y) = (
160            (
161                a.list_id().unwrap_or("\u{7f}"),
162                a.item_id().unwrap_or("\u{7f}"),
163            ),
164            (
165                b.list_id().unwrap_or("\u{7f}"),
166                b.item_id().unwrap_or("\u{7f}"),
167            ),
168        );
169        x.cmp(&y)
170    });
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use serde_json::json;
177
178    fn pulled() -> Rule {
179        // A rule returned by the API.
180        Rule::from_value(json!({
181            "rule_id": "abc", "name": "a rule", "type": "query", "risk_score": 21,
182            "id": "6b796e42-99fa-4296-8dc1-a693dd455dd0",
183            "created_at": "2026-08-12T17:49:01.682Z", "created_by": "key-id",
184            "updated_at": "2026-08-12T17:49:01.682Z", "updated_by": "key-id",
185            "revision": 0, "version": 1,
186            "execution_summary": {"last_execution": {"date": "2026-08-13T03:00:20.804Z"}},
187            "max_signals": 100, "to": "now"
188        }))
189        .unwrap()
190    }
191
192    fn hand_authored() -> Rule {
193        // A hand-authored rule has no volatile fields or defaults.
194        Rule::from_value(json!({
195            "rule_id": "abc", "name": "a rule", "type": "query", "risk_score": 21
196        }))
197        .unwrap()
198    }
199
200    #[test]
201    fn strip_volatile_removes_all_eight_measured_fields() {
202        let mut r = pulled();
203        strip_volatile(&mut r);
204        for f in VOLATILE_FIELDS {
205            assert!(!r.as_map().contains_key(f), "{f} should have been stripped");
206        }
207        assert_eq!(r.rule_id().unwrap(), "abc", "identity must survive");
208        assert_eq!(
209            r.as_map()["max_signals"],
210            json!(100),
211            "non-volatile fields stay"
212        );
213    }
214
215    #[test]
216    fn strip_volatile_is_idempotent() {
217        let mut once = pulled();
218        strip_volatile(&mut once);
219        let mut twice = once.clone();
220        strip_volatile(&mut twice);
221        assert_eq!(once, twice);
222    }
223
224    fn tagged(tags: Value) -> Rule {
225        Rule::from_value(json!({"rule_id": "abc", "tags": tags})).unwrap()
226    }
227
228    /// Spec 5.1: Serverless appends this tag after an enable (spec 7.2).
229    #[test]
230    fn strip_volatile_removes_every_cloud_key_tag_and_keeps_other_tags_in_order() {
231        let mut r = tagged(json!([
232            "zebra",
233            "Missing Elastic Cloud API Key",
234            "alpha",
235            "Missing Elastic Cloud API Key"
236        ]));
237        strip_volatile(&mut r);
238        assert_eq!(r.as_map()["tags"], json!(["zebra", "alpha"]));
239    }
240
241    #[test]
242    fn strip_volatile_removes_only_the_exact_whole_cloud_key_tag() {
243        let near_misses = json!([
244            "missing elastic cloud api key",
245            "Missing Elastic Cloud API Key ",
246            "Missing Elastic Cloud API Key (prod)",
247            ["Missing Elastic Cloud API Key"],
248            7
249        ]);
250        let mut r = tagged(near_misses.clone());
251        strip_volatile(&mut r);
252        assert_eq!(r.as_map()["tags"], near_misses, "only an exact match goes");
253    }
254
255    #[test]
256    fn strip_volatile_leaves_an_emptied_tag_array_empty() {
257        let mut r = tagged(json!(["Missing Elastic Cloud API Key"]));
258        strip_volatile(&mut r);
259        assert_eq!(r.as_map()["tags"], json!([]));
260    }
261
262    #[test]
263    fn strip_volatile_leaves_absent_and_non_array_tags_alone() {
264        let mut absent = Rule::from_value(json!({"rule_id": "abc"})).unwrap();
265        strip_volatile(&mut absent);
266        assert!(
267            !absent.as_map().contains_key("tags"),
268            "no tags are invented"
269        );
270
271        let mut scalar = tagged(json!("Missing Elastic Cloud API Key"));
272        strip_volatile(&mut scalar);
273        assert_eq!(
274            scalar.as_map()["tags"],
275            json!("Missing Elastic Cloud API Key")
276        );
277    }
278
279    #[test]
280    fn a_cloud_key_tag_never_reaches_the_canonical_or_comparable_form() {
281        let mk = |tags: Value| {
282            Rule::from_value(json!({
283                "rule_id": "abc", "name": "a rule", "type": "query", "risk_score": 21,
284                "tags": tags
285            }))
286            .unwrap()
287        };
288        let server = mk(json!(["fixture", "Missing Elastic Cloud API Key"]));
289        let file = mk(json!(["fixture"]));
290        assert_eq!(canonical(&server), canonical(&file));
291        assert_eq!(comparable(&server), comparable(&file));
292        assert_eq!(comparable(&file), comparable(&server));
293    }
294
295    #[test]
296    fn fill_defaults_adds_only_absent_fields() {
297        let mut r = hand_authored();
298        fill_defaults(&mut r);
299        assert_eq!(r.as_map()["max_signals"], json!(100));
300        assert_eq!(r.as_map()["to"], json!("now"));
301        assert_eq!(
302            r.as_map()["risk_score"],
303            json!(21),
304            "an author's value is never replaced"
305        );
306    }
307
308    #[test]
309    fn fill_defaults_never_overwrites_an_explicit_value() {
310        let mut r = Rule::from_value(json!({"rule_id": "abc", "max_signals": 5000})).unwrap();
311        fill_defaults(&mut r);
312        assert_eq!(r.as_map()["max_signals"], json!(5000));
313    }
314
315    #[test]
316    fn canonical_sorts_keys_so_output_is_deterministic() {
317        let a = Rule::from_value(json!({"rule_id": "x", "zeta": 1, "alpha": 2})).unwrap();
318        let b = Rule::from_value(json!({"rule_id": "x", "alpha": 2, "zeta": 1})).unwrap();
319        let (ca, cb) = (canonical(&a), canonical(&b));
320        assert_eq!(ca, cb, "key order must not affect the canonical form");
321        let keys: Vec<&String> = ca.as_map().keys().collect();
322        let mut sorted = keys.clone();
323        sorted.sort();
324        assert_eq!(keys, sorted);
325    }
326
327    #[test]
328    fn canonical_sorts_nested_object_keys_too() {
329        let r = Rule::from_value(json!({
330            "rule_id": "x", "rule_source": {"zeta": 1, "alpha": 2}
331        }))
332        .unwrap();
333        let c = canonical(&r);
334        let nested = c.as_map()["rule_source"].as_object().unwrap();
335        let keys: Vec<&String> = nested.keys().collect();
336        assert_eq!(keys, vec!["alpha", "zeta"]);
337    }
338
339    #[test]
340    fn canonical_does_not_invent_defaults() {
341        let c = canonical(&hand_authored());
342        assert!(
343            !c.as_map().contains_key("max_signals"),
344            "pull must not bloat files"
345        );
346    }
347
348    // The state engine relies on this property.
349    #[test]
350    fn a_pulled_rule_and_its_hand_authored_equivalent_compare_equal() {
351        assert_eq!(
352            comparable(&pulled()),
353            comparable(&hand_authored()),
354            "a sparse local file must not read as drift against its remote counterpart"
355        );
356    }
357
358    #[test]
359    fn a_real_difference_still_compares_unequal() {
360        let mut changed = hand_authored();
361        changed.as_map_mut().insert("risk_score".into(), json!(99));
362        assert_ne!(comparable(&pulled()), comparable(&changed));
363    }
364
365    #[test]
366    fn sort_rules_orders_by_rule_id() {
367        let mk = |id: &str| Rule::from_value(json!({"rule_id": id})).unwrap();
368        let mut rules = vec![mk("c"), mk("a"), mk("b")];
369        sort_rules(&mut rules);
370        let ids: Vec<&str> = rules.iter().map(|r| r.rule_id().unwrap()).collect();
371        assert_eq!(ids, vec!["a", "b", "c"]);
372    }
373
374    #[test]
375    fn array_order_is_preserved_not_sorted() {
376        let r = Rule::from_value(json!({
377            "rule_id": "x",
378            "tags": ["zebra", "alpha", "middle"],
379            "index": ["logs-b-*", "logs-a-*"],
380            "threat": [{"z": 1}, {"a": 2}]
381        }))
382        .unwrap();
383        let c = canonical(&r);
384        let tags = c.as_map()["tags"].as_array().unwrap();
385        let tag_strs: Vec<&str> = tags.iter().filter_map(|v| v.as_str()).collect();
386        assert_eq!(
387            tag_strs,
388            vec!["zebra", "alpha", "middle"],
389            "tag order must be preserved"
390        );
391        let index = c.as_map()["index"].as_array().unwrap();
392        let index_strs: Vec<&str> = index.iter().filter_map(|v| v.as_str()).collect();
393        assert_eq!(
394            index_strs,
395            vec!["logs-b-*", "logs-a-*"],
396            "index order must be preserved"
397        );
398        let threat = c.as_map()["threat"].as_array().unwrap();
399        let first_key = threat[0].as_object().unwrap().keys().next().unwrap();
400        assert_eq!(
401            first_key, "z",
402            "array element order preserved, keys sorted within"
403        );
404    }
405
406    #[test]
407    fn sort_rules_puts_unreadable_rule_id_last_without_panicking() {
408        // Transparent `Deserialize` can create this; `from_value` rejects it.
409        let bad: Rule = serde_json::from_value(json!({"rule_id": 123})).unwrap();
410        let good_c = Rule::from_value(json!({"rule_id": "c"})).unwrap();
411        let good_a = Rule::from_value(json!({"rule_id": "a"})).unwrap();
412        let mut rules = vec![good_c, bad, good_a];
413        sort_rules(&mut rules);
414        assert_eq!(rules[0].rule_id().unwrap(), "a");
415        assert_eq!(rules[1].rule_id().unwrap(), "c");
416        assert!(rules[2].rule_id().is_err(), "unreadable id sorts last");
417    }
418
419    #[test]
420    fn a_rule_with_only_the_server_tag_compares_equal_to_one_without_tags() {
421        let mut tagged = hand_authored();
422        tagged
423            .as_map_mut()
424            .insert("tags".into(), json!([CLOUD_API_KEY_TAG]));
425        let mut untagged = hand_authored();
426        untagged.as_map_mut().remove("tags");
427        assert_eq!(comparable(&tagged), comparable(&untagged));
428        assert_eq!(comparable(&untagged), comparable(&tagged));
429        // The on-disk form does not invent an empty array.
430        assert!(!canonical(&untagged).as_map().contains_key("tags"));
431    }
432
433    #[test]
434    fn canonical_is_idempotent() {
435        let r = Rule::from_value(json!({
436            "rule_id": "x", "tags": ["z", "a"], "nested": {"z": 1, "a": 2}
437        }))
438        .unwrap();
439        let once = canonical(&r);
440        let twice = canonical(&once);
441        assert_eq!(once, twice);
442    }
443
444    #[test]
445    fn comparable_is_idempotent() {
446        let r = Rule::from_value(json!({
447            "rule_id": "x", "name": "test", "type": "query", "risk_score": 10
448        }))
449        .unwrap();
450        let once = comparable(&r);
451        let twice = comparable(&once);
452        assert_eq!(once, twice);
453    }
454
455    /// Spec 4.5. Identity is `list_id`; `id` is a pointer resolved at push time.
456    #[test]
457    fn canonical_strips_the_exception_pointer_but_keeps_the_reference() {
458        let r = Rule::from_value(json!({
459            "rule_id": "x",
460            "exceptions_list": [{
461                "id": "3724d409-4c0f-4630-a1ef-706499730808",
462                "list_id": "shared", "type": "detection", "namespace_type": "single"
463            }]
464        }))
465        .unwrap();
466        let c = canonical(&r);
467        let refs = c.as_map()["exceptions_list"].as_array().unwrap();
468        assert!(
469            refs[0].get("id").is_none(),
470            "the volatile pointer is stripped"
471        );
472        assert_eq!(refs[0]["list_id"], json!("shared"), "identity survives");
473        assert_eq!(refs[0]["namespace_type"], json!("single"));
474    }
475
476    /// The bug this closes: a rule promoted between stacks carries a pointer to an
477    /// object that does not exist on the target.
478    #[test]
479    fn two_stacks_ids_for_one_list_do_not_read_as_drift() {
480        let mk = |id: &str| {
481            Rule::from_value(json!({
482                "rule_id": "x",
483                "exceptions_list": [{"id": id, "list_id": "shared",
484                                     "type": "detection", "namespace_type": "single"}]
485            }))
486            .unwrap()
487        };
488        assert_eq!(
489            comparable(&mk("id-on-dev")),
490            comparable(&mk("id-on-prod")),
491            "the same list on two stacks must not read as drift"
492        );
493    }
494
495    #[test]
496    fn strip_exception_ids_strips_every_reference_not_just_the_first() {
497        let c = canonical(
498            &Rule::from_value(json!({
499                "rule_id": "x",
500                "exceptions_list": [
501                    {"id": "id-1", "list_id": "one"},
502                    {"id": "id-2", "list_id": "two"}
503                ]
504            }))
505            .unwrap(),
506        );
507        let refs = c.as_map()["exceptions_list"].as_array().unwrap();
508        assert_eq!(refs.len(), 2);
509        for entry in refs {
510            assert!(
511                entry.get("id").is_none(),
512                "every reference loses its pointer"
513            );
514        }
515        assert_eq!(refs[0]["list_id"], json!("one"));
516        assert_eq!(refs[1]["list_id"], json!("two"));
517    }
518
519    #[test]
520    fn a_rule_with_no_exceptions_is_untouched() {
521        let r = Rule::from_value(json!({"rule_id": "x", "name": "X"})).unwrap();
522        assert_eq!(canonical(&r).as_map().get("exceptions_list"), None);
523    }
524
525    #[test]
526    fn strip_exception_ids_is_idempotent() {
527        let mut r = Rule::from_value(json!({
528            "rule_id": "x",
529            "exceptions_list": [{"id": "a", "list_id": "l"}]
530        }))
531        .unwrap();
532        strip_exception_ids(&mut r);
533        let once = r.clone();
534        strip_exception_ids(&mut r);
535        assert_eq!(once, r);
536    }
537
538    #[test]
539    fn strip_exception_ids_skips_non_objects_and_absent_ids() {
540        let mut r = Rule::from_value(json!({
541            "rule_id": "x",
542            "exceptions_list": ["not an object", {"list_id": "already stripped"}]
543        }))
544        .unwrap();
545        strip_exception_ids(&mut r); // must not panic
546        let refs = r.as_map()["exceptions_list"].as_array().unwrap();
547        assert_eq!(
548            refs[0],
549            json!("not an object"),
550            "non-object entries are left alone"
551        );
552        assert_eq!(
553            refs[1],
554            json!({"list_id": "already stripped"}),
555            "an absent id is a no-op"
556        );
557    }
558
559    #[test]
560    fn canonical_list_strips_every_measured_volatile_field() {
561        let l = ExceptionList::from_value(json!({
562            "list_id": "l", "name": "L", "id": "server-id", "_version": "WzUsMV0=",
563            "tie_breaker_id": "tb", "version": 3,
564            "created_at": "2026-08-13T23:38:39.519Z", "created_by": "452295856",
565            "updated_at": "2026-08-13T23:38:39.519Z", "updated_by": "452295856",
566            "meta": {"zeta": 1, "alpha": 2},
567            "os_types": ["linux", "windows"]
568        }))
569        .unwrap();
570        let c = canonical_list(&l);
571        for f in LIST_VOLATILE_FIELDS {
572            assert!(!c.as_map().contains_key(f), "{f} should have been stripped");
573        }
574        assert_eq!(c.list_id().unwrap(), "l", "identity must survive");
575        let nested = c.as_map()["meta"].as_object().unwrap();
576        let keys: Vec<&String> = nested.keys().collect();
577        assert_eq!(keys, vec!["alpha", "zeta"], "nested keys are sorted");
578        let os = c.as_map()["os_types"].as_array().unwrap();
579        let os_strs: Vec<&str> = os.iter().filter_map(|v| v.as_str()).collect();
580        assert_eq!(
581            os_strs,
582            vec!["linux", "windows"],
583            "array order is preserved"
584        );
585    }
586
587    #[test]
588    fn canonical_item_strips_every_measured_volatile_field() {
589        let i = ExceptionItem::from_value(json!({
590            "item_id": "i", "list_id": "l", "name": "I", "id": "server-id",
591            "_version": "WzUsMV0=", "tie_breaker_id": "tb",
592            "created_at": "2026-08-13T23:38:39.519Z", "created_by": "452295856",
593            "updated_at": "2026-08-13T23:38:39.519Z", "updated_by": "452295856",
594            "meta": {"zeta": 1, "alpha": 2},
595            "entries": [{"z": 1}, {"a": 2}]
596        }))
597        .unwrap();
598        let c = canonical_item(&i);
599        for f in ITEM_VOLATILE_FIELDS {
600            assert!(!c.as_map().contains_key(f), "{f} should have been stripped");
601        }
602        assert_eq!(c.item_id().unwrap(), "i", "identity must survive");
603        let nested = c.as_map()["meta"].as_object().unwrap();
604        let keys: Vec<&String> = nested.keys().collect();
605        assert_eq!(keys, vec!["alpha", "zeta"], "nested keys are sorted");
606        let entries = c.as_map()["entries"].as_array().unwrap();
607        let first_key = entries[0].as_object().unwrap().keys().next().unwrap();
608        assert_eq!(
609            first_key, "z",
610            "array element order preserved, keys sorted within"
611        );
612    }
613
614    #[test]
615    fn canonical_item_strips_volatile_fields_inside_comments() {
616        let i = ExceptionItem::from_value(json!({
617            "item_id": "i", "list_id": "l",
618            "comments": [{
619                "id": "0b025f61-b0b9-4658-83cf-cbb581ad2358",
620                "comment": "first note",
621                "created_at": "2026-08-14T04:49:54.101Z",
622                "created_by": "452295856"
623            }]
624        }))
625        .unwrap();
626        let c = canonical_item(&i);
627        let comments = c.as_map()["comments"].as_array().unwrap();
628        let first = comments[0].as_object().unwrap();
629        for f in COMMENT_VOLATILE_FIELDS {
630            assert!(!first.contains_key(f), "{f} should have been stripped");
631        }
632        assert_eq!(
633            comments[0]["comment"],
634            json!("first note"),
635            "the author's text survives"
636        );
637    }
638
639    #[test]
640    fn sort_lists_orders_by_namespace_then_list_id() {
641        let mk = |ns: &str, id: &str| {
642            ExceptionList::from_value(json!({"list_id": id, "namespace_type": ns})).unwrap()
643        };
644        let mut lists = vec![mk("agnostic", "b"), mk("single", "a"), mk("agnostic", "a")];
645        sort_lists(&mut lists);
646        let order: Vec<(&str, &str)> = lists
647            .iter()
648            .map(|l| (l.namespace_type(), l.list_id().unwrap()))
649            .collect();
650        assert_eq!(
651            order,
652            vec![("agnostic", "a"), ("agnostic", "b"), ("single", "a")]
653        );
654    }
655
656    #[test]
657    fn sort_items_orders_by_list_then_item_id() {
658        let mk = |list: &str, item: &str| {
659            ExceptionItem::from_value(json!({"list_id": list, "item_id": item})).unwrap()
660        };
661        let mut items = vec![mk("b", "2"), mk("a", "2"), mk("b", "1")];
662        sort_items(&mut items);
663        let order: Vec<(&str, &str)> = items
664            .iter()
665            .map(|i| (i.list_id().unwrap(), i.item_id().unwrap()))
666            .collect();
667        assert_eq!(order, vec![("a", "2"), ("b", "1"), ("b", "2")]);
668    }
669
670    #[test]
671    fn sort_lists_puts_unreadable_list_id_last_without_panicking() {
672        // Transparent `Deserialize` can create this; `from_value` rejects it.
673        let bad: ExceptionList =
674            serde_json::from_value(json!({"namespace_type": "single"})).unwrap();
675        let good_c =
676            ExceptionList::from_value(json!({"list_id": "c", "namespace_type": "single"})).unwrap();
677        let good_a =
678            ExceptionList::from_value(json!({"list_id": "a", "namespace_type": "single"})).unwrap();
679        let mut lists = vec![good_c, bad, good_a];
680        sort_lists(&mut lists);
681        assert_eq!(lists[0].list_id().unwrap(), "a");
682        assert_eq!(lists[1].list_id().unwrap(), "c");
683        assert!(lists[2].list_id().is_err(), "unreadable list_id sorts last");
684    }
685
686    #[test]
687    fn sort_items_puts_unreadable_list_id_last_without_panicking() {
688        // Transparent `Deserialize` can create this; `from_value` rejects it.
689        let bad: ExceptionItem = serde_json::from_value(json!({"item_id": "i"})).unwrap();
690        let good_c = ExceptionItem::from_value(json!({"list_id": "c", "item_id": "i"})).unwrap();
691        let good_a = ExceptionItem::from_value(json!({"list_id": "a", "item_id": "i"})).unwrap();
692        let mut items = vec![good_c, bad, good_a];
693        sort_items(&mut items);
694        assert_eq!(items[0].list_id().unwrap(), "a");
695        assert_eq!(items[1].list_id().unwrap(), "c");
696        assert!(items[2].list_id().is_err(), "unreadable list_id sorts last");
697    }
698}