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 Block(String),
34 Unblock(String),
36 Blocked,
38 Share,
40 Layout(String),
42 Send(String),
44 Usage(&'static str),
46}
47
48pub fn parse(input: &str) -> Cmd {
51 let line = input.trim();
52 if let Some(rest) = line.strip_prefix("//") {
55 return Cmd::Send(format!("/{rest}"));
56 }
57 let Some(body) = line.strip_prefix('/') else {
58 return Cmd::Send(line.to_string());
59 };
60 let mut it = body.splitn(2, char::is_whitespace);
61 let verb = it.next().unwrap_or_default().to_lowercase();
62 let arg = it.next().map(str::trim).filter(|s| !s.is_empty());
63 match verb.as_str() {
64 "join" | "j" => match arg {
65 Some(a) => Cmd::Join(a.to_string()),
66 None => Cmd::Usage(crate::i18n::tr("slash.usage_join")),
67 },
68 "open" => match arg {
69 Some(a) => Cmd::Open(a.to_string()),
70 None => Cmd::Usage(crate::i18n::tr("slash.usage_open")),
71 },
72 "keep" => Cmd::Keep,
73 "part" | "leave" | "close" => Cmd::Part(arg.map(str::to_string)),
74 "quit" | "q" | "exit" => Cmd::Quit,
75 "mentions" | "feed" | "whispers" => match crate::TabKind::parse(&verb) {
76 Some(k) => Cmd::Tab(k),
77 None => Cmd::Send(line.to_string()),
78 },
79 "w" | "whisper" | "dm" => {
80 let mut p = arg.unwrap_or_default().splitn(2, char::is_whitespace);
81 let to = p.next().unwrap_or_default();
82 let text = p.next().map(str::trim).unwrap_or_default();
83 match (verb == "dm", to.is_empty() || text.is_empty()) {
84 (_, true) if verb == "dm" => Cmd::Usage(crate::i18n::tr("slash.usage_dm")),
85 (_, true) => Cmd::Usage(crate::i18n::tr("slash.usage_w")),
86 (true, _) => Cmd::Dm(to.into(), text.into()),
87 _ => Cmd::Whisper(to.into(), text.into()),
88 }
89 }
90 "block" | "blockemote" => match arg {
91 Some(a) => Cmd::Block(a.to_string()),
92 None => Cmd::Usage(crate::i18n::tr("slash.usage_block")),
93 },
94 "unblock" | "unblockemote" => match arg {
95 Some(a) => Cmd::Unblock(a.to_string()),
96 None => Cmd::Usage(crate::i18n::tr("slash.usage_unblock")),
97 },
98 "blocked" | "blocks" => Cmd::Blocked,
99 "sharelayout" | "sl" | "share" => Cmd::Share,
100 "layout" => match arg {
101 Some(a) => Cmd::Layout(a.to_string()),
102 None => Cmd::Usage(crate::i18n::tr("slash.usage_layout")),
103 },
104 "op" => match arg {
105 Some(a) => Cmd::Op(a.to_string()),
106 None => Cmd::Usage(crate::i18n::tr("slash.usage_op")),
107 },
108 "re" => match arg {
109 Some(a) => Cmd::Re(a.to_string()),
110 None => Cmd::Usage(crate::i18n::tr("slash.usage_re")),
111 },
112 _ => Cmd::Send(line.to_string()),
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 #[test]
122 fn join_takes_a_channel() {
123 assert_eq!(parse("/join hot"), Cmd::Join("hot".into()));
124 assert_eq!(parse("/j xqc"), Cmd::Join("xqc".into()));
125 assert_eq!(
126 parse("/join kick:trainwreckstv"),
127 Cmd::Join("kick:trainwreckstv".into())
128 );
129 assert_eq!(parse(" /join hot "), Cmd::Join("hot".into()));
130 }
131
132 #[test]
133 fn emote_block_commands_are_ours() {
134 assert_eq!(parse("/block Kappa"), Cmd::Block("Kappa".into()));
135 assert_eq!(parse("/unblock Kappa "), Cmd::Unblock("Kappa".into()));
136 assert_eq!(parse("/blocked"), Cmd::Blocked);
137 assert!(matches!(parse("/block"), Cmd::Usage(_)));
138 assert!(matches!(parse("/unblock"), Cmd::Usage(_)));
139 }
140
141 #[test]
142 fn open_and_keep_are_ours() {
143 assert_eq!(parse("/open xqc"), Cmd::Open("xqc".into()));
144 assert_eq!(parse("/open kick:abc"), Cmd::Open("kick:abc".into()));
145 assert!(matches!(parse("/open"), Cmd::Usage(_)));
146 assert_eq!(parse("/keep"), Cmd::Keep);
147 }
148
149 #[test]
150 fn join_without_an_argument_explains_itself() {
151 assert!(matches!(parse("/join"), Cmd::Usage(_)));
152 assert!(matches!(parse("/join "), Cmd::Usage(_)));
153 }
154
155 #[test]
156 fn part_and_quit_have_the_usual_aliases() {
157 assert_eq!(parse("/part"), Cmd::Part(None));
158 assert_eq!(parse("/leave"), Cmd::Part(None));
159 assert_eq!(parse("/part xqc"), Cmd::Part(Some("xqc".into())));
160 assert_eq!(parse("/quit"), Cmd::Quit);
161 assert_eq!(parse("/q"), Cmd::Quit);
162 }
163
164 #[test]
165 fn virtual_tabs_open_by_name() {
166 use crate::TabKind;
167 assert_eq!(parse("/mentions"), Cmd::Tab(TabKind::Mentions));
168 assert_eq!(parse("/feed"), Cmd::Tab(TabKind::Feed));
169 assert_eq!(parse("/Whispers"), Cmd::Tab(TabKind::Whispers));
170 }
171
172 #[test]
173 fn share_and_layout_are_ours() {
174 assert_eq!(parse("/share"), Cmd::Share);
175 assert_eq!(parse("/sharelayout"), Cmd::Share);
176 assert_eq!(parse("/sl"), Cmd::Share);
177 assert_eq!(
178 parse("/layout https://heatsync.org/m/t:a"),
179 Cmd::Layout("https://heatsync.org/m/t:a".into())
180 );
181 assert!(matches!(parse("/layout"), Cmd::Usage(_)));
182 }
183
184 #[test]
185 fn whispers_dms_and_feed_posts_are_ours() {
186 assert_eq!(
187 parse("/w bob hi there"),
188 Cmd::Whisper("bob".into(), "hi there".into())
189 );
190 assert_eq!(
191 parse("/whisper bob hi"),
192 Cmd::Whisper("bob".into(), "hi".into())
193 );
194 assert_eq!(parse("/dm cat yo"), Cmd::Dm("cat".into(), "yo".into()));
195 assert_eq!(parse("/op gm all"), Cmd::Op("gm all".into()));
196 assert_eq!(parse("/re agreed"), Cmd::Re("agreed".into()));
197 for bare in ["/w", "/w bob", "/dm", "/dm cat", "/op", "/re"] {
198 assert!(matches!(parse(bare), Cmd::Usage(_)), "{bare}");
199 }
200 assert_eq!(parse("//w bob hi"), Cmd::Send("/w bob hi".into()));
202 }
203
204 #[test]
205 fn verbs_are_case_insensitive() {
206 assert_eq!(parse("/JOIN hot"), Cmd::Join("hot".into()));
207 assert_eq!(parse("/Quit"), Cmd::Quit);
208 }
209
210 #[test]
213 fn platform_commands_pass_through_untouched() {
214 for line in [
215 "/ban someguy",
216 "/timeout someguy 600 spam",
217 "/me waves",
218 "/vip friend",
219 "/unban someguy",
220 "/mod someguy",
221 "/raid xqc",
222 "/clear",
223 "/slow 30",
224 "/emoteonly",
225 "/followers 10m",
226 "/announce hello chat",
227 ] {
228 assert_eq!(
229 parse(line),
230 Cmd::Send(line.into()),
231 "{line} must reach the platform"
232 );
233 }
234 }
235
236 #[test]
237 fn ordinary_messages_are_untouched() {
238 assert_eq!(parse("hello chat"), Cmd::Send("hello chat".into()));
239 assert_eq!(parse("PagMan"), Cmd::Send("PagMan".into()));
240 assert_eq!(parse("/"), Cmd::Send("/".into()));
242 }
243
244 #[test]
245 fn double_slash_sends_a_literal_slash() {
246 assert_eq!(parse("//join hot"), Cmd::Send("/join hot".into()));
247 assert_eq!(parse("//ban"), Cmd::Send("/ban".into()));
248 }
249}