1use std::{collections::BTreeMap, error::Error, fmt, str::FromStr};
6
7#[cfg(feature = "compat-tag-info")]
8use ruma_common::serde::deserialize_as_optional_number_or_string;
9use ruma_common::serde::deserialize_cow_str;
10use ruma_macros::EventContent;
11use serde::{Deserialize, Serialize};
12
13use crate::PrivOwnedStr;
14
15pub type Tags = BTreeMap<TagName, TagInfo>;
17
18#[derive(Clone, Debug, Deserialize, Serialize, EventContent)]
22#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
23#[ruma_event(type = "m.tag", kind = RoomAccountData)]
24pub struct TagEventContent {
25 pub tags: Tags,
27}
28
29impl TagEventContent {
30 pub fn new(tags: Tags) -> Self {
32 Self { tags }
33 }
34}
35
36impl From<Tags> for TagEventContent {
37 fn from(tags: Tags) -> Self {
38 Self::new(tags)
39 }
40}
41
42#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
44pub struct UserTagName {
45 name: String,
46}
47
48impl AsRef<str> for UserTagName {
49 fn as_ref(&self) -> &str {
50 &self.name
51 }
52}
53
54impl FromStr for UserTagName {
55 type Err = InvalidUserTagName;
56
57 fn from_str(s: &str) -> Result<Self, Self::Err> {
58 if s.starts_with("u.") {
59 Ok(Self { name: s.into() })
60 } else {
61 Err(InvalidUserTagName)
62 }
63 }
64}
65
66#[derive(Debug)]
69#[allow(clippy::exhaustive_structs)]
70pub struct InvalidUserTagName;
71
72impl fmt::Display for InvalidUserTagName {
73 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74 write!(f, "missing 'u.' prefix in UserTagName")
75 }
76}
77
78impl Error for InvalidUserTagName {}
79
80#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
82#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
83pub enum TagName {
84 Favorite,
88
89 LowPriority,
91
92 ServerNotice,
95
96 User(UserTagName),
98
99 #[doc(hidden)]
101 _Custom(PrivOwnedStr),
102}
103
104impl TagName {
105 pub fn display_name(&self) -> &str {
110 match self {
111 Self::_Custom(s) => {
112 let start = s.0.rfind('.').map(|p| p + 1).unwrap_or(0);
113 &self.as_ref()[start..]
114 }
115 _ => &self.as_ref()[2..],
116 }
117 }
118}
119
120impl AsRef<str> for TagName {
121 fn as_ref(&self) -> &str {
122 match self {
123 Self::Favorite => "m.favourite",
124 Self::LowPriority => "m.lowpriority",
125 Self::ServerNotice => "m.server_notice",
126 Self::User(tag) => tag.as_ref(),
127 Self::_Custom(s) => &s.0,
128 }
129 }
130}
131
132impl<T> From<T> for TagName
133where
134 T: AsRef<str> + Into<String>,
135{
136 fn from(s: T) -> TagName {
137 match s.as_ref() {
138 "m.favourite" => Self::Favorite,
139 "m.lowpriority" => Self::LowPriority,
140 "m.server_notice" => Self::ServerNotice,
141 s if s.starts_with("u.") => Self::User(UserTagName { name: s.into() }),
142 s => Self::_Custom(PrivOwnedStr(s.into())),
143 }
144 }
145}
146
147impl fmt::Display for TagName {
148 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149 self.as_ref().fmt(f)
150 }
151}
152
153impl<'de> Deserialize<'de> for TagName {
154 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
155 where
156 D: serde::Deserializer<'de>,
157 {
158 let cow = deserialize_cow_str(deserializer)?;
159 Ok(cow.into())
160 }
161}
162
163impl Serialize for TagName {
164 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
165 where
166 S: serde::Serializer,
167 {
168 serializer.serialize_str(self.as_ref())
169 }
170}
171
172#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
174#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
175pub struct TagInfo {
176 #[serde(skip_serializing_if = "Option::is_none")]
181 #[cfg_attr(
182 feature = "compat-tag-info",
183 serde(default, deserialize_with = "deserialize_as_optional_number_or_string")
184 )]
185 pub order: Option<f64>,
186}
187
188impl TagInfo {
189 pub fn new() -> Self {
191 Default::default()
192 }
193}
194
195#[cfg(test)]
196mod tests {
197 use maplit::btreemap;
198 use serde_json::{from_value as from_json_value, json, to_value as to_json_value};
199
200 use super::{TagEventContent, TagInfo, TagName};
201
202 #[test]
203 fn serialization() {
204 let tags = btreemap! {
205 TagName::Favorite => TagInfo::new(),
206 TagName::LowPriority => TagInfo::new(),
207 TagName::ServerNotice => TagInfo::new(),
208 "u.custom".to_owned().into() => TagInfo { order: Some(0.9) }
209 };
210
211 let content = TagEventContent { tags };
212
213 assert_eq!(
214 to_json_value(content).unwrap(),
215 json!({
216 "tags": {
217 "m.favourite": {},
218 "m.lowpriority": {},
219 "m.server_notice": {},
220 "u.custom": {
221 "order": 0.9
222 }
223 },
224 })
225 );
226 }
227
228 #[test]
229 fn deserialize_tag_info() {
230 let json = json!({});
231 assert_eq!(from_json_value::<TagInfo>(json).unwrap(), TagInfo::default());
232
233 let json = json!({ "order": null });
234 assert_eq!(from_json_value::<TagInfo>(json).unwrap(), TagInfo::default());
235
236 let json = json!({ "order": 1 });
237 assert_eq!(from_json_value::<TagInfo>(json).unwrap(), TagInfo { order: Some(1.) });
238
239 let json = json!({ "order": 0.42 });
240 assert_eq!(from_json_value::<TagInfo>(json).unwrap(), TagInfo { order: Some(0.42) });
241
242 #[cfg(feature = "compat-tag-info")]
243 {
244 let json = json!({ "order": "0.5" });
245 assert_eq!(from_json_value::<TagInfo>(json).unwrap(), TagInfo { order: Some(0.5) });
246
247 let json = json!({ "order": ".5" });
248 assert_eq!(from_json_value::<TagInfo>(json).unwrap(), TagInfo { order: Some(0.5) });
249 }
250
251 #[cfg(not(feature = "compat-tag-info"))]
252 {
253 let json = json!({ "order": "0.5" });
254 assert!(from_json_value::<TagInfo>(json).is_err());
255 }
256 }
257
258 #[test]
259 fn display_name() {
260 assert_eq!(TagName::Favorite.display_name(), "favourite");
261 assert_eq!(TagName::LowPriority.display_name(), "lowpriority");
262 assert_eq!(TagName::ServerNotice.display_name(), "server_notice");
263 assert_eq!(TagName::from("u.Work").display_name(), "Work");
264 assert_eq!(TagName::from("rs.conduit.rules").display_name(), "rules");
265 assert_eq!(TagName::from("Play").display_name(), "Play");
266 }
267}