Skip to main content

obby_client/
session.rs

1//! Turning protocol lines into model changes.
2//!
3//! Kept apart from the connection state machine in `client.rs`, which is only concerned with getting
4//! registered and staying connected. Everything here assumes registration already happened.
5
6use alloc::collections::BTreeMap;
7use alloc::string::{String, ToString};
8use alloc::vec::Vec;
9use obby_proto::{CaseFolded, Casemapping, Isupport, Message as Line, parse_channel_modes};
10
11use crate::model::{ChatMessage, MessageKind, Model};
12
13/// What changed, for a host that wants to react without diffing the whole model.
14#[derive(Debug, Clone, PartialEq, Eq)]
15#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
16#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
17#[cfg_attr(feature = "ts", ts(rename = "ModelChange"))]
18#[cfg_attr(feature = "serde", serde(tag = "type", rename_all = "snake_case"))]
19#[non_exhaustive]
20pub enum Change {
21    /// A message landed in a channel or a conversation.
22    MessageAdded {
23        /// The channel or nick it belongs to, as the server spells it.
24        target: String,
25        /// Where it sits in that target's log.
26        key: crate::model::MessageKey,
27    },
28    /// We joined a channel.
29    ChannelJoined {
30        /// The channel.
31        channel: String,
32    },
33    /// We left a channel, whether by choice or by being removed.
34    ChannelParted {
35        /// The channel.
36        channel: String,
37    },
38    /// A channel's member list changed.
39    ChannelMembersChanged {
40        /// The channel.
41        channel: String,
42    },
43    /// A channel's topic changed.
44    ChannelTopicChanged {
45        /// The channel.
46        channel: String,
47    },
48    /// Someone changed nick, possibly us.
49    NickChanged {
50        /// What they were called.
51        from: String,
52        /// What they are called now.
53        to: String,
54    },
55    /// A channel's modes changed.
56    ChannelModesChanged {
57        /// The channel.
58        channel: String,
59    },
60    /// The server confirmed how far we have read, so the unread counts moved.
61    ReadMarkerMoved {
62        /// The channel or person.
63        target: String,
64    },
65    /// A message gained or lost a reaction.
66    MessageReacted {
67        /// Where the message is.
68        target: String,
69        /// The message reacted to.
70        msgid: String,
71    },
72    /// A message was deleted.
73    MessageRedacted {
74        /// Where the message was.
75        target: String,
76        /// The message deleted.
77        msgid: String,
78    },
79    /// A metadata key changed on a person, a channel, or us.
80    MetadataChanged {
81        /// Whose metadata changed.
82        target: String,
83        /// The key that changed.
84        key: String,
85    },
86    /// A `WHOIS` reply finished. The whole record is in the model, under the folded nick.
87    WhoisReceived {
88        /// Who it describes.
89        nick: String,
90    },
91    /// A channel changed its name and is the same channel, with the same people and messages.
92    ChannelRenamed {
93        /// What it was called.
94        from: String,
95        /// What it is called now.
96        to: String,
97    },
98}
99
100/// The token we tag our own WHOX requests with, so a reply to somebody else's is ignored.
101pub(crate) const WHOX_TOKEN: &str = "332";
102
103/// The WHOX fields we ask for, in the order the reply returns them.
104///
105/// `t` is the token, then channel, user, host, nick, flags, account and realname.
106pub(crate) const WHOX_FIELDS: &str = "%tcuhnfar";
107
108/// True when this character may appear in a nick.
109///
110/// Letters and digits, plus the punctuation RFC 1459 allows. It decides where a nick ends, so that
111/// `bob` does not match inside `bobby`.
112fn is_nick_char(c: char) -> bool {
113    c.is_alphanumeric() || "[]\\`_^{|}-".contains(c)
114}
115
116/// True when this text addresses `nick`, under the server's casemapping.
117///
118/// The fold is the whole point. A server folding the bracket alphabet treats `[nick]` and `{NICK}`
119/// as one person, and an ASCII lowercase misses that, which is why highlights are unreliable in the
120/// reference client.
121fn mentions(casemapping: Casemapping, text: &str, nick: &str) -> bool {
122    if nick.is_empty() {
123        return false;
124    }
125    let folded_nick = casemapping.fold(nick);
126    let needle = folded_nick.as_str();
127    let folded: String = text.chars().map(|c| casemapping.fold_char(c)).collect();
128
129    let mut from = 0;
130    while let Some(offset) = folded.get(from..).and_then(|rest| rest.find(needle)) {
131        let start = from + offset;
132        let end = start + needle.len();
133        let before = folded.get(..start).and_then(|s| s.chars().next_back());
134        let after = folded.get(end..).and_then(|s| s.chars().next());
135        if !before.is_some_and(is_nick_char) && !after.is_some_and(is_nick_char) {
136            return true;
137        }
138        from = end;
139    }
140    false
141}
142
143/// Everything needed to fold one line into the model.
144pub(crate) struct Context<'a> {
145    pub(crate) model: &'a mut Model,
146    pub(crate) isupport: &'a Isupport,
147    /// The latest timestamp seen, which stamps a line the server did not stamp itself.
148    pub(crate) latest_ms: &'a mut u64,
149    /// True while replaying a history batch rather than reading live traffic.
150    pub(crate) historical: bool,
151}
152
153impl Context<'_> {
154    fn casemapping(&self) -> Casemapping {
155        self.isupport.casemapping()
156    }
157
158    fn fold(&self, name: &str) -> CaseFolded {
159        self.isupport.fold(name)
160    }
161
162    /// When a line happened.
163    ///
164    /// `server-time` is authoritative. Without it we reuse the latest instant already seen rather
165    /// than reading a clock, which keeps ordering stable and keeps this whole layer free of time.
166    fn stamp(&mut self, line: &Line) -> u64 {
167        let stamped = line
168            .tag("time")
169            .and_then(obby_proto::parse_server_time)
170            .unwrap_or(*self.latest_ms);
171        *self.latest_ms = (*self.latest_ms).max(stamped);
172        stamped
173    }
174
175    fn is_me(&self, nick: &str) -> bool {
176        self.casemapping().eq(&self.model.me.nick, nick)
177    }
178
179    fn sender(line: &Line) -> String {
180        line.source
181            .as_ref()
182            .map_or_else(String::new, |source| source.name.clone())
183    }
184}
185
186/// Fold one line into the model, returning what changed.
187pub(crate) fn apply(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
188    let command = line.command.to_ascii_uppercase();
189    match command.as_str() {
190        "PRIVMSG" | "NOTICE" | "TAGMSG" => message(ctx, line, &command),
191        "JOIN" => join(ctx, line),
192        "PART" => part(ctx, line),
193        "QUIT" => quit(ctx, line),
194        "KICK" => kick(ctx, line),
195        "NICK" => nick(ctx, line),
196        "RENAME" => rename_channel(ctx, line),
197        "TOPIC" => topic(ctx, line),
198        "MODE" => mode(ctx, line),
199        "332" => topic_reply(ctx, line),
200        "MARKREAD" => mark_read(ctx, line),
201        "REDACT" => redact(ctx, line),
202        "AWAY" => away(ctx, line),
203        "ACCOUNT" => account(ctx, line),
204        // the server push and the reply to our own GET carry the same fields, one parameter apart
205        "METADATA" => metadata(ctx, line, 0),
206        // 760 is WHOIS surfacing metadata inline, with the same shape as a 761 reply
207        "760" | "761" | "766" => metadata(ctx, line, 1),
208        // one WHOIS reply, numeric by numeric, reported once when 318 closes it
209        "311" | "312" | "313" | "317" | "318" | "319" | "330" | "338" | "378" | "671" => {
210            whois(ctx, line, &command)
211        }
212        "353" => names(ctx, line),
213        "352" => who_reply(ctx, line),
214        "354" => whox_reply(ctx, line),
215        "PROP" => prop(ctx, line),
216        "961" => prop_list(ctx, line),
217        _ => Vec::new(),
218    }
219}
220
221fn message(ctx: &mut Context<'_>, line: &Line, command: &str) -> Vec<Change> {
222    let Some(target) = line.param(0) else {
223        return Vec::new();
224    };
225    if command == "TAGMSG"
226        && let Some(changes) = reaction(ctx, line)
227    {
228        return changes;
229    }
230    #[cfg(feature = "obby")]
231    if command == "TAGMSG"
232        && let Some(changes) = link_preview(ctx, line)
233    {
234        return changes;
235    }
236
237    let sender = Context::sender(line);
238    let time_ms = ctx.stamp(line);
239    let key = ctx.model.next_message_key(time_ms);
240    let body = if command == "TAGMSG" {
241        String::new()
242    } else {
243        line.param(1).unwrap_or_default().to_string()
244    };
245
246    let (kind, text) = match command {
247        "TAGMSG" => (MessageKind::Tagmsg, body),
248        "NOTICE" => (MessageKind::Notice, body),
249        _ => match obby_proto::parse_ctcp(&body) {
250            Some(ctcp) => (
251                MessageKind::Ctcp {
252                    command: ctcp.command.to_ascii_uppercase(),
253                },
254                ctcp.params,
255            ),
256            None => (MessageKind::Privmsg, body),
257        },
258    };
259
260    let mut message = ChatMessage::new(key, sender.clone(), kind);
261    message.text = text;
262    message.msgid = line.tag("msgid").map(ToString::to_string);
263    message.account = line.tag("account").map(ToString::to_string);
264    message.own = ctx.is_me(&sender);
265    message.historical = ctx.historical;
266    // the server sends `+reply` from one module and `+draft/reply` from another, so both spellings
267    // have to be accepted or threading silently breaks depending on which module answered
268    message.reply_to = line
269        .tag("+reply")
270        .or_else(|| line.tag("+draft/reply"))
271        .map(ToString::to_string);
272
273    // a message to `@#channel` is addressed to the operators of that channel and still belongs in
274    // it, so the status prefix comes off before we decide what the target is
275    let addressed = target.trim_start_matches(|c| ctx.isupport.is_statusmsg(c));
276    // a message addressed to us belongs in the conversation with whoever sent it, not in one named
277    // after ourselves
278    let (name, is_channel) = if ctx.isupport.is_channel(addressed) {
279        (addressed.to_string(), true)
280    } else if message.own {
281        (target.to_string(), false)
282    } else {
283        (sender, false)
284    };
285
286    // history and our own words are never unread, and nothing counts as addressing us twice
287    let counts = !message.own && !message.historical;
288    let addressed = counts && mentions(ctx.casemapping(), &message.text, &ctx.model.me.nick);
289
290    let folded = ctx.fold(&name);
291    // a channel only exists because we joined it. Without this a server can name arbitrarily many
292    // channels we are not in and grow the model without limit, which `join` already refuses
293    if is_channel && ctx.model.channel(&folded).is_none() {
294        return Vec::new();
295    }
296    let accepted = if is_channel {
297        let channel = ctx.model.channel_or_insert(folded, &name);
298        let accepted = channel.log.insert(message);
299        if accepted && counts {
300            channel.unread = channel.unread.saturating_add(1);
301            if addressed {
302                channel.mentions = channel.mentions.saturating_add(1);
303            }
304        }
305        accepted
306    } else {
307        let query = ctx.model.conversation_or_insert(folded, &name);
308        let accepted = query.log.insert(message);
309        if accepted && counts {
310            // a private message is addressed to us by existing at all
311            query.unread = query.unread.saturating_add(1);
312        }
313        accepted
314    };
315
316    if accepted {
317        alloc::vec![Change::MessageAdded { target: name, key }]
318    } else {
319        Vec::new()
320    }
321}
322
323fn join(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
324    let Some(channel) = line.param(0) else {
325        return Vec::new();
326    };
327    let who = Context::sender(line);
328    let name = channel.to_string();
329    let folded = ctx.fold(&name);
330
331    if ctx.is_me(&who) {
332        ctx.model.channel_or_insert(folded, &name);
333        return alloc::vec![Change::ChannelJoined { channel: name }];
334    }
335
336    let account = line.param(1).filter(|a| *a != "*").map(ToString::to_string);
337    // only our own JOIN brings a channel into being. A server naming channels we are not in would
338    // otherwise grow the model without limit, and every one of them would be a lie
339    let key = ctx.isupport.fold(&who);
340    if ctx.model.channel(&folded).is_none() {
341        return Vec::new();
342    }
343    let person = ctx.model.person_or_insert(key.clone(), &who);
344    person.nick = who;
345    // extended-join carries the account on the JOIN itself, sparing us a WHO for it
346    if account.is_some() {
347        person.account = account;
348    }
349    if let Some(channel) = ctx.model.channel_mut(&folded) {
350        channel.members.entry(key).or_default();
351    }
352    alloc::vec![Change::ChannelMembersChanged { channel: name }]
353}
354
355fn part(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
356    let Some(channel) = line.param(0) else {
357        return Vec::new();
358    };
359    let who = Context::sender(line);
360    let name = channel.to_string();
361    let folded = ctx.fold(&name);
362
363    if ctx.is_me(&who) {
364        ctx.model.remove_channel(&folded);
365        return alloc::vec![Change::ChannelParted { channel: name }];
366    }
367    if let Some(channel) = ctx.model.channel_mut(&folded) {
368        channel.members.remove(&ctx.isupport.fold(&who));
369    }
370    ctx.model.forget_strangers();
371    alloc::vec![Change::ChannelMembersChanged { channel: name }]
372}
373
374fn quit(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
375    let who = Context::sender(line);
376    let folded = ctx.fold(&who);
377    let left = ctx.model.remove_everywhere(&folded);
378    ctx.model.forget_strangers();
379    left.into_iter()
380        .filter_map(|key| {
381            ctx.model
382                .channel(&key)
383                .map(|channel| Change::ChannelMembersChanged {
384                    channel: channel.name.clone(),
385                })
386        })
387        .collect()
388}
389
390fn kick(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
391    let (Some(channel), Some(target)) = (line.param(0), line.param(1)) else {
392        return Vec::new();
393    };
394    let name = channel.to_string();
395    let folded = ctx.fold(&name);
396
397    if ctx.is_me(target) {
398        ctx.model.remove_channel(&folded);
399        return alloc::vec![Change::ChannelParted { channel: name }];
400    }
401    let removed = ctx.isupport.fold(target);
402    if let Some(channel) = ctx.model.channel_mut(&folded) {
403        channel.members.remove(&removed);
404    }
405    ctx.model.forget_strangers();
406    alloc::vec![Change::ChannelMembersChanged { channel: name }]
407}
408
409fn nick(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
410    let Some(to) = line.param(0) else {
411        return Vec::new();
412    };
413    let from = Context::sender(line);
414    ctx.model.rename(ctx.isupport.casemapping(), &from, to);
415    alloc::vec![Change::NickChanged {
416        from,
417        to: to.to_string()
418    }]
419}
420
421/// Apply `RENAME <old> <new> [:<reason>]`, from `draft/channel-rename`.
422fn rename_channel(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
423    let (Some(from), Some(to)) = (line.param(0), line.param(1)) else {
424        return Vec::new();
425    };
426    if !ctx.model.rename_channel(ctx.casemapping(), from, to) {
427        return Vec::new();
428    }
429    alloc::vec![Change::ChannelRenamed {
430        from: from.to_string(),
431        to: to.to_string(),
432    }]
433}
434
435/// Collect one numeric of a `WHOIS` reply, reporting the record only once `318` closes it.
436///
437/// Every numeric here is `<us> <them> ...`, so the second parameter names who the reply is about.
438/// The vendor `obby.world/whois` batch changes nothing about this: it wraps the same numerics, and
439/// the engine unwraps a batch before anything reaches here.
440/// Numerics we fold into a record and report only once the record is complete.
441///
442/// The host hears one `WhoisReceived` when `318` closes the record, so these lines are handled even
443/// though they change nothing a host can see yet, and must not reach it as unmodelled traffic.
444pub(crate) fn accumulates(command: &str) -> bool {
445    matches!(
446        command,
447        "311" | "312" | "313" | "317" | "319" | "330" | "338" | "378" | "671"
448    )
449}
450
451fn whois(ctx: &mut Context<'_>, line: &Line, command: &str) -> Vec<Change> {
452    let Some(nick) = line.param(1) else {
453        return Vec::new();
454    };
455    let key = ctx.fold(nick);
456    if command == "318" {
457        let Some(record) = ctx.model.whois_mut(&key) else {
458            return Vec::new();
459        };
460        record.complete = true;
461        return alloc::vec![Change::WhoisReceived {
462            nick: nick.to_string(),
463        }];
464    }
465
466    let text = line.trailing().map(ToString::to_string);
467    let record = ctx.model.whois_or_insert(key, nick);
468    match command {
469        "311" => {
470            record.username = line.param(2).map(ToString::to_string);
471            record.host = line.param(3).map(ToString::to_string);
472            record.realname = text;
473        }
474        "312" => {
475            record.server = line.param(2).map(ToString::to_string);
476            record.server_info = text;
477        }
478        "313" => record.operator = text,
479        "317" => {
480            record.idle_secs = line.param(2).and_then(|secs| secs.parse().ok());
481            record.signon_ms = line
482                .param(3)
483                .and_then(|at| at.parse::<u64>().ok())
484                .map(|seconds| seconds.saturating_mul(1000));
485        }
486        "319" => {
487            record.channels = line
488                .param(2)
489                .unwrap_or_default()
490                .split_whitespace()
491                .map(ToString::to_string)
492                .collect();
493        }
494        "330" => record.account = line.param(2).map(ToString::to_string),
495        // 338 names the host in its own parameter; 378 only ever describes it in the trailing text
496        "338" | "378" => {
497            record.actual_host = match line.param(3) {
498                Some(_) => line.param(2).map(ToString::to_string),
499                None => text,
500            };
501        }
502        "671" => record.secure = true,
503        _ => {}
504    }
505    Vec::new()
506}
507
508fn topic(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
509    let Some(channel) = line.param(0) else {
510        return Vec::new();
511    };
512    let name = channel.to_string();
513    let folded = ctx.fold(&name);
514    let who = Context::sender(line);
515    let text = line.param(1).unwrap_or_default().to_string();
516    let Some(entry) = ctx.model.channel_mut(&folded) else {
517        return Vec::new();
518    };
519    entry.topic = (!text.is_empty()).then_some(text);
520    entry.topic_by = Some(who);
521    alloc::vec![Change::ChannelTopicChanged { channel: name }]
522}
523
524fn topic_reply(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
525    let Some(channel) = line.param(1) else {
526        return Vec::new();
527    };
528    let name = channel.to_string();
529    let folded = ctx.fold(&name);
530    let text = line.param(2).unwrap_or_default().to_string();
531    let Some(entry) = ctx.model.channel_mut(&folded) else {
532        return Vec::new();
533    };
534    entry.topic = (!text.is_empty()).then_some(text);
535    alloc::vec![Change::ChannelTopicChanged { channel: name }]
536}
537
538fn names(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
539    // `353 <nick> <symbol> <channel> :<names>`
540    let Some(channel) = line.param(2) else {
541        return Vec::new();
542    };
543    let name = channel.to_string();
544    let folded = ctx.fold(&name);
545    let entries: Vec<String> = line
546        .trailing()
547        .unwrap_or_default()
548        .split_whitespace()
549        .map(ToString::to_string)
550        .collect();
551
552    for entry in entries {
553        let (prefixes, nick) = ctx.isupport.prefix().split(&entry);
554        // userhost-in-names turns the entry into a full hostmask
555        let nick = nick.split('!').next().unwrap_or(nick);
556        if nick.is_empty() {
557            continue;
558        }
559        let (prefixes, nick, key) = (
560            prefixes.to_string(),
561            nick.to_string(),
562            ctx.isupport.fold(nick),
563        );
564        if ctx.model.channel(&folded).is_none() {
565            return Vec::new();
566        }
567        ctx.model
568            .person_or_insert(key.clone(), &nick)
569            .nick
570            .clone_from(&nick);
571        if let Some(channel) = ctx.model.channel_mut(&folded) {
572            channel.members.entry(key).or_default().prefixes = prefixes;
573        }
574    }
575    alloc::vec![Change::ChannelMembersChanged { channel: name }]
576}
577
578/// Apply a reaction carried on a TAGMSG, if that is what this is.
579///
580/// Returns `None` when the line is an ordinary TAGMSG, so the caller files it as a message.
581fn reaction(ctx: &mut Context<'_>, line: &Line) -> Option<Vec<Change>> {
582    let (tag, adding) = match (line.tag("+draft/react"), line.tag("+draft/unreact")) {
583        (Some(emoji), _) => (emoji, true),
584        (None, Some(emoji)) => (emoji, false),
585        (None, None) => return None,
586    };
587    let emoji = tag.to_string();
588    // the server is inconsistent about which spelling of the reply tag it sends, so both are read
589    let msgid = line
590        .tag("+reply")
591        .or_else(|| line.tag("+draft/reply"))?
592        .to_string();
593    let sender = Context::sender(line);
594    // a reactor is a person, and two spellings of one nick are one person, so this folds like every
595    // other identity rather than comparing the raw string
596    let who = ctx.fold(&sender).into_string();
597    let target = line.param(0)?;
598    let name = target.to_string();
599    let folded = ctx.fold(&name);
600
601    let log = match ctx.model.channel_mut(&folded) {
602        Some(channel) => &mut channel.log,
603        None => &mut ctx.model.conversation_mut(&folded)?.log,
604    };
605    let message = log.get_mut(&msgid)?;
606    let reactors = message.reactions.entry(emoji.clone()).or_default();
607    if adding {
608        if !reactors.contains(&who) {
609            reactors.push(who);
610        }
611    } else {
612        reactors.retain(|reactor| *reactor != who);
613        if reactors.is_empty() {
614            message.reactions.remove(&emoji);
615        }
616    }
617    Some(alloc::vec![Change::MessageReacted {
618        target: name,
619        msgid
620    }])
621}
622
623/// Attach a server-built link preview to the message it describes.
624///
625/// It arrives as a bare TAGMSG rather than as part of the message, because the server has to fetch
626/// the page before it knows what to say, long after the message itself went out.
627#[cfg(feature = "obby")]
628fn link_preview(ctx: &mut Context<'_>, line: &Line) -> Option<Vec<Change>> {
629    let (msgid, preview) = crate::extensions::LinkPreview::parse(line)?;
630    // past this point the line is a preview whatever happens next. A message we no longer hold, or
631    // never held, leaves nothing to attach it to, and filing it as a message of its own would put an
632    // empty row in the conversation
633    let mut changes = Vec::new();
634    if let Some(name) = line.param(0) {
635        let name = name.to_string();
636        let folded = ctx.fold(&name);
637        let log = match ctx.model.channel_mut(&folded) {
638            Some(channel) => Some(&mut channel.log),
639            None => ctx
640                .model
641                .conversation_mut(&folded)
642                .map(|query| &mut query.log),
643        };
644        if let Some(log) = log
645            && let Some(message) = log.get_mut(&msgid)
646        {
647            message.link_preview = Some(preview);
648            let key = message.key;
649            changes.push(Change::MessageAdded { target: name, key });
650        }
651    }
652    Some(changes)
653}
654
655/// Mark a message deleted, keeping what it said.
656///
657/// Throwing the content away leaves no way to show who deleted what, and a client that has already
658/// shown the message to someone gains nothing by forgetting it.
659fn redact(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
660    let (Some(target), Some(msgid)) = (line.param(0), line.param(1)) else {
661        return Vec::new();
662    };
663    let name = target.to_string();
664    let folded = ctx.fold(&name);
665    let msgid = msgid.to_string();
666
667    let Some(log) = (match ctx.model.channel_mut(&folded) {
668        Some(channel) => Some(&mut channel.log),
669        None => ctx.model.conversation_mut(&folded).map(|q| &mut q.log),
670    }) else {
671        return Vec::new();
672    };
673    let Some(message) = log.get_mut(&msgid) else {
674        return Vec::new();
675    };
676    message.redacted = true;
677    alloc::vec![Change::MessageRedacted {
678        target: name,
679        msgid
680    }]
681}
682
683/// Apply one metadata key to whoever it belongs to.
684///
685/// `offset` is where the target sits: a server push names it first, while a numeric reply puts our
686/// own nick there and shifts everything along by one.
687///
688/// A key with no value is a deletion. `766` says the key is not set, which is the same thing.
689fn metadata(ctx: &mut Context<'_>, line: &Line, offset: usize) -> Vec<Change> {
690    let (Some(target), Some(key)) = (line.param(offset), line.param(offset + 1)) else {
691        return Vec::new();
692    };
693    // a target of `*` means us, which is how a server answers before it knows our nick
694    let target = if target == "*" {
695        ctx.model.me.nick.clone()
696    } else {
697        target.to_string()
698    };
699    let key = key.to_string();
700    // `761` carries a visibility between the key and the value; `766` has neither
701    let value = line
702        .param(offset + 3)
703        .filter(|_| !line.is("766"))
704        .map(ToString::to_string);
705    let folded = ctx.fold(&target);
706
707    let apply = |store: &mut BTreeMap<String, String>| match &value {
708        Some(value) => {
709            store.insert(key.clone(), value.clone());
710        }
711        None => {
712            store.remove(&key);
713        }
714    };
715
716    if ctx.isupport.is_channel(&target) {
717        let Some(channel) = ctx.model.channel_mut(&folded) else {
718            return Vec::new();
719        };
720        apply(&mut channel.metadata);
721        return alloc::vec![Change::MetadataChanged { target, key }];
722    }
723
724    apply(&mut ctx.model.person_or_insert(folded, &target).metadata);
725    if ctx.is_me(&target) {
726        apply(&mut ctx.model.me.metadata);
727    }
728    alloc::vec![Change::MetadataChanged { target, key }]
729}
730
731/// Note that someone went away or came back.
732fn away(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
733    let reason = line.param(0).map(ToString::to_string);
734    person_changed(ctx, line, |person| person.away.clone_from(&reason))
735}
736
737/// Note that someone logged in or out of an account.
738fn account(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
739    let name = line.param(0).filter(|a| *a != "*").map(ToString::to_string);
740    person_changed(ctx, line, |person| person.account.clone_from(&name))
741}
742
743/// Apply a change to whoever sent the line, and report every channel it shows in.
744fn person_changed(
745    ctx: &mut Context<'_>,
746    line: &Line,
747    change: impl FnOnce(&mut crate::model::Person),
748) -> Vec<Change> {
749    let who = Context::sender(line);
750    let key = ctx.fold(&who);
751    change(ctx.model.person_or_insert(key.clone(), &who));
752    if ctx.is_me(&who) {
753        let person = ctx.model.person(&key).cloned().unwrap_or_default();
754        ctx.model.me.away = person.away;
755        ctx.model.me.account = person.account;
756    }
757    ctx.model
758        .channels()
759        .filter(|(_, channel)| channel.members.contains_key(&key))
760        .map(|(_, channel)| Change::ChannelMembersChanged {
761            channel: channel.name.clone(),
762        })
763        .collect()
764}
765
766/// Record how far the server says we have read, and clear what that covers.
767///
768/// The marker is server-authoritative: we set it by sending `MARKREAD` and only believe it when the
769/// server says so, which is what keeps two clients on one account agreeing.
770fn mark_read(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
771    let (Some(target), Some(timestamp)) = (line.param(0), line.param(1)) else {
772        return Vec::new();
773    };
774    let marker = timestamp
775        .strip_prefix("timestamp=")
776        .unwrap_or(timestamp)
777        .to_string();
778    let folded = ctx.fold(target);
779    let name = target.to_string();
780
781    if let Some(channel) = ctx.model.channel_mut(&folded) {
782        channel.read_marker = Some(marker);
783        channel.unread = 0;
784        channel.mentions = 0;
785        return alloc::vec![Change::ReadMarkerMoved { target: name }];
786    }
787    if let Some(query) = ctx.model.conversation_mut(&folded) {
788        query.read_marker = Some(marker);
789        query.unread = 0;
790        return alloc::vec![Change::ReadMarkerMoved { target: name }];
791    }
792    Vec::new()
793}
794
795/// Apply a plain `WHO` reply.
796///
797/// `352 <client> <channel> <user> <host> <server> <nick> <flags> :<hops> <realname>`. Nothing here
798/// carries an account, which is why the WHOX form exists and why we ask for it when we can.
799fn who_reply(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
800    let (Some(channel), Some(user), Some(host), Some(nick), Some(flags)) = (
801        line.param(1),
802        line.param(2),
803        line.param(3),
804        line.param(5),
805        line.param(6),
806    ) else {
807        return Vec::new();
808    };
809    // the trailing parameter is a hop count and the realname, separated by a space
810    let realname = line
811        .trailing()
812        .and_then(|trailing| trailing.split_once(' '))
813        .map(|(_, realname)| realname.to_string());
814    apply_who(
815        ctx,
816        WhoRow {
817            channel,
818            nick,
819            user,
820            host,
821            flags,
822            account: Account::Unknown,
823            realname,
824        },
825    )
826}
827
828/// Apply a WHOX reply, which is the same information plus the account.
829///
830/// The field order follows what we ask for, so this only reads a reply carrying our own token.
831fn whox_reply(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
832    if line.param(1) != Some(WHOX_TOKEN) {
833        return Vec::new();
834    }
835    let (Some(channel), Some(user), Some(host), Some(nick), Some(flags), Some(account)) = (
836        line.param(2),
837        line.param(3),
838        line.param(4),
839        line.param(5),
840        line.param(6),
841        line.param(7),
842    ) else {
843        return Vec::new();
844    };
845    let account = if account == "0" || account == "*" {
846        Account::LoggedOut
847    } else {
848        Account::As(account.to_string())
849    };
850    apply_who(
851        ctx,
852        WhoRow {
853            channel,
854            nick,
855            user,
856            host,
857            flags,
858            account,
859            realname: line.param(8).map(ToString::to_string),
860        },
861    )
862}
863
864/// What one WHO or WHOX row says about somebody.
865struct WhoRow<'a> {
866    channel: &'a str,
867    nick: &'a str,
868    user: &'a str,
869    host: &'a str,
870    flags: &'a str,
871    account: Account,
872    realname: Option<String>,
873}
874
875/// What a reply says about somebody's account, which is not the same question as what it is.
876///
877/// A plain WHO carries no account field at all, and treating that as "logged out" would sign
878/// everybody out every time we refreshed a member list.
879enum Account {
880    /// The reply did not say.
881    Unknown,
882    /// The reply said they are logged in as nobody.
883    LoggedOut,
884    /// The reply named an account.
885    As(String),
886}
887
888/// Fold one WHO or WHOX row into the person and, when it names a channel, their membership.
889fn apply_who(ctx: &mut Context<'_>, row: WhoRow<'_>) -> Vec<Change> {
890    let key = ctx.fold(row.nick);
891    let prefixes: String = row
892        .flags
893        .chars()
894        .filter(|c| ctx.isupport.prefix().rank(*c).is_some())
895        .collect();
896    let away = row.flags.starts_with('G');
897
898    let person = ctx.model.person_or_insert(key.clone(), row.nick);
899    person.nick = row.nick.to_string();
900    person.username = Some(row.user.to_string());
901    person.host = Some(row.host.to_string());
902    person.operator = row.flags.contains('*');
903    person.bot = row.flags.contains('B');
904    if let Some(realname) = row.realname {
905        person.realname = Some(realname);
906    }
907    // WHO says only whether someone is away, never why, so an existing message is kept rather than
908    // replaced with nothing
909    if away {
910        person.away.get_or_insert_with(String::new);
911    } else {
912        person.away = None;
913    }
914    match row.account {
915        Account::Unknown => {}
916        Account::LoggedOut => person.account = None,
917        Account::As(account) => person.account = Some(account),
918    }
919
920    if !ctx.isupport.is_channel(row.channel) {
921        return Vec::new();
922    }
923    let name = row.channel.to_string();
924    let folded = ctx.fold(&name);
925    let Some(entry) = ctx.model.channel_mut(&folded) else {
926        return Vec::new();
927    };
928    entry.members.entry(key).or_default().prefixes = prefixes;
929    alloc::vec![Change::ChannelMembersChanged { channel: name }]
930}
931
932/// Apply a named mode change.
933///
934/// `PROP <target> (+name[=param] | -name)+`. The server relays every legacy `MODE` as an equivalent
935/// `PROP` to anyone holding the capability, so both arrive and both are applied. They agree, and
936/// applying the same change twice is what makes that harmless.
937fn prop(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
938    let Some(target) = line.param(0) else {
939        return Vec::new();
940    };
941    let name = target.to_string();
942    let folded = ctx.fold(&name);
943    let changes: Vec<String> = line.params.iter().skip(1).cloned().collect();
944    if !apply_named(ctx, &folded, changes.iter().map(String::as_str)) {
945        return Vec::new();
946    }
947    alloc::vec![Change::ChannelModesChanged { channel: name }]
948}
949
950/// Apply one line of a `PROP` listing, which reports state rather than a change.
951///
952/// `961 <client> <target> <name>[=<param>]...`, with no leading sign, so every entry is set.
953fn prop_list(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
954    let Some(target) = line.param(1) else {
955        return Vec::new();
956    };
957    let name = target.to_string();
958    let folded = ctx.fold(&name);
959    let entries: Vec<String> = line
960        .params
961        .iter()
962        .skip(2)
963        .flat_map(|param| param.split_whitespace())
964        .map(|entry| alloc::format!("+{entry}"))
965        .collect();
966    if !apply_named(ctx, &folded, entries.iter().map(String::as_str)) {
967        return Vec::new();
968    }
969    alloc::vec![Change::ChannelModesChanged { channel: name }]
970}
971
972/// Fold `+name[=param]` and `-name` entries into a channel's named modes.
973///
974/// Returns false when we are not in the channel, so a server naming one we never joined does not
975/// bring it into being.
976fn apply_named<'a>(
977    ctx: &mut Context<'_>,
978    folded: &CaseFolded,
979    entries: impl Iterator<Item = &'a str>,
980) -> bool {
981    let Some(channel) = ctx.model.channel_mut(folded) else {
982        return false;
983    };
984    for entry in entries {
985        if let Some(rest) = entry.strip_prefix('+') {
986            let (name, param) = rest.split_once('=').map_or((rest, None), |(name, param)| {
987                (name, Some(param.to_string()))
988            });
989            channel.named_modes.insert(name.to_string(), param);
990        } else if let Some(name) = entry.strip_prefix('-') {
991            // a removal may still carry the parameter it is removing, which is not part of the name
992            let name = name.split('=').next().unwrap_or(name);
993            channel.named_modes.remove(name);
994        }
995    }
996    true
997}
998
999fn mode(ctx: &mut Context<'_>, line: &Line) -> Vec<Change> {
1000    let Some(target) = line.param(0) else {
1001        return Vec::new();
1002    };
1003    if !ctx.isupport.is_channel(target) {
1004        for change in obby_proto::parse_user_modes(line.param(1).unwrap_or_default()) {
1005            if change.set {
1006                if !ctx.model.me.modes.contains(change.mode) {
1007                    ctx.model.me.modes.push(change.mode);
1008                }
1009            } else {
1010                ctx.model.me.modes.retain(|m| m != change.mode);
1011            }
1012        }
1013        return Vec::new();
1014    }
1015
1016    let name = target.to_string();
1017    let folded = ctx.fold(&name);
1018    let args: Vec<String> = line.params.iter().skip(1).cloned().collect();
1019    let changes = parse_channel_modes(ctx.isupport, &args);
1020    let order = ctx.isupport.prefix().clone();
1021
1022    let mut touched_members = false;
1023    for change in changes {
1024        if change.membership {
1025            let Some(nick) = change.arg else { continue };
1026            let Some(prefix) = order.char_for_mode(change.mode) else {
1027                continue;
1028            };
1029            let key = ctx.isupport.fold(&nick);
1030            if let Some(member) = ctx
1031                .model
1032                .channel_mut(&folded)
1033                .and_then(|channel| channel.members.get_mut(&key))
1034            {
1035                if change.set {
1036                    member.grant(prefix, &order);
1037                } else {
1038                    member.revoke(prefix);
1039                }
1040                touched_members = true;
1041            }
1042            continue;
1043        }
1044        let Some(channel) = ctx.model.channel_mut(&folded) else {
1045            continue;
1046        };
1047        if change.set {
1048            channel.modes.insert(change.mode, change.arg);
1049        } else {
1050            channel.modes.remove(&change.mode);
1051        }
1052    }
1053
1054    if touched_members {
1055        alloc::vec![
1056            Change::ChannelModesChanged {
1057                channel: name.clone()
1058            },
1059            Change::ChannelMembersChanged { channel: name },
1060        ]
1061    } else {
1062        alloc::vec![Change::ChannelModesChanged { channel: name }]
1063    }
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068    use super::*;
1069    use crate::model::MessageKind;
1070
1071    struct Harness {
1072        model: Model,
1073        isupport: Isupport,
1074        latest_ms: u64,
1075        historical: bool,
1076    }
1077
1078    impl Harness {
1079        fn new() -> Self {
1080            let mut isupport = Isupport::default();
1081            isupport.apply("CHANTYPES=#^$");
1082            isupport.apply("PREFIX=(qaohv)~&@%+");
1083            isupport.apply("CHANMODES=beI,k,l,imnpst");
1084            let mut model = Model::default();
1085            model.me.nick = "me".to_string();
1086            Self {
1087                model,
1088                isupport,
1089                latest_ms: 1000,
1090                historical: false,
1091            }
1092        }
1093
1094        /// Put us in a channel, which is the only thing that brings one into being.
1095        fn joined(mut self, channel: &str) -> Self {
1096            self.feed(&alloc::format!(":me!u@h JOIN {channel}"));
1097            self
1098        }
1099
1100        fn feed(&mut self, raw: &str) -> Vec<Change> {
1101            let line = Line::parse(raw).expect("the test line should parse");
1102            let mut ctx = Context {
1103                model: &mut self.model,
1104                isupport: &self.isupport,
1105                latest_ms: &mut self.latest_ms,
1106                historical: self.historical,
1107            };
1108            apply(&mut ctx, &line)
1109        }
1110
1111        fn channel(&self, name: &str) -> &crate::model::Channel {
1112            self.model
1113                .channel(&self.isupport.fold(name))
1114                .expect("the channel should exist")
1115        }
1116
1117        fn person(&self, nick: &str) -> &crate::model::Person {
1118            self.model
1119                .person(&self.isupport.fold(nick))
1120                .expect("we should know this person")
1121        }
1122
1123        fn conversation(&self, nick: &str) -> &crate::model::Conversation {
1124            self.model
1125                .conversation(&self.isupport.fold(nick))
1126                .expect("the conversation should exist")
1127        }
1128    }
1129
1130    impl Harness {
1131        fn whois(&self, nick: &str) -> &crate::model::Whois {
1132            self.model
1133                .whois(&self.isupport.fold(nick))
1134                .expect("the whois record should exist")
1135        }
1136    }
1137
1138    #[test]
1139    fn a_whois_reply_is_reported_once_it_is_whole() {
1140        let mut h = Harness::new();
1141        for line in [
1142            ":s 311 me bob ident host.example * :Bob Smith",
1143            ":s 312 me bob irc.example.org :ObbyIRCd",
1144            ":s 313 me bob :is an IRC Operator",
1145            ":s 378 me bob :is connecting from *@h4ks.local 172.18.0.1",
1146            ":s 671 me bob :is using a Secure Connection",
1147            ":s 319 me bob :~@#obby +#other",
1148            ":s 330 me bob bob_acct :is logged in as",
1149            ":s 317 me bob 42 1787477054 :seconds idle, signon time",
1150        ] {
1151            assert!(
1152                h.feed(line).is_empty(),
1153                "a partial record would redraw the card once per numeric"
1154            );
1155        }
1156
1157        let changes = h.feed(":s 318 me bob :End of /WHOIS list.");
1158        assert!(matches!(changes.first(), Some(Change::WhoisReceived { nick }) if nick == "bob"));
1159
1160        let whois = h.whois("BOB");
1161        assert_eq!(whois.username.as_deref(), Some("ident"));
1162        assert_eq!(whois.host.as_deref(), Some("host.example"));
1163        assert_eq!(whois.realname.as_deref(), Some("Bob Smith"));
1164        assert_eq!(whois.server.as_deref(), Some("irc.example.org"));
1165        assert_eq!(whois.server_info.as_deref(), Some("ObbyIRCd"));
1166        assert_eq!(whois.operator.as_deref(), Some("is an IRC Operator"));
1167        assert_eq!(whois.account.as_deref(), Some("bob_acct"));
1168        assert_eq!(whois.channels, ["~@#obby", "+#other"]);
1169        assert_eq!(whois.idle_secs, Some(42));
1170        assert_eq!(whois.signon_ms, Some(1_787_477_054_000));
1171        assert!(whois.secure);
1172        assert!(whois.complete);
1173    }
1174
1175    #[test]
1176    fn a_second_whois_replaces_the_first_rather_than_merging_into_it() {
1177        let mut h = Harness::new();
1178        h.feed(":s 313 me bob :is an IRC Operator");
1179        h.feed(":s 671 me bob :is using a Secure Connection");
1180        h.feed(":s 318 me bob :End of /WHOIS list.");
1181
1182        h.feed(":s 311 me bob ident host.example * :Bob Smith");
1183        h.feed(":s 318 me bob :End of /WHOIS list.");
1184        let whois = h.whois("bob");
1185        assert_eq!(
1186            whois.operator, None,
1187            "a deopered user sends no 313 to say they lost it"
1188        );
1189        assert!(!whois.secure);
1190    }
1191
1192    #[test]
1193    fn the_end_of_a_whois_for_a_nick_with_no_record_reports_nothing() {
1194        let mut h = Harness::new();
1195        assert!(h.feed(":s 318 me nobody :End of /WHOIS list.").is_empty());
1196    }
1197
1198    #[test]
1199    fn a_338_names_the_host_in_its_own_parameter() {
1200        let mut h = Harness::new();
1201        h.feed(":s 338 me bob real.host.example :actually using host");
1202        assert_eq!(
1203            h.whois("bob").actual_host.as_deref(),
1204            Some("real.host.example")
1205        );
1206    }
1207
1208    #[test]
1209    fn a_rename_moves_the_channel_and_keeps_what_is_in_it() {
1210        let mut h = Harness::new().joined("#obby");
1211        h.feed(":bob!u@h JOIN #obby");
1212        h.feed(":bob!u@h PRIVMSG #obby :hello");
1213
1214        let changes = h.feed(":s RENAME #obby #obby-world :tidying up");
1215        assert!(
1216            matches!(changes.first(), Some(Change::ChannelRenamed { from, to }) if from == "#obby" && to == "#obby-world")
1217        );
1218        assert!(
1219            h.model.channel(&h.isupport.fold("#obby")).is_none(),
1220            "the old name must not still resolve"
1221        );
1222        let renamed = h.channel("#obby-world");
1223        assert_eq!(renamed.name, "#obby-world");
1224        assert!(renamed.members.contains_key(&h.isupport.fold("bob")));
1225        assert_eq!(renamed.log.last().map(|m| m.text.as_str()), Some("hello"));
1226    }
1227
1228    #[test]
1229    fn a_rename_of_a_channel_we_are_not_in_changes_nothing() {
1230        let mut h = Harness::new();
1231        assert!(h.feed(":s RENAME #theirs #theirs-too").is_empty());
1232        assert_eq!(h.model.channels().count(), 0);
1233    }
1234
1235    #[test]
1236    fn a_message_to_a_channel_we_never_joined_invents_nothing() {
1237        let mut h = Harness::new();
1238        assert!(h.feed(":bob!u@h PRIVMSG #ghost :hello").is_empty());
1239        assert_eq!(
1240            h.model.channels().count(),
1241            0,
1242            "a server naming channels we are not in would otherwise grow the model without limit"
1243        );
1244    }
1245
1246    #[test]
1247    fn a_channel_message_lands_in_that_channel() {
1248        let mut h = Harness::new().joined("#obby");
1249        let changes = h.feed(":bob!u@h PRIVMSG #obby :hello");
1250        assert!(
1251            matches!(changes.first(), Some(Change::MessageAdded { target, .. }) if target == "#obby")
1252        );
1253        let message = h.channel("#obby").log.last().expect("a message");
1254        assert_eq!(message.text, "hello");
1255        assert_eq!(message.sender, "bob");
1256        assert!(!message.own);
1257    }
1258
1259    #[test]
1260    fn a_private_message_is_filed_under_the_sender_not_under_us() {
1261        let mut h = Harness::new();
1262        h.feed(":bob!u@h PRIVMSG me :psst");
1263        assert_eq!(h.conversation("bob").log.len(), 1);
1264        assert!(
1265            h.model.conversation(&h.isupport.fold("me")).is_none(),
1266            "a conversation named after ourselves would be nobody"
1267        );
1268    }
1269
1270    #[test]
1271    fn our_own_private_message_is_filed_under_who_we_sent_it_to() {
1272        let mut h = Harness::new();
1273        h.feed(":me!u@h PRIVMSG bob :hi there");
1274        let message = h.conversation("bob").log.last().expect("a message");
1275        assert!(message.own);
1276        assert_eq!(message.text, "hi there");
1277    }
1278
1279    #[test]
1280    fn joining_creates_the_channel_and_others_joining_add_members() {
1281        let mut h = Harness::new();
1282        assert_eq!(
1283            h.feed(":me!u@h JOIN #obby"),
1284            [Change::ChannelJoined {
1285                channel: "#obby".to_string()
1286            }]
1287        );
1288        h.feed(":bob!u@h JOIN #obby");
1289        assert_eq!(h.channel("#obby").members.len(), 1);
1290        assert_eq!(h.person("bob").nick.as_str(), "bob");
1291    }
1292
1293    #[test]
1294    fn extended_join_carries_the_account_without_a_who() {
1295        let mut h = Harness::new().joined("#obby");
1296        h.feed(":bob!u@h JOIN #obby alice :Bob");
1297        assert_eq!(h.person("bob").account.as_deref(), Some("alice"));
1298    }
1299
1300    #[test]
1301    fn an_unauthenticated_join_records_no_account() {
1302        let mut h = Harness::new().joined("#obby");
1303        h.feed(":bob!u@h JOIN #obby * :Bob");
1304        assert_eq!(
1305            h.person("bob").account,
1306            None,
1307            "a star means logged in as nobody"
1308        );
1309    }
1310
1311    #[test]
1312    fn leaving_forgets_the_channel_but_someone_else_leaving_does_not() {
1313        let mut h = Harness::new().joined("#obby");
1314        h.feed(":bob!u@h JOIN #obby");
1315        h.feed(":bob!u@h PART #obby :bye");
1316        assert!(h.channel("#obby").members.is_empty());
1317
1318        h.feed(":me!u@h PART #obby :bye");
1319        assert!(h.model.channel(&h.isupport.fold("#obby")).is_none());
1320    }
1321
1322    #[test]
1323    fn being_kicked_forgets_the_channel() {
1324        let mut h = Harness::new().joined("#obby");
1325        h.feed(":bob!u@h JOIN #obby");
1326        h.feed(":op!u@h KICK #obby bob :rude");
1327        assert!(h.channel("#obby").members.is_empty());
1328
1329        h.feed(":op!u@h KICK #obby me :rude");
1330        assert!(h.model.channel(&h.isupport.fold("#obby")).is_none());
1331    }
1332
1333    #[test]
1334    fn a_quit_reports_every_channel_they_were_in() {
1335        let mut h = Harness::new().joined("#a").joined("#b");
1336        for name in ["#a", "#b"] {
1337            h.feed(&alloc::format!(":bob!u@h JOIN {name}"));
1338        }
1339        let changes = h.feed(":bob!u@h QUIT :gone");
1340        assert_eq!(changes.len(), 2);
1341        assert!(h.channel("#a").members.is_empty());
1342        assert!(h.channel("#b").members.is_empty());
1343    }
1344
1345    #[test]
1346    fn a_rename_follows_the_servers_casemapping() {
1347        let mut h = Harness::new().joined("#obby");
1348        h.feed(":[bob]!u@h JOIN #obby");
1349        h.feed(":[bob]!u@h NICK {BOB}");
1350        let channel = h.channel("#obby");
1351        assert_eq!(
1352            channel.members.len(),
1353            1,
1354            "rfc1459 folds braces onto brackets, so this is one person"
1355        );
1356        assert!(channel.members.contains_key(&h.isupport.fold("{bob}")));
1357        assert_eq!(h.person("{bob}").nick, "{BOB}");
1358    }
1359
1360    #[test]
1361    fn names_splits_prefixes_and_hostmasks() {
1362        let mut h = Harness::new().joined("#obby");
1363        h.feed(":s 353 me = #obby :~&@alice +bob carol!u@host");
1364        let channel = h.channel("#obby");
1365        assert_eq!(channel.members.len(), 3);
1366        assert_eq!(
1367            channel
1368                .members
1369                .get(&h.isupport.fold("alice"))
1370                .map(|m| m.prefixes.as_str()),
1371            Some("~&@")
1372        );
1373        assert_eq!(
1374            channel
1375                .members
1376                .get(&h.isupport.fold("bob"))
1377                .map(|m| m.prefixes.as_str()),
1378            Some("+")
1379        );
1380        assert!(
1381            channel.members.contains_key(&h.isupport.fold("carol")),
1382            "userhost-in-names must not become part of the nick"
1383        );
1384    }
1385
1386    #[test]
1387    fn a_membership_mode_moves_a_prefix_and_a_channel_mode_does_not() {
1388        let mut h = Harness::new().joined("#obby");
1389        h.feed(":bob!u@h JOIN #obby");
1390        h.feed(":op!u@h MODE #obby +o bob");
1391        assert_eq!(
1392            h.channel("#obby")
1393                .members
1394                .get(&h.isupport.fold("bob"))
1395                .map(|m| m.prefixes.as_str()),
1396            Some("@")
1397        );
1398
1399        h.feed(":op!u@h MODE #obby +b nuisance!*@*");
1400        assert_eq!(
1401            h.channel("#obby")
1402                .members
1403                .get(&h.isupport.fold("bob"))
1404                .map(|m| m.prefixes.as_str()),
1405            Some("@"),
1406            "a ban must not consume the nick argument of a membership mode"
1407        );
1408        assert!(h.channel("#obby").modes.contains_key(&'b'));
1409
1410        h.feed(":op!u@h MODE #obby -o bob");
1411        assert_eq!(
1412            h.channel("#obby")
1413                .members
1414                .get(&h.isupport.fold("bob"))
1415                .map(|m| m.prefixes.as_str()),
1416            Some("")
1417        );
1418    }
1419
1420    #[test]
1421    fn our_own_user_modes_accumulate() {
1422        let mut h = Harness::new();
1423        h.feed(":s MODE me +iw");
1424        assert_eq!(h.model.me.modes, "iw");
1425        h.feed(":s MODE me -i");
1426        assert_eq!(h.model.me.modes, "w");
1427    }
1428
1429    #[test]
1430    fn the_topic_arrives_from_a_command_or_a_numeric() {
1431        let mut h = Harness::new().joined("#obby");
1432        h.feed(":s 332 me #obby :from the numeric");
1433        assert_eq!(
1434            h.channel("#obby").topic.as_deref(),
1435            Some("from the numeric")
1436        );
1437        h.feed(":bob!u@h TOPIC #obby :from the command");
1438        assert_eq!(
1439            h.channel("#obby").topic.as_deref(),
1440            Some("from the command")
1441        );
1442        assert_eq!(h.channel("#obby").topic_by.as_deref(), Some("bob"));
1443    }
1444
1445    #[test]
1446    fn clearing_the_topic_leaves_none_rather_than_an_empty_string() {
1447        let mut h = Harness::new().joined("#obby");
1448        h.feed(":bob!u@h TOPIC #obby :something");
1449        h.feed(":bob!u@h TOPIC #obby :");
1450        assert_eq!(h.channel("#obby").topic, None);
1451    }
1452
1453    #[test]
1454    fn server_time_decides_when_a_message_happened() {
1455        let mut h = Harness::new().joined("#obby");
1456        h.feed("@time=2026-09-06T10:00:00.000Z :bob!u@h PRIVMSG #obby :stamped");
1457        let message = h.channel("#obby").log.last().expect("a message");
1458        assert_eq!(message.key.time_ms, 1_788_688_800_000);
1459    }
1460
1461    #[test]
1462    fn an_unstamped_line_reuses_the_latest_instant_seen() {
1463        let mut h = Harness::new().joined("#obby");
1464        h.feed("@time=2026-09-06T10:00:00.000Z :bob!u@h PRIVMSG #obby :stamped");
1465        h.feed(":bob!u@h PRIVMSG #obby :unstamped");
1466        let messages: Vec<u64> = h
1467            .channel("#obby")
1468            .log
1469            .iter()
1470            .map(|m| m.key.time_ms)
1471            .collect();
1472        assert_eq!(
1473            messages,
1474            [1_788_688_800_000, 1_788_688_800_000],
1475            "an unstamped line must not sort back before the stamped ones"
1476        );
1477    }
1478
1479    #[test]
1480    fn a_ctcp_keeps_its_command_and_loses_its_wrapper() {
1481        let mut h = Harness::new().joined("#obby");
1482        h.feed(":bob!u@h PRIVMSG #obby :\u{1}ACTION waves\u{1}");
1483        let message = h.channel("#obby").log.last().expect("a message");
1484        assert_eq!(
1485            message.kind,
1486            MessageKind::Ctcp {
1487                command: "ACTION".to_string()
1488            }
1489        );
1490        assert_eq!(message.text, "waves");
1491    }
1492
1493    #[test]
1494    fn both_spellings_of_the_reply_tag_are_accepted() {
1495        let mut h = Harness::new().joined("#obby");
1496        h.feed("@+reply=abc :bob!u@h PRIVMSG #obby :one");
1497        h.feed("@+draft/reply=def :bob!u@h PRIVMSG #obby :two");
1498        let replies: Vec<Option<&str>> = h
1499            .channel("#obby")
1500            .log
1501            .iter()
1502            .map(|m| m.reply_to.as_deref())
1503            .collect();
1504        assert_eq!(
1505            replies,
1506            [Some("abc"), Some("def")],
1507            "the server sends one spelling from filehost and the other from pushbot"
1508        );
1509    }
1510
1511    #[test]
1512    fn a_repeated_msgid_is_only_stored_once() {
1513        let mut h = Harness::new().joined("#obby");
1514        h.feed("@msgid=x1 :bob!u@h PRIVMSG #obby :hello");
1515        h.feed("@msgid=x1 :bob!u@h PRIVMSG #obby :hello");
1516        assert_eq!(h.channel("#obby").log.len(), 1);
1517    }
1518
1519    #[test]
1520    fn a_mention_needs_a_whole_nick_not_a_substring() {
1521        let map = Casemapping::Rfc1459;
1522        assert!(mentions(map, "hey me, look", "me"));
1523        assert!(mentions(map, "me: look", "me"));
1524        assert!(mentions(map, "me", "me"));
1525        assert!(
1526            !mentions(map, "spameda", "me"),
1527            "a nick inside a word is not a mention"
1528        );
1529        assert!(!mentions(map, "me-too", "me"), "a dash is a nick character");
1530        assert!(!mentions(map, "", "me"));
1531        assert!(!mentions(map, "anything", ""));
1532    }
1533
1534    #[test]
1535    fn a_mention_folds_the_way_the_server_does() {
1536        assert!(
1537            mentions(Casemapping::Rfc1459, "hey {NICK} there", "[nick]"),
1538            "rfc1459 folds braces onto brackets, so this addresses us"
1539        );
1540        assert!(
1541            !mentions(Casemapping::Ascii, "hey {NICK} there", "[nick]"),
1542            "an ascii server treats them as different people"
1543        );
1544        assert!(mentions(Casemapping::Ascii, "hey NICK there", "nick"));
1545    }
1546
1547    #[test]
1548    fn a_channel_message_counts_as_unread_and_a_mention_only_when_it_names_us() {
1549        let mut h = Harness::new().joined("#obby");
1550        h.feed(":bob!u@h PRIVMSG #obby :morning all");
1551        assert_eq!(
1552            (h.channel("#obby").unread, h.channel("#obby").mentions),
1553            (1, 0)
1554        );
1555
1556        h.feed(":bob!u@h PRIVMSG #obby :me: got a second?");
1557        assert_eq!(
1558            (h.channel("#obby").unread, h.channel("#obby").mentions),
1559            (2, 1)
1560        );
1561    }
1562
1563    #[test]
1564    fn our_own_words_and_replayed_history_are_never_unread() {
1565        let mut h = Harness::new().joined("#obby");
1566        h.feed(":me!u@h PRIVMSG #obby :me talking about me");
1567        assert_eq!(
1568            (h.channel("#obby").unread, h.channel("#obby").mentions),
1569            (0, 0)
1570        );
1571
1572        h.historical = true;
1573        h.feed(":bob!u@h PRIVMSG #obby :me, an old message");
1574        assert_eq!(
1575            (h.channel("#obby").unread, h.channel("#obby").mentions),
1576            (0, 0),
1577            "scrolling back must not light up the unread badge"
1578        );
1579    }
1580
1581    #[test]
1582    fn every_private_message_is_addressed_to_us_by_existing() {
1583        let mut h = Harness::new();
1584        h.feed(":bob!u@h PRIVMSG me :no nick needed");
1585        assert_eq!(h.conversation("bob").unread, 1);
1586    }
1587
1588    #[test]
1589    fn the_server_confirming_a_read_marker_clears_what_it_covers() {
1590        let mut h = Harness::new().joined("#obby");
1591        h.feed(":bob!u@h PRIVMSG #obby :me: hello");
1592        assert_eq!(
1593            (h.channel("#obby").unread, h.channel("#obby").mentions),
1594            (1, 1)
1595        );
1596
1597        let changes = h.feed(":s MARKREAD #obby timestamp=2026-09-06T10:00:00.000Z");
1598        assert_eq!(
1599            changes,
1600            [Change::ReadMarkerMoved {
1601                target: "#obby".to_string()
1602            }]
1603        );
1604        assert_eq!(
1605            (h.channel("#obby").unread, h.channel("#obby").mentions),
1606            (0, 0)
1607        );
1608        assert_eq!(
1609            h.channel("#obby").read_marker.as_deref(),
1610            Some("2026-09-06T10:00:00.000Z")
1611        );
1612    }
1613
1614    #[test]
1615    fn a_reaction_attaches_to_the_message_rather_than_becoming_one() {
1616        let mut h = Harness::new().joined("#obby");
1617        h.feed("@msgid=m1 :bob!u@h PRIVMSG #obby :something");
1618        let changes = h.feed("@+draft/react=👍;+draft/reply=m1 :carol!u@h TAGMSG #obby");
1619        assert_eq!(
1620            changes,
1621            [Change::MessageReacted {
1622                target: "#obby".to_string(),
1623                msgid: "m1".to_string()
1624            }]
1625        );
1626        assert_eq!(
1627            h.channel("#obby").log.len(),
1628            1,
1629            "a reaction is not a message in the log"
1630        );
1631        let message = h.channel("#obby").log.get("m1").expect("the message");
1632        assert_eq!(
1633            message.reactions.get("👍").map(Vec::as_slice),
1634            Some(&["carol".to_string()][..])
1635        );
1636    }
1637
1638    #[test]
1639    fn reacting_twice_counts_once_and_taking_it_back_removes_it() {
1640        let mut h = Harness::new().joined("#obby");
1641        h.feed("@msgid=m1 :bob!u@h PRIVMSG #obby :something");
1642        h.feed("@+draft/react=👍;+reply=m1 :carol!u@h TAGMSG #obby");
1643        h.feed("@+draft/react=👍;+reply=m1 :carol!u@h TAGMSG #obby");
1644        assert_eq!(
1645            h.channel("#obby")
1646                .log
1647                .get("m1")
1648                .expect("message")
1649                .reactions
1650                .get("👍")
1651                .map(Vec::len),
1652            Some(1)
1653        );
1654
1655        h.feed("@+draft/unreact=👍;+reply=m1 :carol!u@h TAGMSG #obby");
1656        assert!(
1657            h.channel("#obby")
1658                .log
1659                .get("m1")
1660                .expect("message")
1661                .reactions
1662                .is_empty(),
1663            "the last reactor leaving takes the emoji with them"
1664        );
1665    }
1666
1667    #[test]
1668    fn a_redaction_marks_the_message_and_keeps_what_it_said() {
1669        let mut h = Harness::new().joined("#obby");
1670        h.feed("@msgid=m1 :bob!u@h PRIVMSG #obby :regrettable");
1671        let changes = h.feed(":op!u@h REDACT #obby m1 :spam");
1672        assert_eq!(
1673            changes,
1674            [Change::MessageRedacted {
1675                target: "#obby".to_string(),
1676                msgid: "m1".to_string()
1677            }]
1678        );
1679        let message = h.channel("#obby").log.get("m1").expect("the message stays");
1680        assert!(message.redacted);
1681        assert_eq!(
1682            message.text, "regrettable",
1683            "discarding the content leaves no way to show who deleted what"
1684        );
1685    }
1686
1687    #[test]
1688    fn going_away_shows_on_every_member_list_they_are_in() {
1689        let mut h = Harness::new().joined("#a").joined("#b");
1690        h.feed(":bob!u@h JOIN #a");
1691        h.feed(":bob!u@h JOIN #b");
1692        h.feed(":bob!u@h AWAY :back later");
1693        for name in ["#a", "#b"] {
1694            assert!(
1695                h.channel(name)
1696                    .members
1697                    .contains_key(&h.isupport.fold("bob"))
1698            );
1699        }
1700        assert_eq!(
1701            h.person("bob").away.as_deref(),
1702            Some("back later"),
1703            "away is recorded once, not once per channel"
1704        );
1705
1706        h.feed(":bob!u@h AWAY");
1707        assert_eq!(
1708            h.model
1709                .person(&h.isupport.fold("bob"))
1710                .and_then(|p| p.away.as_deref()),
1711            None
1712        );
1713    }
1714
1715    #[test]
1716    fn logging_into_an_account_updates_everywhere_they_are() {
1717        let mut h = Harness::new().joined("#obby");
1718        h.feed(":bob!u@h JOIN #obby");
1719        h.feed(":bob!u@h ACCOUNT alice");
1720        assert_eq!(
1721            h.model
1722                .person(&h.isupport.fold("bob"))
1723                .and_then(|p| p.account.as_deref()),
1724            Some("alice")
1725        );
1726
1727        h.feed(":bob!u@h ACCOUNT *");
1728        assert_eq!(
1729            h.model
1730                .person(&h.isupport.fold("bob"))
1731                .and_then(|p| p.account.as_deref()),
1732            None,
1733            "a star means logged out"
1734        );
1735    }
1736
1737    #[test]
1738    fn our_own_away_and_account_land_on_us() {
1739        let mut h = Harness::new();
1740        h.feed(":me!u@h AWAY :lunch");
1741        assert_eq!(h.model.me.away.as_deref(), Some("lunch"));
1742        h.feed(":me!u@h ACCOUNT myaccount");
1743        assert_eq!(h.model.me.account.as_deref(), Some("myaccount"));
1744    }
1745
1746    #[test]
1747    fn a_tagmsg_carrying_nothing_we_model_is_still_a_message() {
1748        let mut h = Harness::new().joined("#obby");
1749        h.feed("@+example.org/thing=1 :bob!u@h TAGMSG #obby");
1750        assert_eq!(h.channel("#obby").log.len(), 1);
1751    }
1752
1753    #[test]
1754    fn metadata_about_a_person_is_held_once_for_them() {
1755        let mut h = Harness::new().joined("#a").joined("#b");
1756        h.feed(":bob!u@h JOIN #a");
1757        h.feed(":bob!u@h JOIN #b");
1758        let changes = h.feed(":s METADATA bob display-name * :Bobby Tables");
1759        assert_eq!(
1760            changes,
1761            [Change::MetadataChanged {
1762                target: "bob".to_string(),
1763                key: "display-name".to_string()
1764            }]
1765        );
1766        assert_eq!(
1767            h.person("bob")
1768                .metadata
1769                .get("display-name")
1770                .map(String::as_str),
1771            Some("Bobby Tables"),
1772            "one person, one record, however many channels we share"
1773        );
1774    }
1775
1776    #[test]
1777    fn a_key_with_no_value_clears_it() {
1778        let mut h = Harness::new();
1779        h.feed(":s METADATA bob avatar * :https://example.org/a.png");
1780        assert!(h.person("bob").metadata.contains_key("avatar"));
1781        h.feed(":s METADATA bob avatar *");
1782        assert!(
1783            !h.person("bob").metadata.contains_key("avatar"),
1784            "a push with no value is how the server deletes a key"
1785        );
1786    }
1787
1788    #[test]
1789    fn the_not_set_reply_clears_a_key_too() {
1790        let mut h = Harness::new();
1791        h.feed(":s 761 me bob color * :#ff0000");
1792        assert_eq!(
1793            h.person("bob").metadata.get("color").map(String::as_str),
1794            Some("#ff0000")
1795        );
1796        h.feed(":s 766 me bob color :no matching key");
1797        assert!(!h.person("bob").metadata.contains_key("color"));
1798    }
1799
1800    #[test]
1801    fn a_numeric_reply_reads_past_our_own_nick() {
1802        let mut h = Harness::new();
1803        h.feed(":s 761 me bob display-name * :Bobby");
1804        assert_eq!(
1805            h.person("bob")
1806                .metadata
1807                .get("display-name")
1808                .map(String::as_str),
1809            Some("Bobby"),
1810            "the numeric puts our nick first, so the target is one parameter along"
1811        );
1812    }
1813
1814    #[test]
1815    fn channel_metadata_lands_on_the_channel_not_on_a_person() {
1816        let mut h = Harness::new().joined("#obby");
1817        h.feed(":s METADATA #obby avatar * :https://example.org/c.png");
1818        assert_eq!(
1819            h.channel("#obby")
1820                .metadata
1821                .get("avatar")
1822                .map(String::as_str),
1823            Some("https://example.org/c.png")
1824        );
1825        assert!(h.model.person(&h.isupport.fold("#obby")).is_none());
1826    }
1827
1828    #[test]
1829    fn a_star_target_means_us() {
1830        let mut h = Harness::new();
1831        h.feed(":s METADATA * display-name * :LocalUser Myself");
1832        assert_eq!(
1833            h.model.me.metadata.get("display-name").map(String::as_str),
1834            Some("LocalUser Myself")
1835        );
1836    }
1837
1838    #[cfg(feature = "obby")]
1839    #[test]
1840    fn a_link_preview_attaches_to_its_message_instead_of_becoming_one() {
1841        let mut h = Harness::new().joined("#obby");
1842        h.feed("@msgid=m1 :bob!u@h PRIVMSG #obby :look at https://example.org");
1843        h.feed(
1844            "@+reply=m1;obsidianirc/link-preview-title=Example;obsidianirc/link-preview-snippet=A\\spage :s TAGMSG #obby",
1845        );
1846        assert_eq!(
1847            h.channel("#obby").log.len(),
1848            1,
1849            "the preview arrives long after the message, and must not become a second one"
1850        );
1851        let preview = h
1852            .channel("#obby")
1853            .log
1854            .get("m1")
1855            .expect("the message")
1856            .link_preview
1857            .as_ref()
1858            .expect("a preview");
1859        assert_eq!(preview.title, "Example");
1860        assert_eq!(preview.snippet.as_deref(), Some("A page"));
1861    }
1862
1863    #[cfg(feature = "obby")]
1864    #[test]
1865    fn a_preview_for_a_message_we_never_saw_is_dropped() {
1866        let mut h = Harness::new().joined("#obby");
1867        h.feed("@+reply=gone;obsidianirc/link-preview-title=Example :s TAGMSG #obby");
1868        assert_eq!(
1869            h.channel("#obby").log.len(),
1870            0,
1871            "there is nothing to attach it to, and it is not a message of its own"
1872        );
1873    }
1874
1875    #[test]
1876    fn a_who_reply_fills_in_who_someone_actually_is() {
1877        let mut h = Harness::new().joined("#obby");
1878        h.feed(":s 352 me #obby ident host.example irc.example bob H@ :0 Bob Smith");
1879        let person = h.person("bob");
1880        assert_eq!(person.username.as_deref(), Some("ident"));
1881        assert_eq!(person.host.as_deref(), Some("host.example"));
1882        assert_eq!(person.realname.as_deref(), Some("Bob Smith"));
1883        assert_eq!(person.away, None, "H means here");
1884        assert_eq!(
1885            h.channel("#obby")
1886                .members
1887                .get(&h.isupport.fold("bob"))
1888                .map(|m| m.prefixes.as_str()),
1889            Some("@")
1890        );
1891    }
1892
1893    #[test]
1894    fn a_plain_who_never_signs_anybody_out() {
1895        let mut h = Harness::new().joined("#obby");
1896        h.feed(":bob!u@h JOIN #obby alice_acct :Bob");
1897        assert_eq!(h.person("bob").account.as_deref(), Some("alice_acct"));
1898
1899        h.feed(":s 352 me #obby ident host.example irc.example bob H :0 Bob");
1900        assert_eq!(
1901            h.person("bob").account.as_deref(),
1902            Some("alice_acct"),
1903            "a plain WHO carries no account field, which is not the same as an empty one"
1904        );
1905    }
1906
1907    #[test]
1908    fn a_whox_reply_carries_the_account() {
1909        let mut h = Harness::new().joined("#obby");
1910        h.feed(":s 354 me 332 #obby ident host.example bob H+ alice_acct :Bob Smith");
1911        let person = h.person("bob");
1912        assert_eq!(person.account.as_deref(), Some("alice_acct"));
1913        assert_eq!(person.realname.as_deref(), Some("Bob Smith"));
1914        assert_eq!(
1915            h.channel("#obby")
1916                .members
1917                .get(&h.isupport.fold("bob"))
1918                .map(|m| m.prefixes.as_str()),
1919            Some("+")
1920        );
1921    }
1922
1923    #[test]
1924    fn a_whox_reply_can_sign_somebody_out() {
1925        let mut h = Harness::new().joined("#obby");
1926        h.feed(":s 354 me 332 #obby ident host bob H acct :Bob");
1927        assert!(h.person("bob").account.is_some());
1928        h.feed(":s 354 me 332 #obby ident host bob H 0 :Bob");
1929        assert_eq!(
1930            h.person("bob").account,
1931            None,
1932            "a zero in the account field is how WHOX spells logged out"
1933        );
1934    }
1935
1936    #[test]
1937    fn a_whox_reply_to_somebody_elses_request_is_ignored() {
1938        let mut h = Harness::new().joined("#obby");
1939        h.feed(":s 354 me 999 #obby ident host bob H acct :Bob");
1940        assert!(
1941            h.model.person(&h.isupport.fold("bob")).is_none(),
1942            "the token is what tells our reply apart from another client's"
1943        );
1944    }
1945
1946    #[test]
1947    fn who_reports_away_and_operator_and_bot_flags() {
1948        let mut h = Harness::new().joined("#obby");
1949        h.feed(":s 352 me #obby ident host irc bob G*B@ :0 Bob");
1950        let person = h.person("bob");
1951        assert!(person.away.is_some(), "G means gone");
1952        assert!(person.operator);
1953        assert!(person.bot);
1954    }
1955
1956    #[test]
1957    fn coming_back_from_away_clears_it_but_going_away_keeps_a_known_reason() {
1958        let mut h = Harness::new().joined("#obby");
1959        h.feed(":bob!u@h AWAY :at lunch");
1960        h.feed(":s 352 me #obby ident host irc bob G :0 Bob");
1961        assert_eq!(
1962            h.person("bob").away.as_deref(),
1963            Some("at lunch"),
1964            "WHO says only that they are away, so a reason we already have survives"
1965        );
1966        h.feed(":s 352 me #obby ident host irc bob H :0 Bob");
1967        assert_eq!(h.person("bob").away, None);
1968    }
1969
1970    #[test]
1971    fn a_named_mode_is_recorded_by_its_name_not_its_letter() {
1972        let mut h = Harness::new().joined("#obby");
1973        let changes = h.feed(":s PROP #obby +obsidianirc/censor +obsidianirc/history=30d");
1974        assert_eq!(
1975            changes,
1976            [Change::ChannelModesChanged {
1977                channel: "#obby".to_string()
1978            }]
1979        );
1980        let modes = &h.channel("#obby").named_modes;
1981        assert_eq!(modes.get("obsidianirc/censor"), Some(&None));
1982        assert_eq!(
1983            modes.get("obsidianirc/history"),
1984            Some(&Some("30d".to_string())),
1985            "a letter means nothing without the server saying what it does; the name is stable"
1986        );
1987    }
1988
1989    #[test]
1990    fn removing_a_named_mode_ignores_the_parameter_it_carries() {
1991        let mut h = Harness::new().joined("#obby");
1992        h.feed(":s PROP #obby +obsidianirc/history=30d");
1993        h.feed(":s PROP #obby -obsidianirc/history=30d");
1994        assert!(
1995            !h.channel("#obby")
1996                .named_modes
1997                .contains_key("obsidianirc/history")
1998        );
1999    }
2000
2001    #[test]
2002    fn a_prop_listing_reports_state_rather_than_a_change() {
2003        let mut h = Harness::new().joined("#obby");
2004        h.feed(":s 961 me #obby obsidianirc/censor obsidianirc/history=7d");
2005        let modes = &h.channel("#obby").named_modes;
2006        assert_eq!(
2007            modes.len(),
2008            2,
2009            "a listing has no signs, so every entry is set"
2010        );
2011        assert_eq!(
2012            modes.get("obsidianirc/history"),
2013            Some(&Some("7d".to_string()))
2014        );
2015    }
2016
2017    #[test]
2018    fn a_prop_for_a_channel_we_never_joined_invents_nothing() {
2019        let mut h = Harness::new();
2020        assert!(h.feed(":s PROP #ghost +obsidianirc/censor").is_empty());
2021        assert_eq!(h.model.channels().count(), 0);
2022    }
2023
2024    #[test]
2025    fn the_same_change_arriving_as_mode_and_as_prop_is_harmless() {
2026        let mut h = Harness::new().joined("#obby");
2027        // the server relays every legacy MODE as an equivalent PROP to capability holders
2028        h.feed(":op!u@h MODE #obby +t");
2029        h.feed(":s PROP #obby +topiclock");
2030        assert!(h.channel("#obby").modes.contains_key(&'t'));
2031        assert!(h.channel("#obby").named_modes.contains_key("topiclock"));
2032    }
2033
2034    #[test]
2035    fn a_notice_is_not_an_ordinary_message() {
2036        let mut h = Harness::new().joined("#obby");
2037        h.feed(":bob!u@h NOTICE #obby :careful");
2038        assert_eq!(
2039            h.channel("#obby").log.last().map(|m| m.kind.clone()),
2040            Some(MessageKind::Notice)
2041        );
2042    }
2043}