1use 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
13const CLOUD_API_KEY_TAG: &str = "Missing Elastic Cloud API Key";
17
18pub 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
30pub 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
45pub 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
53fn 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 Value::Array(a) => Value::Array(a.iter().map(sort_value).collect()),
72 other => other.clone(),
73 }
74}
75
76pub 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
85pub 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 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
100pub 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
110pub 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 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
131pub 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
143pub 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
155pub 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 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 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 #[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 #[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 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 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 #[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 #[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); 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 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 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}