Skip to main content

heatsync_core/
slash.rs

1//! slash commands typed in the composer, the way every chat client does it.
2//!
3//! the rule that matters: we only claim commands a CLIENT owns — joining,
4//! parting, quitting. everything else is the platform's (`/ban`, `/timeout`,
5//! `/me`, `/vip`, `/raid` …) and goes out verbatim, so moderating from here
6//! behaves exactly like typing into twitch or kick directly. claiming a name
7//! the platform also uses would silently break a mod action, which is why the
8//! list below is short and stays short.
9
10/// what a line typed into the composer turns out to be.
11#[derive(Debug, PartialEq)]
12pub enum Cmd {
13    Join(String),
14    /// leave a named channel, or the focused one when `None`.
15    Part(Option<String>),
16    Quit,
17    /// send these bytes to the platform as an ordinary message.
18    Send(String),
19    /// a client command we recognise but that was typed without its argument.
20    Usage(&'static str),
21}
22
23/// parse one composer line. anything that isn't ours comes back as `Send`, so
24/// platform commands are never swallowed.
25pub fn parse(input: &str) -> Cmd {
26    let line = input.trim();
27    // `//x` escapes: sends a literal `/x`, the only way to start a message with
28    // a slash. same convention chatterino uses.
29    if let Some(rest) = line.strip_prefix("//") {
30        return Cmd::Send(format!("/{rest}"));
31    }
32    let Some(body) = line.strip_prefix('/') else {
33        return Cmd::Send(line.to_string());
34    };
35    let mut it = body.splitn(2, char::is_whitespace);
36    let verb = it.next().unwrap_or_default().to_lowercase();
37    let arg = it.next().map(str::trim).filter(|s| !s.is_empty());
38    match verb.as_str() {
39        "join" | "j" => match arg {
40            Some(a) => Cmd::Join(a.to_string()),
41            None => Cmd::Usage("usage: /join <channel>  (or kick:<channel>)"),
42        },
43        "part" | "leave" | "close" => Cmd::Part(arg.map(str::to_string)),
44        "quit" | "q" | "exit" => Cmd::Quit,
45        // not ours — the platform's problem, and it must arrive untouched.
46        _ => Cmd::Send(line.to_string()),
47    }
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53
54    #[test]
55    fn join_takes_a_channel() {
56        assert_eq!(parse("/join hot"), Cmd::Join("hot".into()));
57        assert_eq!(parse("/j xqc"), Cmd::Join("xqc".into()));
58        assert_eq!(
59            parse("/join kick:trainwreckstv"),
60            Cmd::Join("kick:trainwreckstv".into())
61        );
62        assert_eq!(parse("  /join   hot  "), Cmd::Join("hot".into()));
63    }
64
65    #[test]
66    fn join_without_an_argument_explains_itself() {
67        assert!(matches!(parse("/join"), Cmd::Usage(_)));
68        assert!(matches!(parse("/join   "), Cmd::Usage(_)));
69    }
70
71    #[test]
72    fn part_and_quit_have_the_usual_aliases() {
73        assert_eq!(parse("/part"), Cmd::Part(None));
74        assert_eq!(parse("/leave"), Cmd::Part(None));
75        assert_eq!(parse("/part xqc"), Cmd::Part(Some("xqc".into())));
76        assert_eq!(parse("/quit"), Cmd::Quit);
77        assert_eq!(parse("/q"), Cmd::Quit);
78    }
79
80    #[test]
81    fn verbs_are_case_insensitive() {
82        assert_eq!(parse("/JOIN hot"), Cmd::Join("hot".into()));
83        assert_eq!(parse("/Quit"), Cmd::Quit);
84    }
85
86    /// the one that must never regress: claiming a platform command would break
87    /// moderating from the TUI, silently.
88    #[test]
89    fn platform_commands_pass_through_untouched() {
90        for line in [
91            "/ban someguy",
92            "/timeout someguy 600 spam",
93            "/me waves",
94            "/vip friend",
95            "/unban someguy",
96            "/mod someguy",
97            "/raid xqc",
98            "/clear",
99            "/slow 30",
100            "/emoteonly",
101            "/followers 10m",
102            "/w someguy hello",
103            "/announce hello chat",
104        ] {
105            assert_eq!(
106                parse(line),
107                Cmd::Send(line.into()),
108                "{line} must reach the platform"
109            );
110        }
111    }
112
113    #[test]
114    fn ordinary_messages_are_untouched() {
115        assert_eq!(parse("hello chat"), Cmd::Send("hello chat".into()));
116        assert_eq!(parse("PagMan"), Cmd::Send("PagMan".into()));
117        // a bare slash isn't a command
118        assert_eq!(parse("/"), Cmd::Send("/".into()));
119    }
120
121    #[test]
122    fn double_slash_sends_a_literal_slash() {
123        assert_eq!(parse("//join hot"), Cmd::Send("/join hot".into()));
124        assert_eq!(parse("//ban"), Cmd::Send("/ban".into()));
125    }
126}