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