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.") { Ok(Self { name: s.into() }) } else { Err(InvalidUserTagName) }
59 }
60}
61
62#[derive(Debug)]
65#[allow(clippy::exhaustive_structs)]
66pub struct InvalidUserTagName;
67
68impl fmt::Display for InvalidUserTagName {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 write!(f, "missing 'u.' prefix in UserTagName")
71 }
72}
73
74impl Error for InvalidUserTagName {}
75
76#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
78#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
79pub enum TagName {
80 Favorite,
84
85 LowPriority,
87
88 ServerNotice,
91
92 User(UserTagName),
94
95 #[doc(hidden)]
97 _Custom(PrivOwnedStr),
98}
99
100impl TagName {
101 pub fn display_name(&self) -> &str {
106 match self {
107 Self::_Custom(s) => {
108 let start = s.0.rfind('.').map(|p| p + 1).unwrap_or(0);
109 &self.as_ref()[start..]
110 }
111 _ => &self.as_ref()[2..],
112 }
113 }
114}
115
116impl AsRef<str> for TagName {
117 fn as_ref(&self) -> &str {
118 match self {
119 Self::Favorite => "m.favourite",
120 Self::LowPriority => "m.lowpriority",
121 Self::ServerNotice => "m.server_notice",
122 Self::User(tag) => tag.as_ref(),
123 Self::_Custom(s) => &s.0,
124 }
125 }
126}
127
128impl<T> From<T> for TagName
129where
130 T: AsRef<str> + Into<String>,
131{
132 fn from(s: T) -> TagName {
133 match s.as_ref() {
134 "m.favourite" => Self::Favorite,
135 "m.lowpriority" => Self::LowPriority,
136 "m.server_notice" => Self::ServerNotice,
137 s if s.starts_with("u.") => Self::User(UserTagName { name: s.into() }),
138 s => Self::_Custom(PrivOwnedStr(s.into())),
139 }
140 }
141}
142
143impl fmt::Display for TagName {
144 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145 self.as_ref().fmt(f)
146 }
147}
148
149impl<'de> Deserialize<'de> for TagName {
150 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
151 where
152 D: serde::Deserializer<'de>,
153 {
154 let cow = deserialize_cow_str(deserializer)?;
155 Ok(cow.into())
156 }
157}
158
159impl Serialize for TagName {
160 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
161 where
162 S: serde::Serializer,
163 {
164 serializer.serialize_str(self.as_ref())
165 }
166}
167
168#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)]
170#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
171pub struct TagInfo {
172 #[serde(skip_serializing_if = "Option::is_none")]
177 #[cfg_attr(
178 feature = "compat-tag-info",
179 serde(default, deserialize_with = "deserialize_as_optional_number_or_string")
180 )]
181 pub order: Option<f64>,
182}
183
184impl TagInfo {
185 pub fn new() -> Self {
187 Default::default()
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use maplit::btreemap;
194 use serde_json::{from_value as from_json_value, json, to_value as to_json_value};
195
196 use super::{TagEventContent, TagInfo, TagName};
197
198 #[test]
199 fn serialization() {
200 let tags = btreemap! {
201 TagName::Favorite => TagInfo::new(),
202 TagName::LowPriority => TagInfo::new(),
203 TagName::ServerNotice => TagInfo::new(),
204 "u.custom".to_owned().into() => TagInfo { order: Some(0.9) }
205 };
206
207 let content = TagEventContent { tags };
208
209 assert_eq!(
210 to_json_value(content).unwrap(),
211 json!({
212 "tags": {
213 "m.favourite": {},
214 "m.lowpriority": {},
215 "m.server_notice": {},
216 "u.custom": {
217 "order": 0.9
218 }
219 },
220 })
221 );
222 }
223
224 #[test]
225 fn deserialize_tag_info() {
226 let json = json!({});
227 assert_eq!(from_json_value::<TagInfo>(json).unwrap(), TagInfo::default());
228
229 let json = json!({ "order": null });
230 assert_eq!(from_json_value::<TagInfo>(json).unwrap(), TagInfo::default());
231
232 let json = json!({ "order": 1 });
233 assert_eq!(from_json_value::<TagInfo>(json).unwrap(), TagInfo { order: Some(1.) });
234
235 let json = json!({ "order": 0.42 });
236 assert_eq!(from_json_value::<TagInfo>(json).unwrap(), TagInfo { order: Some(0.42) });
237
238 #[cfg(feature = "compat-tag-info")]
239 {
240 let json = json!({ "order": "0.5" });
241 assert_eq!(from_json_value::<TagInfo>(json).unwrap(), TagInfo { order: Some(0.5) });
242
243 let json = json!({ "order": ".5" });
244 assert_eq!(from_json_value::<TagInfo>(json).unwrap(), TagInfo { order: Some(0.5) });
245 }
246
247 #[cfg(not(feature = "compat-tag-info"))]
248 {
249 let json = json!({ "order": "0.5" });
250 assert!(from_json_value::<TagInfo>(json).is_err());
251 }
252 }
253
254 #[test]
255 fn display_name() {
256 assert_eq!(TagName::Favorite.display_name(), "favourite");
257 assert_eq!(TagName::LowPriority.display_name(), "lowpriority");
258 assert_eq!(TagName::ServerNotice.display_name(), "server_notice");
259 assert_eq!(TagName::from("u.Work").display_name(), "Work");
260 assert_eq!(TagName::from("rs.conduit.rules").display_name(), "rules");
261 assert_eq!(TagName::from("Play").display_name(), "Play");
262 }
263}