jmap_mail_types/
keyword.rs1use std::borrow::Borrow;
18use std::fmt;
19use std::ops::Deref;
20
21use serde::{Deserialize, Serialize};
22
23#[non_exhaustive]
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum KeywordError {
27 Empty,
29 InvalidChar(char),
32 ReservedPrefix,
35}
36
37impl fmt::Display for KeywordError {
38 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39 match self {
40 KeywordError::Empty => f.write_str("keyword must not be empty"),
41 KeywordError::InvalidChar(c) => write!(f, "keyword contains invalid character {:?}", c),
42 KeywordError::ReservedPrefix => f.write_str(
43 "custom keywords must not begin with '$' (reserved for system keywords)",
44 ),
45 }
46 }
47}
48
49impl std::error::Error for KeywordError {}
50
51const SYSTEM_KEYWORDS: &[&str] = &[
54 "$draft",
55 "$seen",
56 "$flagged",
57 "$answered",
58 "$forwarded",
59 "$phishing",
60 "$junk",
61 "$notjunk",
62];
63
64#[non_exhaustive]
89#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
90#[serde(transparent)]
91pub struct Keyword(String);
92
93impl Keyword {
94 pub fn new(s: impl Into<String>) -> Self {
96 Keyword(s.into())
97 }
98
99 pub fn try_new(s: impl Into<String>) -> Result<Self, KeywordError> {
109 let s: String = s.into();
110 if s.is_empty() {
111 return Err(KeywordError::Empty);
112 }
113 for ch in s.chars() {
115 let b = ch as u32;
116 if !(0x21..=0x7E).contains(&b) {
117 return Err(KeywordError::InvalidChar(ch));
118 }
119 }
120 if s.starts_with('$') {
122 let lower = s.to_ascii_lowercase();
123 if !SYSTEM_KEYWORDS.contains(&lower.as_str()) {
124 return Err(KeywordError::ReservedPrefix);
125 }
126 }
127 Ok(Keyword(s))
128 }
129}
130
131impl From<&str> for Keyword {
132 fn from(s: &str) -> Self {
133 Keyword(s.to_owned())
134 }
135}
136
137impl From<String> for Keyword {
138 fn from(s: String) -> Self {
139 Keyword(s)
140 }
141}
142
143impl AsRef<str> for Keyword {
144 fn as_ref(&self) -> &str {
145 &self.0
146 }
147}
148
149impl Borrow<str> for Keyword {
150 fn borrow(&self) -> &str {
151 &self.0
152 }
153}
154
155impl Deref for Keyword {
156 type Target = str;
157 fn deref(&self) -> &str {
158 &self.0
159 }
160}
161
162impl fmt::Display for Keyword {
163 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164 f.write_str(&self.0)
165 }
166}
167
168pub const DRAFT: &str = "$draft";
170
171pub const SEEN: &str = "$seen";
173
174pub const FLAGGED: &str = "$flagged";
176
177pub const ANSWERED: &str = "$answered";
179
180pub const FORWARDED: &str = "$forwarded";
182
183pub const PHISHING: &str = "$phishing";
185
186pub const JUNK: &str = "$junk";
188
189pub const NOT_JUNK: &str = "$notjunk";
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
198 fn keyword_wire_strings() {
199 assert_eq!(DRAFT, "$draft");
200 assert_eq!(SEEN, "$seen");
201 assert_eq!(FLAGGED, "$flagged");
202 assert_eq!(ANSWERED, "$answered");
203 assert_eq!(FORWARDED, "$forwarded");
204 assert_eq!(PHISHING, "$phishing");
205 assert_eq!(JUNK, "$junk");
206 assert_eq!(NOT_JUNK, "$notjunk");
207 }
208
209 #[test]
212 fn keyword_usable_as_hashmap_key() {
213 let mut keywords = std::collections::BTreeMap::new();
214 keywords.insert(Keyword::from(SEEN), true);
215 keywords.insert(Keyword::from(FLAGGED), true);
216 assert!(keywords.contains_key(SEEN));
217 assert!(keywords.contains_key(FLAGGED));
218 assert!(!keywords.contains_key(DRAFT));
219 }
220
221 #[test]
223 fn keyword_roundtrips_as_json_string() {
224 let kw = Keyword::from(SEEN);
225 let json = serde_json::to_string(&kw).expect("serialize");
226 assert_eq!(json, "\"$seen\"");
227 let back: Keyword = serde_json::from_str(&json).expect("deserialize");
228 assert_eq!(back, kw);
229 }
230
231 #[test]
233 fn keyword_construction() {
234 let a = Keyword::new("$seen");
235 let b = Keyword::from("$seen");
236 let c = Keyword::from("$seen".to_owned());
237 assert_eq!(a, b);
238 assert_eq!(b, c);
239 assert_eq!(a.as_ref(), "$seen");
240 assert_eq!(a.to_string(), "$seen");
241 }
242
243 #[test]
247 fn try_new_empty_fails() {
248 assert_eq!(Keyword::try_new(""), Err(KeywordError::Empty));
249 }
250
251 #[test]
253 fn try_new_space_fails() {
254 let err = Keyword::try_new("has space").unwrap_err();
255 assert_eq!(err, KeywordError::InvalidChar(' '));
256 }
257
258 #[test]
260 fn try_new_tab_fails() {
261 let err = Keyword::try_new("has\ttab").unwrap_err();
262 assert_eq!(err, KeywordError::InvalidChar('\t'));
263 }
264
265 #[test]
267 fn try_new_control_char_fails() {
268 let err = Keyword::try_new("has\x01ctrl").unwrap_err();
269 assert_eq!(err, KeywordError::InvalidChar('\x01'));
270 }
271
272 #[test]
274 fn try_new_unknown_dollar_prefix_fails() {
275 let err = Keyword::try_new("$unknown").unwrap_err();
276 assert_eq!(err, KeywordError::ReservedPrefix);
277 }
278
279 #[test]
281 fn try_new_all_system_keywords_succeed() {
282 for kw in &[
283 "$draft",
284 "$seen",
285 "$flagged",
286 "$answered",
287 "$forwarded",
288 "$phishing",
289 "$junk",
290 "$notjunk",
291 ] {
292 Keyword::try_new(*kw)
293 .unwrap_or_else(|e| panic!("system keyword {kw:?} must succeed: {e}"));
294 }
295 }
296
297 #[test]
299 fn try_new_system_keyword_case_insensitive() {
300 Keyword::try_new("$SEEN").expect("$SEEN (case variant of $seen) must succeed");
303 }
304
305 #[test]
307 fn try_new_custom_keyword_succeeds() {
308 let kw = Keyword::try_new("project-alpha").expect("valid custom keyword must succeed");
309 assert_eq!(kw.as_ref(), "project-alpha");
310 }
311
312 #[test]
314 fn keyword_error_implements_error() {
315 let e = Keyword::try_new("").unwrap_err();
316 let _: &dyn std::error::Error = &e;
317 assert!(!e.to_string().is_empty());
318 }
319}