1use alloc::borrow::ToOwned;
4use alloc::string::String;
5use alloc::vec::Vec;
6use core::fmt;
7use core::fmt::Write as _;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
12#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
13#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
14#[cfg_attr(feature = "ts", ts(rename = "MessageTag"))]
15pub struct Tag {
16 pub key: String,
18 pub value: Option<String>,
20}
21
22impl Tag {
23 pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
25 Self {
26 key: key.into(),
27 value: Some(value.into()),
28 }
29 }
30
31 pub fn flag(key: impl Into<String>) -> Self {
33 Self {
34 key: key.into(),
35 value: None,
36 }
37 }
38
39 pub fn is_client_only(&self) -> bool {
41 self.key.starts_with('+')
42 }
43}
44
45#[derive(Debug, Clone, Default, PartialEq, Eq)]
50#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
51#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
52#[cfg_attr(feature = "ts", ts(rename = "MessageTags"))]
53pub struct Tags(Vec<Tag>);
54
55impl Tags {
56 pub fn parse(raw: &str) -> Self {
58 Self(
59 raw.split(';')
60 .filter(|part| !part.is_empty())
61 .map(|part| match part.split_once('=') {
62 None | Some((_, "")) => Tag::flag(key_of(part)),
63 Some((key, value)) => Tag::new(key, unescape(value)),
64 })
65 .collect(),
66 )
67 }
68
69 pub fn get(&self, key: &str) -> Option<&str> {
74 self.0
75 .iter()
76 .rev()
77 .find(|tag| tag.key == key)?
78 .value
79 .as_deref()
80 }
81
82 pub fn contains(&self, key: &str) -> bool {
84 self.0.iter().any(|tag| tag.key == key)
85 }
86
87 pub fn set(&mut self, tag: Tag) {
89 match self.0.iter_mut().find(|existing| existing.key == tag.key) {
90 Some(existing) => *existing = tag,
91 None => self.0.push(tag),
92 }
93 }
94
95 pub fn remove(&mut self, key: &str) {
97 self.0.retain(|tag| tag.key != key);
98 }
99
100 pub fn is_empty(&self) -> bool {
102 self.0.is_empty()
103 }
104
105 pub fn len(&self) -> usize {
107 self.0.len()
108 }
109
110 pub fn iter(&self) -> core::slice::Iter<'_, Tag> {
112 self.0.iter()
113 }
114}
115
116impl FromIterator<Tag> for Tags {
117 fn from_iter<T: IntoIterator<Item = Tag>>(iter: T) -> Self {
118 Self(iter.into_iter().collect())
119 }
120}
121
122impl<'a> IntoIterator for &'a Tags {
123 type Item = &'a Tag;
124 type IntoIter = core::slice::Iter<'a, Tag>;
125
126 fn into_iter(self) -> Self::IntoIter {
127 self.iter()
128 }
129}
130
131impl fmt::Display for Tags {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 for (i, tag) in self.0.iter().enumerate() {
134 if i > 0 {
135 f.write_str(";")?;
136 }
137 f.write_str(&tag.key)?;
138 if let Some(value) = &tag.value {
139 write!(f, "={}", Escaped(value))?;
140 }
141 }
142 Ok(())
143 }
144}
145
146fn key_of(part: &str) -> &str {
147 part.split_once('=').map_or(part, |(key, _)| key)
148}
149
150pub(crate) fn unescape(value: &str) -> String {
155 if !value.contains('\\') {
156 return value.to_owned();
157 }
158 let mut out = String::with_capacity(value.len());
159 let mut chars = value.chars();
160 while let Some(c) = chars.next() {
161 if c != '\\' {
162 out.push(c);
163 continue;
164 }
165 match chars.next() {
166 Some(':') => out.push(';'),
167 Some('s') => out.push(' '),
168 Some('\\') => out.push('\\'),
169 Some('r') => out.push('\r'),
170 Some('n') => out.push('\n'),
171 Some(other) => out.push(other),
172 None => {}
173 }
174 }
175 out
176}
177
178pub(crate) struct Escaped<'a>(pub &'a str);
180
181impl fmt::Display for Escaped<'_> {
182 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183 for c in self.0.chars() {
184 match c {
185 ';' => f.write_str("\\:")?,
186 ' ' => f.write_str("\\s")?,
187 '\\' => f.write_str("\\\\")?,
188 '\r' => f.write_str("\\r")?,
189 '\n' => f.write_str("\\n")?,
190 other => f.write_char(other)?,
191 }
192 }
193 Ok(())
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use alloc::format;
201
202 #[test]
203 fn unescapes_the_specified_sequences() {
204 assert_eq!(unescape(r"a\:b"), "a;b");
205 assert_eq!(unescape(r"a\sb"), "a b");
206 assert_eq!(unescape(r"a\\b"), r"a\b");
207 assert_eq!(unescape(r"a\rb"), "a\rb");
208 assert_eq!(unescape(r"a\nb"), "a\nb");
209 }
210
211 #[test]
212 fn drops_the_backslash_of_an_undefined_escape() {
213 assert_eq!(unescape(r"a\qb"), "aqb");
214 }
215
216 #[test]
217 fn drops_a_lone_trailing_backslash() {
218 assert_eq!(unescape(r"ab\"), "ab");
219 }
220
221 #[test]
222 fn escapes_round_trip() {
223 let raw = "semi;space colon:back\\slash\r\n";
224 let escaped = format!("{}", Escaped(raw));
225 assert_eq!(unescape(&escaped), raw);
226 }
227
228 #[test]
229 fn an_empty_value_is_the_same_as_no_value() {
230 let tags = Tags::parse("a=;b");
231 assert_eq!(tags.get("a"), None);
232 assert!(tags.contains("a"));
233 assert!(tags.contains("b"));
234 }
235
236 #[test]
237 fn keeps_wire_order_and_duplicate_keys() {
238 let tags = Tags::parse("z=1;a=2;z=3");
239 assert_eq!(tags.len(), 3);
240 assert_eq!(
241 tags.get("z"),
242 Some("3"),
243 "the specification says to disregard all but the final occurrence"
244 );
245 assert_eq!(
246 format!("{tags}"),
247 "z=1;a=2;z=3",
248 "but the line still renders as it arrived"
249 );
250 }
251
252 #[test]
253 fn recognises_a_client_only_tag() {
254 assert!(Tag::flag("+obby.world/e2ee").is_client_only());
255 assert!(!Tag::flag("time").is_client_only());
256 }
257}