1use alloc::borrow::ToOwned;
4use alloc::string::String;
5use alloc::vec::Vec;
6use core::fmt;
7
8use crate::tags::Tags;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
12pub enum ParseError {
13 #[error("the line carries no command")]
15 MissingCommand,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
23#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
24#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
25#[cfg_attr(feature = "ts", ts(rename = "MessageSource"))]
26pub struct Source {
27 pub name: String,
29 pub user: Option<String>,
31 pub host: Option<String>,
33}
34
35impl Source {
36 pub fn parse(raw: &str) -> Self {
38 let (name, rest) = raw
39 .split_once('!')
40 .map_or((raw, None), |(n, r)| (n, Some(r)));
41 if let Some(rest) = rest {
42 let (user, host) = rest
43 .split_once('@')
44 .map_or((rest, None), |(u, h)| (u, Some(h)));
45 Self {
46 name: name.to_owned(),
47 user: Some(user.to_owned()),
48 host: host.map(ToOwned::to_owned),
49 }
50 } else {
51 let (name, host) = name
52 .split_once('@')
53 .map_or((name, None), |(n, h)| (n, Some(h)));
54 Self {
55 name: name.to_owned(),
56 user: None,
57 host: host.map(ToOwned::to_owned),
58 }
59 }
60 }
61
62 pub fn looks_like_server(&self) -> bool {
68 self.user.is_none() && self.name.contains('.')
69 }
70}
71
72impl fmt::Display for Source {
73 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74 f.write_str(&self.name)?;
75 if let Some(user) = &self.user {
76 write!(f, "!{user}")?;
77 }
78 if let Some(host) = &self.host {
79 write!(f, "@{host}")?;
80 }
81 Ok(())
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Default)]
91#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
92#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
93#[cfg_attr(feature = "ts", ts(rename = "RawMessage"))]
94pub struct Message {
95 pub tags: Tags,
97 pub source: Option<Source>,
99 pub command: String,
102 pub params: Vec<String>,
104}
105
106impl Message {
107 pub fn new(command: impl Into<String>, params: impl IntoIterator<Item: Into<String>>) -> Self {
109 Self {
110 tags: Tags::default(),
111 source: None,
112 command: command.into(),
113 params: params.into_iter().map(Into::into).collect(),
114 }
115 }
116
117 pub fn parse(line: &str) -> Result<Self, ParseError> {
119 let mut rest = line.trim_end_matches(['\r', '\n']);
120
121 let mut tags = Tags::default();
122 if let Some(after) = rest.strip_prefix('@') {
123 let (raw, remainder) = after.split_once(' ').ok_or(ParseError::MissingCommand)?;
124 tags = Tags::parse(raw);
125 rest = remainder.trim_start_matches(' ');
126 }
127
128 let mut source = None;
129 if let Some(after) = rest.strip_prefix(':') {
130 let (raw, remainder) = after.split_once(' ').ok_or(ParseError::MissingCommand)?;
131 source = Some(Source::parse(raw));
132 rest = remainder.trim_start_matches(' ');
133 }
134
135 let (command, mut rest) = rest.split_once(' ').unwrap_or((rest, ""));
136 if command.is_empty() {
137 return Err(ParseError::MissingCommand);
138 }
139
140 let mut params = Vec::new();
141 loop {
142 rest = rest.trim_start_matches(' ');
143 if rest.is_empty() {
144 break;
145 }
146 if let Some(trailing) = rest.strip_prefix(':') {
147 params.push(trailing.to_owned());
148 break;
149 }
150 if let Some((param, remainder)) = rest.split_once(' ') {
151 params.push(param.to_owned());
152 rest = remainder;
153 } else {
154 params.push(rest.to_owned());
155 break;
156 }
157 }
158
159 Ok(Self {
160 tags,
161 source,
162 command: command.to_owned(),
163 params,
164 })
165 }
166
167 pub fn is(&self, command: &str) -> bool {
169 self.command.eq_ignore_ascii_case(command)
170 }
171
172 pub fn param(&self, index: usize) -> Option<&str> {
174 self.params.get(index).map(String::as_str)
175 }
176
177 pub fn trailing(&self) -> Option<&str> {
179 self.params.last().map(String::as_str)
180 }
181
182 pub fn tag(&self, key: &str) -> Option<&str> {
184 self.tags.get(key)
185 }
186}
187
188impl fmt::Display for Message {
189 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190 if !self.tags.is_empty() {
191 write!(f, "@{} ", self.tags)?;
192 }
193 if let Some(source) = &self.source {
194 write!(f, ":{source} ")?;
195 }
196 f.write_str(&self.command)?;
197 let last = self.params.len().saturating_sub(1);
198 for (i, param) in self.params.iter().enumerate() {
199 if i == last && (param.is_empty() || param.contains(' ') || param.starts_with(':')) {
202 write!(f, " :{param}")?;
203 } else {
204 write!(f, " {param}")?;
205 }
206 }
207 Ok(())
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use alloc::format;
215 use alloc::vec;
216
217 fn parse(line: &str) -> Message {
218 Message::parse(line).expect("line should parse")
219 }
220
221 #[test]
222 fn parses_a_bare_command() {
223 let msg = parse("PING");
224 assert!(msg.is("ping"));
225 assert!(msg.params.is_empty());
226 assert!(msg.source.is_none());
227 }
228
229 #[test]
230 fn parses_source_command_and_trailing() {
231 let msg = parse(":nick!user@host PRIVMSG #chan :hello world");
232 let source = msg.source.as_ref().expect("source");
233 assert_eq!(source.name, "nick");
234 assert_eq!(source.user.as_deref(), Some("user"));
235 assert_eq!(source.host.as_deref(), Some("host"));
236 assert_eq!(msg.params, vec!["#chan", "hello world"]);
237 }
238
239 #[test]
240 fn parses_a_source_that_is_only_a_nick() {
241 let msg = parse(":nick JOIN #chan");
242 let source = msg.source.as_ref().expect("source");
243 assert_eq!(source.name, "nick");
244 assert!(source.user.is_none());
245 assert!(source.host.is_none());
246 }
247
248 #[test]
249 fn parses_a_nick_with_a_host_but_no_user() {
250 let source = Source::parse("nick@host");
251 assert_eq!(source.name, "nick");
252 assert!(source.user.is_none());
253 assert_eq!(source.host.as_deref(), Some("host"));
254 }
255
256 #[test]
257 fn keeps_an_empty_trailing_parameter() {
258 let msg = parse("PRIVMSG #chan :");
259 assert_eq!(msg.params, vec!["#chan", ""]);
260 }
261
262 #[test]
263 fn keeps_a_colon_inside_the_trailing_parameter() {
264 let msg = parse("PRIVMSG #chan :a : b");
265 assert_eq!(msg.trailing(), Some("a : b"));
266 }
267
268 #[test]
269 fn tolerates_repeated_spaces_between_parameters() {
270 let msg = parse(":s 353 me = #chan :a b");
271 assert_eq!(msg.params, vec!["me", "=", "#chan", "a b"]);
272 }
273
274 #[test]
275 fn parses_tags_with_a_source() {
276 let msg = parse("@time=2026-09-06T10:00:00.000Z;+draft/reply=abc :n!u@h TAGMSG #c");
277 assert_eq!(msg.tag("time"), Some("2026-09-06T10:00:00.000Z"));
278 assert_eq!(msg.tag("+draft/reply"), Some("abc"));
279 assert!(msg.is("TAGMSG"));
280 }
281
282 #[test]
283 fn rejects_tags_with_no_command() {
284 assert_eq!(Message::parse("@a=b"), Err(ParseError::MissingCommand));
285 assert_eq!(Message::parse(":source"), Err(ParseError::MissingCommand));
286 assert_eq!(Message::parse(""), Err(ParseError::MissingCommand));
287 }
288
289 #[test]
290 fn round_trips_a_full_line() {
291 let line = "@id=1;+obby.world/e2ee=blob :n!u@h PRIVMSG #chan :hello world";
292 assert_eq!(format!("{}", parse(line)), line);
293 }
294
295 #[test]
296 fn writes_a_trailing_marker_only_where_it_is_needed() {
297 assert_eq!(format!("{}", Message::new("JOIN", ["#chan"])), "JOIN #chan");
298 assert_eq!(
299 format!("{}", Message::new("PRIVMSG", ["#c", "a b"])),
300 "PRIVMSG #c :a b"
301 );
302 assert_eq!(
303 format!("{}", Message::new("PRIVMSG", ["#c", ""])),
304 "PRIVMSG #c :"
305 );
306 assert_eq!(
307 format!("{}", Message::new("PRIVMSG", ["#c", ":o"])),
308 "PRIVMSG #c ::o"
309 );
310 }
311
312 #[test]
313 fn ignores_the_trailing_line_ending() {
314 assert_eq!(parse("PING :x\r\n"), parse("PING :x"));
315 }
316
317 proptest::proptest! {
318 #[test]
319 fn any_message_survives_a_round_trip(
320 command in "[A-Z]{1,8}",
321 params in proptest::collection::vec("[^ \r\n:][^ \r\n]{0,16}", 0..4),
322 trailing in "[^\r\n]{0,32}",
323 ) {
324 let mut msg = Message::new(command, params);
325 msg.params.push(trailing);
326 let rendered = format!("{msg}");
327 proptest::prop_assert_eq!(Message::parse(&rendered).expect("round trip"), msg);
328 }
329 }
330}