1#[derive(Debug, PartialEq)]
13pub enum Cmd {
14 Join(String),
15 Open(String),
17 Keep,
19 Part(Option<String>),
21 Quit,
22 Tab(crate::TabKind),
24 Whisper(String, String),
26 Dm(String, String),
28 Op(String),
30 Re(String),
32 Share,
34 Layout(String),
36 Send(String),
38 Usage(&'static str),
40}
41
42pub fn parse(input: &str) -> Cmd {
45 let line = input.trim();
46 if let Some(rest) = line.strip_prefix("//") {
49 return Cmd::Send(format!("/{rest}"));
50 }
51 let Some(body) = line.strip_prefix('/') else {
52 return Cmd::Send(line.to_string());
53 };
54 let mut it = body.splitn(2, char::is_whitespace);
55 let verb = it.next().unwrap_or_default().to_lowercase();
56 let arg = it.next().map(str::trim).filter(|s| !s.is_empty());
57 match verb.as_str() {
58 "join" | "j" => match arg {
59 Some(a) => Cmd::Join(a.to_string()),
60 None => Cmd::Usage("usage: /join <channel> (or kick:<channel>)"),
61 },
62 "open" => match arg {
63 Some(a) => Cmd::Open(a.to_string()),
64 None => Cmd::Usage("usage: /open <channel> (or kick:<channel>)"),
65 },
66 "keep" => Cmd::Keep,
67 "part" | "leave" | "close" => Cmd::Part(arg.map(str::to_string)),
68 "quit" | "q" | "exit" => Cmd::Quit,
69 "mentions" | "feed" | "whispers" => match crate::TabKind::parse(&verb) {
70 Some(k) => Cmd::Tab(k),
71 None => Cmd::Send(line.to_string()),
72 },
73 "w" | "whisper" | "dm" => {
74 let mut p = arg.unwrap_or_default().splitn(2, char::is_whitespace);
75 let to = p.next().unwrap_or_default();
76 let text = p.next().map(str::trim).unwrap_or_default();
77 match (verb == "dm", to.is_empty() || text.is_empty()) {
78 (_, true) if verb == "dm" => Cmd::Usage("usage: /dm <user> <message>"),
79 (_, true) => Cmd::Usage("usage: /w <user> <message>"),
80 (true, _) => Cmd::Dm(to.into(), text.into()),
81 _ => Cmd::Whisper(to.into(), text.into()),
82 }
83 }
84 "sharelayout" | "sl" | "share" => Cmd::Share,
85 "layout" => match arg {
86 Some(a) => Cmd::Layout(a.to_string()),
87 None => Cmd::Usage("usage: /layout <heatsync.org/m/ link>"),
88 },
89 "op" => match arg {
90 Some(a) => Cmd::Op(a.to_string()),
91 None => Cmd::Usage("usage: /op <text> (posts to the heatsync feed)"),
92 },
93 "re" => match arg {
94 Some(a) => Cmd::Re(a.to_string()),
95 None => Cmd::Usage("usage: /re <text> (replies to your last OP)"),
96 },
97 _ => Cmd::Send(line.to_string()),
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn join_takes_a_channel() {
108 assert_eq!(parse("/join hot"), Cmd::Join("hot".into()));
109 assert_eq!(parse("/j xqc"), Cmd::Join("xqc".into()));
110 assert_eq!(
111 parse("/join kick:trainwreckstv"),
112 Cmd::Join("kick:trainwreckstv".into())
113 );
114 assert_eq!(parse(" /join hot "), Cmd::Join("hot".into()));
115 }
116
117 #[test]
118 fn open_and_keep_are_ours() {
119 assert_eq!(parse("/open xqc"), Cmd::Open("xqc".into()));
120 assert_eq!(parse("/open kick:abc"), Cmd::Open("kick:abc".into()));
121 assert!(matches!(parse("/open"), Cmd::Usage(_)));
122 assert_eq!(parse("/keep"), Cmd::Keep);
123 }
124
125 #[test]
126 fn join_without_an_argument_explains_itself() {
127 assert!(matches!(parse("/join"), Cmd::Usage(_)));
128 assert!(matches!(parse("/join "), Cmd::Usage(_)));
129 }
130
131 #[test]
132 fn part_and_quit_have_the_usual_aliases() {
133 assert_eq!(parse("/part"), Cmd::Part(None));
134 assert_eq!(parse("/leave"), Cmd::Part(None));
135 assert_eq!(parse("/part xqc"), Cmd::Part(Some("xqc".into())));
136 assert_eq!(parse("/quit"), Cmd::Quit);
137 assert_eq!(parse("/q"), Cmd::Quit);
138 }
139
140 #[test]
141 fn virtual_tabs_open_by_name() {
142 use crate::TabKind;
143 assert_eq!(parse("/mentions"), Cmd::Tab(TabKind::Mentions));
144 assert_eq!(parse("/feed"), Cmd::Tab(TabKind::Feed));
145 assert_eq!(parse("/Whispers"), Cmd::Tab(TabKind::Whispers));
146 }
147
148 #[test]
149 fn share_and_layout_are_ours() {
150 assert_eq!(parse("/share"), Cmd::Share);
151 assert_eq!(parse("/sharelayout"), Cmd::Share);
152 assert_eq!(parse("/sl"), Cmd::Share);
153 assert_eq!(
154 parse("/layout https://heatsync.org/m/t:a"),
155 Cmd::Layout("https://heatsync.org/m/t:a".into())
156 );
157 assert!(matches!(parse("/layout"), Cmd::Usage(_)));
158 }
159
160 #[test]
161 fn whispers_dms_and_feed_posts_are_ours() {
162 assert_eq!(
163 parse("/w bob hi there"),
164 Cmd::Whisper("bob".into(), "hi there".into())
165 );
166 assert_eq!(
167 parse("/whisper bob hi"),
168 Cmd::Whisper("bob".into(), "hi".into())
169 );
170 assert_eq!(parse("/dm cat yo"), Cmd::Dm("cat".into(), "yo".into()));
171 assert_eq!(parse("/op gm all"), Cmd::Op("gm all".into()));
172 assert_eq!(parse("/re agreed"), Cmd::Re("agreed".into()));
173 for bare in ["/w", "/w bob", "/dm", "/dm cat", "/op", "/re"] {
174 assert!(matches!(parse(bare), Cmd::Usage(_)), "{bare}");
175 }
176 assert_eq!(parse("//w bob hi"), Cmd::Send("/w bob hi".into()));
178 }
179
180 #[test]
181 fn verbs_are_case_insensitive() {
182 assert_eq!(parse("/JOIN hot"), Cmd::Join("hot".into()));
183 assert_eq!(parse("/Quit"), Cmd::Quit);
184 }
185
186 #[test]
189 fn platform_commands_pass_through_untouched() {
190 for line in [
191 "/ban someguy",
192 "/timeout someguy 600 spam",
193 "/me waves",
194 "/vip friend",
195 "/unban someguy",
196 "/mod someguy",
197 "/raid xqc",
198 "/clear",
199 "/slow 30",
200 "/emoteonly",
201 "/followers 10m",
202 "/announce hello chat",
203 ] {
204 assert_eq!(
205 parse(line),
206 Cmd::Send(line.into()),
207 "{line} must reach the platform"
208 );
209 }
210 }
211
212 #[test]
213 fn ordinary_messages_are_untouched() {
214 assert_eq!(parse("hello chat"), Cmd::Send("hello chat".into()));
215 assert_eq!(parse("PagMan"), Cmd::Send("PagMan".into()));
216 assert_eq!(parse("/"), Cmd::Send("/".into()));
218 }
219
220 #[test]
221 fn double_slash_sends_a_literal_slash() {
222 assert_eq!(parse("//join hot"), Cmd::Send("/join hot".into()));
223 assert_eq!(parse("//ban"), Cmd::Send("/ban".into()));
224 }
225}