Skip to main content

obby_proto/
message.rs

1//! One IRC protocol line.
2
3use alloc::borrow::ToOwned;
4use alloc::string::String;
5use alloc::vec::Vec;
6use core::fmt;
7
8use crate::tags::Tags;
9
10/// Why a line could not be parsed.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
12pub enum ParseError {
13    /// The line was empty, or held only tags and a source with no command after them.
14    #[error("the line carries no command")]
15    MissingCommand,
16}
17
18/// Where a message came from, as sent in the `:`-prefixed source.
19///
20/// A server sends its own name; a client's message arrives as `nick!user@host`, though a server may
21/// send only the nick.
22#[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    /// The nick, or the server name when there is no `!` or `@`.
28    pub name: String,
29    /// The user part, when the source is a full hostmask.
30    pub user: Option<String>,
31    /// The host part, when present.
32    pub host: Option<String>,
33}
34
35impl Source {
36    /// Split a raw source into its parts. Never fails: a source with no `!` or `@` is all `name`.
37    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    /// True when the source names a server rather than a user, which we infer from the absence of a
63    /// user part and the presence of a dot.
64    ///
65    /// There is no reliable signal on the wire, so a nick containing a dot is indistinguishable from
66    /// a server name. Prefer comparing against the server name from `001` when you have it.
67    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/// A parsed protocol line.
86///
87/// The types are owned rather than borrowed from the input. A borrowed `Message<'a>` would parse
88/// faster, but every consumer of this crate reaches it across a language boundary that cannot carry
89/// a Rust lifetime, so the copy has to happen somewhere and here is the only place it happens once.
90#[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    /// The tag section, empty when the line carried no `@`.
96    pub tags: Tags,
97    /// The source, when the line carried one.
98    pub source: Option<Source>,
99    /// The command or three-digit numeric, as it arrived. Commands are case-insensitive on the wire,
100    /// so compare with [`Message::is`] rather than `==`.
101    pub command: String,
102    /// The parameters, with the trailing parameter last and its `:` removed.
103    pub params: Vec<String>,
104}
105
106impl Message {
107    /// Build a message with no tags and no source.
108    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    /// Parse one line. A trailing CRLF is optional and is ignored.
118    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    /// Compare the command case-insensitively, which is how the wire defines it.
168    pub fn is(&self, command: &str) -> bool {
169        self.command.eq_ignore_ascii_case(command)
170    }
171
172    /// The parameter at this position, if the message has one.
173    pub fn param(&self, index: usize) -> Option<&str> {
174        self.params.get(index).map(String::as_str)
175    }
176
177    /// The last parameter, which for most commands is the message body.
178    pub fn trailing(&self) -> Option<&str> {
179        self.params.last().map(String::as_str)
180    }
181
182    /// The unescaped value of a tag.
183    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            // a parameter that is empty, holds a space, or starts with a colon can only travel as
200            // the trailing one, so it has to keep its marker
201            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}