From d69ebe49347f5508ff54cd750fdd629f94533f10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Fri, 23 Jan 2026 14:51:32 +0100 Subject: [PATCH 1/4] feat: add quirk to dedup and sort sequence sets during encoding --- imap-codec/Cargo.toml | 3 ++ imap-codec/src/codec/encode.rs | 71 ++++++++++++++++++++++++++++++++++ imap-types/src/sequence.rs | 60 +++++++++++++++++++++++++++- 3 files changed, 133 insertions(+), 1 deletion(-) diff --git a/imap-codec/Cargo.toml b/imap-codec/Cargo.toml index d4588145..2448ebe1 100644 --- a/imap-codec/Cargo.toml +++ b/imap-codec/Cargo.toml @@ -35,6 +35,7 @@ quirk = [ "quirk_spaces_between_addresses", "quirk_empty_continue_req", "quirk_body_fld_enc_nil_to_empty", + "quirk_dedup_sort_seq_encoding", ] # Make `\r` in `\r\n` optional. quirk_crlf_relaxed = [] @@ -60,6 +61,8 @@ quirk_trailing_space_search = [] quirk_empty_continue_req = [] # Encode NIL `body-fld-enc` as empty string. quirk_body_fld_enc_nil_to_empty = [] +# Dedup and sort sequence sets during encoding +quirk_dedup_sort_seq_encoding = [] # arbitrary = ["imap-types/arbitrary"] diff --git a/imap-codec/src/codec/encode.rs b/imap-codec/src/codec/encode.rs index 92a2e516..f7859007 100644 --- a/imap-codec/src/codec/encode.rs +++ b/imap-codec/src/codec/encode.rs @@ -1024,9 +1024,49 @@ impl EncodeIntoContext for SearchKey<'_> { } impl EncodeIntoContext for SequenceSet { + #[cfg(not(feature = "quirk_dedup_sort_seq_encoding"))] fn encode_ctx(&self, ctx: &mut EncodeContext) -> std::io::Result<()> { join_serializable(self.0.as_ref(), b",", ctx) } + + #[cfg(feature = "quirk_dedup_sort_seq_encoding")] + fn encode_ctx(&self, ctx: &mut EncodeContext) -> std::io::Result<()> { + let mut seq_set = self.0.as_ref().to_vec(); + + if seq_set.len() == 1 { + return seq_set.remove(0).encode_ctx(ctx); + } + + seq_set.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + + let mut a = 0; + let mut b = 1; + + while b < seq_set.len() { + if seq_set[a].partial_cmp(&seq_set[b]).is_some() { + a += 1; + b += 1; + continue; + } + + match (&seq_set[a], &seq_set[b]) { + (Sequence::Single(_), _) => { + seq_set.remove(a); + } + (Sequence::Range(_, _), Sequence::Single(_)) => { + seq_set.remove(b); + } + (Sequence::Range(a1, a2), Sequence::Range(b1, b2)) => { + let min = a1.clone().min(a2.clone()).min(b1.clone()).min(b2.clone()); + let max = a1.clone().max(a2.clone()).max(b1.clone()).max(b2.clone()); + let _ = std::mem::replace(&mut seq_set[a], Sequence::Range(min, max)); + seq_set.remove(b); + } + } + } + + join_serializable(&seq_set, b",", ctx) + } } impl EncodeIntoContext for Sequence { @@ -1034,6 +1074,11 @@ impl EncodeIntoContext for Sequence { match self { Sequence::Single(seq_no) => seq_no.encode_ctx(ctx), Sequence::Range(from, to) => { + #[cfg(feature = "quirk_dedup_sort_seq_encoding")] + let from = from.min(to); + #[cfg(feature = "quirk_dedup_sort_seq_encoding")] + let to = from.max(to); + from.encode_ctx(ctx)?; ctx.write_all(b":")?; to.encode_ctx(ctx) @@ -2128,6 +2173,32 @@ mod tests { use super::*; + #[cfg(feature = "quirk_dedup_sort_seq_encoding")] + #[test] + fn test_sequence_reordering() { + let tests = [ + ("1,2,3,4", "1,2,3,4"), + ("3,1,2,4", "1,2,3,4"), + ("3:1,5", "1:3,5"), + ("5,3:1", "1:3,5"), + ("3,3:1", "1:3"), + ("3:1,1", "1:3"), + ("3:1,2:5", "1:5"), + ("3:1,4:9", "1:3,4:9"), + ("9:4,3:1", "1:3,4:9"), + ("8:10,3:1,2:5,9", "1:5,8:10"), + ]; + + for (expected, got) in tests { + let mut ctx = EncodeContext::default(); + SequenceSet::try_from(expected) + .unwrap() + .encode_ctx(&mut ctx) + .unwrap(); + assert_eq!(got, escape_byte_string(&ctx.accumulator)); + } + } + #[test] fn test_api_encoder_usage() { let cmd = Command::new( diff --git a/imap-types/src/sequence.rs b/imap-types/src/sequence.rs index dbd0cf8f..957fbc22 100644 --- a/imap-types/src/sequence.rs +++ b/imap-types/src/sequence.rs @@ -1,5 +1,5 @@ use std::{ - cmp::max, + cmp::{Ordering, max}, collections::VecDeque, fmt::Debug, iter::Rev, @@ -140,6 +140,47 @@ pub enum Sequence { Range(SeqOrUid, SeqOrUid), } +impl PartialOrd for Sequence { + fn partial_cmp(&self, other: &Self) -> Option { + match (self, other) { + (Sequence::Single(a), Sequence::Single(b)) => a.partial_cmp(b), + (Sequence::Single(a), Sequence::Range(b1, b2)) => { + if a < b1.min(b2) { + return Some(Ordering::Less); + } + + if a > b1.max(b2) { + return Some(Ordering::Greater); + } + + None + } + (Sequence::Range(a1, a2), Sequence::Single(b)) => { + if b < a1.min(a2) { + return Some(Ordering::Greater); + } + + if b > a1.max(a2) { + return Some(Ordering::Less); + } + + None + } + (Sequence::Range(a1, a2), Sequence::Range(b1, b2)) => { + if a1.max(a2) < b1 && a1.max(a2) < b2 { + return Some(Ordering::Less); + } + + if a1.min(a2) > b1 && a1.min(a2) > b2 { + return Some(Ordering::Greater); + } + + None + } + } + } +} + impl From for Sequence { fn from(value: SeqOrUid) -> Self { Self::Single(value) @@ -192,6 +233,23 @@ pub enum SeqOrUid { Asterisk, } +impl Ord for SeqOrUid { + fn cmp(&self, other: &Self) -> Ordering { + match (self, other) { + (SeqOrUid::Asterisk, SeqOrUid::Asterisk) => Ordering::Equal, + (_, SeqOrUid::Asterisk) => Ordering::Greater, + (SeqOrUid::Asterisk, _) => Ordering::Less, + (SeqOrUid::Value(a), SeqOrUid::Value(b)) => a.cmp(b), + } + } +} + +impl PartialOrd for SeqOrUid { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + impl From for SeqOrUid { fn from(value: NonZeroU32) -> Self { Self::Value(value) From 71f5ba048fc42f9617819b0e43172ece31cb4695 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Mon, 26 Jan 2026 21:45:27 +0100 Subject: [PATCH 2/4] refactor: move logic into normalize functions --- imap-codec/Cargo.toml | 6 +- imap-codec/src/codec/encode.rs | 87 +++++-------------------- imap-types/src/sequence.rs | 112 +++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 74 deletions(-) diff --git a/imap-codec/Cargo.toml b/imap-codec/Cargo.toml index 2448ebe1..ac5ff57f 100644 --- a/imap-codec/Cargo.toml +++ b/imap-codec/Cargo.toml @@ -35,7 +35,7 @@ quirk = [ "quirk_spaces_between_addresses", "quirk_empty_continue_req", "quirk_body_fld_enc_nil_to_empty", - "quirk_dedup_sort_seq_encoding", + "quirk_always_normalize_sequence_sets", ] # Make `\r` in `\r\n` optional. quirk_crlf_relaxed = [] @@ -61,8 +61,8 @@ quirk_trailing_space_search = [] quirk_empty_continue_req = [] # Encode NIL `body-fld-enc` as empty string. quirk_body_fld_enc_nil_to_empty = [] -# Dedup and sort sequence sets during encoding -quirk_dedup_sort_seq_encoding = [] +# Always normalize sequence sets during encoding +quirk_always_normalize_sequence_sets = [] # arbitrary = ["imap-types/arbitrary"] diff --git a/imap-codec/src/codec/encode.rs b/imap-codec/src/codec/encode.rs index f7859007..64332270 100644 --- a/imap-codec/src/codec/encode.rs +++ b/imap-codec/src/codec/encode.rs @@ -1024,61 +1024,32 @@ impl EncodeIntoContext for SearchKey<'_> { } impl EncodeIntoContext for SequenceSet { - #[cfg(not(feature = "quirk_dedup_sort_seq_encoding"))] fn encode_ctx(&self, ctx: &mut EncodeContext) -> std::io::Result<()> { - join_serializable(self.0.as_ref(), b",", ctx) - } - - #[cfg(feature = "quirk_dedup_sort_seq_encoding")] - fn encode_ctx(&self, ctx: &mut EncodeContext) -> std::io::Result<()> { - let mut seq_set = self.0.as_ref().to_vec(); - - if seq_set.len() == 1 { - return seq_set.remove(0).encode_ctx(ctx); - } - - seq_set.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + #[cfg(feature = "quirk_always_normalize_sequence_sets")] + let mut set = self.clone(); + #[cfg(feature = "quirk_always_normalize_sequence_sets")] + set.normalize(); - let mut a = 0; - let mut b = 1; - - while b < seq_set.len() { - if seq_set[a].partial_cmp(&seq_set[b]).is_some() { - a += 1; - b += 1; - continue; - } - - match (&seq_set[a], &seq_set[b]) { - (Sequence::Single(_), _) => { - seq_set.remove(a); - } - (Sequence::Range(_, _), Sequence::Single(_)) => { - seq_set.remove(b); - } - (Sequence::Range(a1, a2), Sequence::Range(b1, b2)) => { - let min = a1.clone().min(a2.clone()).min(b1.clone()).min(b2.clone()); - let max = a1.clone().max(a2.clone()).max(b1.clone()).max(b2.clone()); - let _ = std::mem::replace(&mut seq_set[a], Sequence::Range(min, max)); - seq_set.remove(b); - } - } - } + #[cfg(not(feature = "quirk_always_normalize_sequence_sets"))] + let set = self; - join_serializable(&seq_set, b",", ctx) + join_serializable(set.0.as_ref(), b",", ctx) } } impl EncodeIntoContext for Sequence { fn encode_ctx(&self, ctx: &mut EncodeContext) -> std::io::Result<()> { - match self { + #[cfg(feature = "quirk_always_normalize_sequence_sets")] + let mut seq = self.clone(); + #[cfg(feature = "quirk_always_normalize_sequence_sets")] + seq.normalize(); + + #[cfg(not(feature = "quirk_always_normalize_sequence_sets"))] + let seq = self; + + match seq { Sequence::Single(seq_no) => seq_no.encode_ctx(ctx), Sequence::Range(from, to) => { - #[cfg(feature = "quirk_dedup_sort_seq_encoding")] - let from = from.min(to); - #[cfg(feature = "quirk_dedup_sort_seq_encoding")] - let to = from.max(to); - from.encode_ctx(ctx)?; ctx.write_all(b":")?; to.encode_ctx(ctx) @@ -2173,32 +2144,6 @@ mod tests { use super::*; - #[cfg(feature = "quirk_dedup_sort_seq_encoding")] - #[test] - fn test_sequence_reordering() { - let tests = [ - ("1,2,3,4", "1,2,3,4"), - ("3,1,2,4", "1,2,3,4"), - ("3:1,5", "1:3,5"), - ("5,3:1", "1:3,5"), - ("3,3:1", "1:3"), - ("3:1,1", "1:3"), - ("3:1,2:5", "1:5"), - ("3:1,4:9", "1:3,4:9"), - ("9:4,3:1", "1:3,4:9"), - ("8:10,3:1,2:5,9", "1:5,8:10"), - ]; - - for (expected, got) in tests { - let mut ctx = EncodeContext::default(); - SequenceSet::try_from(expected) - .unwrap() - .encode_ctx(&mut ctx) - .unwrap(); - assert_eq!(got, escape_byte_string(&ctx.accumulator)); - } - } - #[test] fn test_api_encoder_usage() { let cmd = Command::new( diff --git a/imap-types/src/sequence.rs b/imap-types/src/sequence.rs index 957fbc22..d7f0e2a0 100644 --- a/imap-types/src/sequence.rs +++ b/imap-types/src/sequence.rs @@ -3,6 +3,7 @@ use std::{ collections::VecDeque, fmt::Debug, iter::Rev, + mem, num::NonZeroU32, ops::{Range, RangeFrom, RangeFull, RangeInclusive, RangeTo, RangeToInclusive}, str::FromStr, @@ -34,6 +35,50 @@ pub const MAX: NonZeroU32 = match NonZeroU32::new(u32::MAX) { #[derive(Debug, Clone, PartialEq, Eq, Hash, ToStatic)] pub struct SequenceSet(pub Vec1); +impl SequenceSet { + pub fn normalize(&mut self) -> &mut Self { + for seq in &mut self.0.0 { + seq.normalize(); + } + + let set = &mut self.0.0; + + if set.len() == 1 { + return self; + } + + set.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); + + let mut a = 0; + let mut b = 1; + + while b < set.len() { + if set[a].partial_cmp(&set[b]).is_some() { + a += 1; + b += 1; + continue; + } + + match (&set[a], &set[b]) { + (Sequence::Single(_), _) => { + set.remove(a); + } + (Sequence::Range(_, _), Sequence::Single(_)) => { + set.remove(b); + } + (Sequence::Range(a1, a2), Sequence::Range(b1, b2)) => { + let min = a1.clone().min(a2.clone()).min(b1.clone()).min(b2.clone()); + let max = a1.clone().max(a2.clone()).max(b1.clone()).max(b2.clone()); + let _ = mem::replace(&mut set[a], Sequence::Range(min, max)); + set.remove(b); + } + } + } + + self + } +} + impl From for SequenceSet { fn from(sequence: Sequence) -> Self { Self(Vec1::from(sequence)) @@ -140,6 +185,30 @@ pub enum Sequence { Range(SeqOrUid, SeqOrUid), } +impl Sequence { + pub fn normalize(&mut self) -> &mut Self { + match self { + Sequence::Single(SeqOrUid::Asterisk) => { + let begin = NonZeroU32::new(1).unwrap(); + let range = Sequence::Range(begin.into(), SeqOrUid::Asterisk); + let _ = mem::replace(self, range); + } + Sequence::Range(SeqOrUid::Value(a), SeqOrUid::Value(b)) if *a == *b => { + let range = Sequence::Single(a.clone().into()); + let _ = mem::replace(self, range); + } + Sequence::Range(SeqOrUid::Value(a), SeqOrUid::Value(b)) if *a > *b => { + mem::swap(a, b); + } + _ => { + // already normalized + } + } + + self + } +} + impl PartialOrd for Sequence { fn partial_cmp(&self, other: &Self) -> Option { match (self, other) { @@ -952,4 +1021,47 @@ mod tests { assert_eq!(naive, clean); } } + + #[test] + fn normalize_sequence() { + let tests = vec![ + ("*", "1:*"), + ("1:*", "1:*"), + ("*:1", "*:1"), + ("1", "1"), + ("1:1", "1"), + ("1:2", "1:2"), + ("2:1", "1:2"), + ]; + + for (test, expected) in tests { + let expected = Sequence::try_from(expected).unwrap(); + let mut got = Sequence::try_from(test).unwrap(); + got.normalize(); + assert_eq!(expected, got); + } + } + + #[test] + fn normalize_sequence_set() { + let tests = [ + ("1,2,3,4", "1,2,3,4"), + ("3,1,2,4", "1,2,3,4"), + ("3:1,5", "1:3,5"), + ("5,3:1", "1:3,5"), + ("3,3:1", "1:3"), + ("3:1,1", "1:3"), + ("3:1,2:5", "1:5"), + ("3:1,4:9", "1:3,4:9"), + ("9:4,3:1", "1:3,4:9"), + ("8:10,3:1,2:5,9", "1:5,8:10"), + ]; + + for (test, expected) in tests { + let expected = SequenceSet::try_from(expected).unwrap(); + let mut got = SequenceSet::try_from(test).unwrap(); + got.normalize(); + assert_eq!(expected, got); + } + } } From 49a107466d6d5f620d440ed79f79a9d2fabc2bb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Thu, 5 Feb 2026 14:01:13 +0100 Subject: [PATCH 3/4] refactor: improve merge logic --- imap-types/src/sequence.rs | 176 +++++++++++++++++++++++++++---------- 1 file changed, 131 insertions(+), 45 deletions(-) diff --git a/imap-types/src/sequence.rs b/imap-types/src/sequence.rs index d7f0e2a0..879f1227 100644 --- a/imap-types/src/sequence.rs +++ b/imap-types/src/sequence.rs @@ -1,6 +1,6 @@ use std::{ cmp::{Ordering, max}, - collections::VecDeque, + collections::{HashSet, VecDeque}, fmt::Debug, iter::Rev, mem, @@ -37,44 +37,71 @@ pub struct SequenceSet(pub Vec1); impl SequenceSet { pub fn normalize(&mut self) -> &mut Self { + let mut singles = HashSet::with_capacity(self.0.0.len()); + let mut ranges = Vec::with_capacity(self.0.0.len()); + + // First, normalize all sequences for seq in &mut self.0.0 { - seq.normalize(); + match seq.normalize() { + Sequence::Single(id) => { + singles.insert(id.to_non_zero_u32()); + // Push a 1-lenth range so they can merge nicely + // later on + ranges.push(id.to_non_zero_u32()..id.to_non_zero_u32().saturating_add(1)); + } + Sequence::Range(a, b) => { + ranges.push(a.to_non_zero_u32()..b.to_non_zero_u32().saturating_add(1)); + } + } } - let set = &mut self.0.0; - - if set.len() == 1 { - return self; + // TODO: Improve this loop, for eg. with a .retain(). + for single in singles.clone() { + for range in &mut ranges { + if single.get() == range.start.get().max(2) - 1 { + singles.remove(&single); + range.start = single; + } else if single.get() == range.end.get() { + singles.remove(&single); + range.end = single; + } else if range.contains(&single) { + singles.remove(&single); + } + } } - set.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); - - let mut a = 0; - let mut b = 1; - - while b < set.len() { - if set[a].partial_cmp(&set[b]).is_some() { - a += 1; - b += 1; - continue; - } + // Rebuild the inner sequence set with merged ranges + self.0.0.clear(); + for range in merge_ranges(ranges) { + singles.retain(|single| !range.contains(single)); - match (&set[a], &set[b]) { - (Sequence::Single(_), _) => { - set.remove(a); + match (range.start, range.end) { + (a, b) if a == b || a.saturating_add(1) == b => { + let a = NonZeroU32::new(a.get()).unwrap(); + self.0.0.push(Sequence::Single(a.into())); } - (Sequence::Range(_, _), Sequence::Single(_)) => { - set.remove(b); + (a, NonZeroU32::MAX) => { + self.0.0.push(Sequence::Range(a.into(), SeqOrUid::Asterisk)) } - (Sequence::Range(a1, a2), Sequence::Range(b1, b2)) => { - let min = a1.clone().min(a2.clone()).min(b1.clone()).min(b2.clone()); - let max = a1.clone().max(a2.clone()).max(b1.clone()).max(b2.clone()); - let _ = mem::replace(&mut set[a], Sequence::Range(min, max)); - set.remove(b); + (a, b) => { + let b = NonZeroU32::new(b.get().max(2) - 1).unwrap(); + self.0.0.push(Sequence::Range(a.into(), b.into())); } } } + // Add remaining singles that don't belong to any range to the + // sequence set + for single in singles { + self.0.0.push(single.into()); + } + + // Sort the merged sequence set + self.0.0.sort_by_key(|seq| match seq { + Sequence::Single(a) => a.to_non_zero_u32(), + Sequence::Range(a, _) => a.to_non_zero_u32(), + }); + self } } @@ -188,22 +215,17 @@ pub enum Sequence { impl Sequence { pub fn normalize(&mut self) -> &mut Self { match self { - Sequence::Single(SeqOrUid::Asterisk) => { - let begin = NonZeroU32::new(1).unwrap(); - let range = Sequence::Range(begin.into(), SeqOrUid::Asterisk); - let _ = mem::replace(self, range); - } - Sequence::Range(SeqOrUid::Value(a), SeqOrUid::Value(b)) if *a == *b => { - let range = Sequence::Single(a.clone().into()); + Sequence::Range(a, b) if *a == *b => { + let range = Sequence::Single(*a); let _ = mem::replace(self, range); } - Sequence::Range(SeqOrUid::Value(a), SeqOrUid::Value(b)) if *a > *b => { + Sequence::Range(a, b) if *a > *b => { mem::swap(a, b); } _ => { // already normalized } - } + }; self } @@ -302,13 +324,22 @@ pub enum SeqOrUid { Asterisk, } +impl SeqOrUid { + pub fn to_non_zero_u32(&self) -> NonZeroU32 { + match self { + SeqOrUid::Value(n) => *n, + SeqOrUid::Asterisk => NonZeroU32::MAX, + } + } +} + impl Ord for SeqOrUid { fn cmp(&self, other: &Self) -> Ordering { match (self, other) { - (SeqOrUid::Asterisk, SeqOrUid::Asterisk) => Ordering::Equal, - (_, SeqOrUid::Asterisk) => Ordering::Greater, - (SeqOrUid::Asterisk, _) => Ordering::Less, (SeqOrUid::Value(a), SeqOrUid::Value(b)) => a.cmp(b), + (SeqOrUid::Asterisk, SeqOrUid::Asterisk) => Ordering::Equal, + (_, SeqOrUid::Asterisk) => Ordering::Less, + (SeqOrUid::Asterisk, _) => Ordering::Greater, } } } @@ -321,7 +352,11 @@ impl PartialOrd for SeqOrUid { impl From for SeqOrUid { fn from(value: NonZeroU32) -> Self { - Self::Value(value) + if value == NonZeroU32::MAX { + Self::Asterisk + } else { + Self::Value(value) + } } } @@ -742,6 +777,28 @@ fn cleanup(remaining: VecDeque<(u32, u32)>) -> VecDeque<(u32, u32)> { stack } +fn merge_ranges(mut ranges: Vec>) -> Vec> { + if ranges.is_empty() { + return ranges; + } + + ranges.sort_unstable_by_key(|r| r.start); + + let mut merged = Vec::with_capacity(ranges.len()); + merged.push(ranges[0].clone()); + + for range in ranges.into_iter().skip(1) { + let last = merged.last_mut().unwrap(); + if range.start < last.end { + last.end = last.end.max(range.end); + } else { + merged.push(range); + } + } + + merged +} + #[cfg(test)] mod tests { use std::num::NonZeroU32; @@ -1022,12 +1079,40 @@ mod tests { } } + #[test] + fn ordering_sequence() { + let tests = vec![ + ("1", "1", Some(Ordering::Equal)), + ("1", "2", Some(Ordering::Less)), + ("2", "1", Some(Ordering::Greater)), + ("1", "2:4", Some(Ordering::Less)), + ("2", "2:4", None), + ("4", "2:4", None), + ("7", "2:4", Some(Ordering::Greater)), + ("2:4", "1", Some(Ordering::Greater)), + ("2:4", "2", None), + ("2:4", "4", None), + ("2:4", "7", Some(Ordering::Less)), + ("1:2", "3:4", Some(Ordering::Less)), + ("3:4", "1:2", Some(Ordering::Greater)), + ("1:2", "2:4", None), + ("2:4", "3:8", None), + ]; + + for (a, b, expected) in tests { + let a = Sequence::try_from(a).unwrap(); + let b = Sequence::try_from(b).unwrap(); + let got = a.partial_cmp(&b); + + assert_eq!(expected, got); + } + } + #[test] fn normalize_sequence() { let tests = vec![ - ("*", "1:*"), ("1:*", "1:*"), - ("*:1", "*:1"), + ("*:1", "1:*"), ("1", "1"), ("1:1", "1"), ("1:2", "1:2"), @@ -1045,8 +1130,9 @@ mod tests { #[test] fn normalize_sequence_set() { let tests = [ - ("1,2,3,4", "1,2,3,4"), - ("3,1,2,4", "1,2,3,4"), + ("1,2,3,4", "1:4"), + ("4,1,3,2", "1:4"), + ("3,1,2,4", "1:4"), ("3:1,5", "1:3,5"), ("5,3:1", "1:3,5"), ("3,3:1", "1:3"), @@ -1054,7 +1140,7 @@ mod tests { ("3:1,2:5", "1:5"), ("3:1,4:9", "1:3,4:9"), ("9:4,3:1", "1:3,4:9"), - ("8:10,3:1,2:5,9", "1:5,8:10"), + ("8:10,12,3:1,2:5,9", "1:5,8:10,12"), ]; for (test, expected) in tests { From cfba6430dca44815518d94b882a1523ae03ee34d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Thu, 12 Feb 2026 11:42:16 +0100 Subject: [PATCH 4/4] fix: make contiguous ranges merge properly --- imap-types/src/sequence.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/imap-types/src/sequence.rs b/imap-types/src/sequence.rs index 879f1227..73d70b0c 100644 --- a/imap-types/src/sequence.rs +++ b/imap-types/src/sequence.rs @@ -789,7 +789,7 @@ fn merge_ranges(mut ranges: Vec>) -> Vec> { for range in ranges.into_iter().skip(1) { let last = merged.last_mut().unwrap(); - if range.start < last.end { + if range.start <= last.end { last.end = last.end.max(range.end); } else { merged.push(range); @@ -1138,8 +1138,9 @@ mod tests { ("3,3:1", "1:3"), ("3:1,1", "1:3"), ("3:1,2:5", "1:5"), - ("3:1,4:9", "1:3,4:9"), - ("9:4,3:1", "1:3,4:9"), + ("3:1,4:9", "1:9"), + ("9:4,3:1", "1:9"), + ("9:5,3:1", "1:3,5:9"), ("8:10,12,3:1,2:5,9", "1:5,8:10,12"), ];