#[derive(Debug, PartialEq)]
pub enum Cmd {
Join(String),
Open(String),
Keep,
Part(Option<String>),
Quit,
Tab(crate::TabKind),
Whisper(String, String),
Dm(String, String),
Op(String),
Re(String),
Share,
Layout(String),
Send(String),
Usage(&'static str),
}
pub fn parse(input: &str) -> Cmd {
let line = input.trim();
if let Some(rest) = line.strip_prefix("//") {
return Cmd::Send(format!("/{rest}"));
}
let Some(body) = line.strip_prefix('/') else {
return Cmd::Send(line.to_string());
};
let mut it = body.splitn(2, char::is_whitespace);
let verb = it.next().unwrap_or_default().to_lowercase();
let arg = it.next().map(str::trim).filter(|s| !s.is_empty());
match verb.as_str() {
"join" | "j" => match arg {
Some(a) => Cmd::Join(a.to_string()),
None => Cmd::Usage("usage: /join <channel> (or kick:<channel>)"),
},
"open" => match arg {
Some(a) => Cmd::Open(a.to_string()),
None => Cmd::Usage("usage: /open <channel> (or kick:<channel>)"),
},
"keep" => Cmd::Keep,
"part" | "leave" | "close" => Cmd::Part(arg.map(str::to_string)),
"quit" | "q" | "exit" => Cmd::Quit,
"mentions" | "feed" | "whispers" => match crate::TabKind::parse(&verb) {
Some(k) => Cmd::Tab(k),
None => Cmd::Send(line.to_string()),
},
"w" | "whisper" | "dm" => {
let mut p = arg.unwrap_or_default().splitn(2, char::is_whitespace);
let to = p.next().unwrap_or_default();
let text = p.next().map(str::trim).unwrap_or_default();
match (verb == "dm", to.is_empty() || text.is_empty()) {
(_, true) if verb == "dm" => Cmd::Usage("usage: /dm <user> <message>"),
(_, true) => Cmd::Usage("usage: /w <user> <message>"),
(true, _) => Cmd::Dm(to.into(), text.into()),
_ => Cmd::Whisper(to.into(), text.into()),
}
}
"sharelayout" | "sl" | "share" => Cmd::Share,
"layout" => match arg {
Some(a) => Cmd::Layout(a.to_string()),
None => Cmd::Usage("usage: /layout <heatsync.org/m/ link>"),
},
"op" => match arg {
Some(a) => Cmd::Op(a.to_string()),
None => Cmd::Usage("usage: /op <text> (posts to the heatsync feed)"),
},
"re" => match arg {
Some(a) => Cmd::Re(a.to_string()),
None => Cmd::Usage("usage: /re <text> (replies to your last OP)"),
},
_ => Cmd::Send(line.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn join_takes_a_channel() {
assert_eq!(parse("/join hot"), Cmd::Join("hot".into()));
assert_eq!(parse("/j xqc"), Cmd::Join("xqc".into()));
assert_eq!(
parse("/join kick:trainwreckstv"),
Cmd::Join("kick:trainwreckstv".into())
);
assert_eq!(parse(" /join hot "), Cmd::Join("hot".into()));
}
#[test]
fn open_and_keep_are_ours() {
assert_eq!(parse("/open xqc"), Cmd::Open("xqc".into()));
assert_eq!(parse("/open kick:abc"), Cmd::Open("kick:abc".into()));
assert!(matches!(parse("/open"), Cmd::Usage(_)));
assert_eq!(parse("/keep"), Cmd::Keep);
}
#[test]
fn join_without_an_argument_explains_itself() {
assert!(matches!(parse("/join"), Cmd::Usage(_)));
assert!(matches!(parse("/join "), Cmd::Usage(_)));
}
#[test]
fn part_and_quit_have_the_usual_aliases() {
assert_eq!(parse("/part"), Cmd::Part(None));
assert_eq!(parse("/leave"), Cmd::Part(None));
assert_eq!(parse("/part xqc"), Cmd::Part(Some("xqc".into())));
assert_eq!(parse("/quit"), Cmd::Quit);
assert_eq!(parse("/q"), Cmd::Quit);
}
#[test]
fn virtual_tabs_open_by_name() {
use crate::TabKind;
assert_eq!(parse("/mentions"), Cmd::Tab(TabKind::Mentions));
assert_eq!(parse("/feed"), Cmd::Tab(TabKind::Feed));
assert_eq!(parse("/Whispers"), Cmd::Tab(TabKind::Whispers));
}
#[test]
fn share_and_layout_are_ours() {
assert_eq!(parse("/share"), Cmd::Share);
assert_eq!(parse("/sharelayout"), Cmd::Share);
assert_eq!(parse("/sl"), Cmd::Share);
assert_eq!(
parse("/layout https://heatsync.org/m/t:a"),
Cmd::Layout("https://heatsync.org/m/t:a".into())
);
assert!(matches!(parse("/layout"), Cmd::Usage(_)));
}
#[test]
fn whispers_dms_and_feed_posts_are_ours() {
assert_eq!(
parse("/w bob hi there"),
Cmd::Whisper("bob".into(), "hi there".into())
);
assert_eq!(
parse("/whisper bob hi"),
Cmd::Whisper("bob".into(), "hi".into())
);
assert_eq!(parse("/dm cat yo"), Cmd::Dm("cat".into(), "yo".into()));
assert_eq!(parse("/op gm all"), Cmd::Op("gm all".into()));
assert_eq!(parse("/re agreed"), Cmd::Re("agreed".into()));
for bare in ["/w", "/w bob", "/dm", "/dm cat", "/op", "/re"] {
assert!(matches!(parse(bare), Cmd::Usage(_)), "{bare}");
}
assert_eq!(parse("//w bob hi"), Cmd::Send("/w bob hi".into()));
}
#[test]
fn verbs_are_case_insensitive() {
assert_eq!(parse("/JOIN hot"), Cmd::Join("hot".into()));
assert_eq!(parse("/Quit"), Cmd::Quit);
}
#[test]
fn platform_commands_pass_through_untouched() {
for line in [
"/ban someguy",
"/timeout someguy 600 spam",
"/me waves",
"/vip friend",
"/unban someguy",
"/mod someguy",
"/raid xqc",
"/clear",
"/slow 30",
"/emoteonly",
"/followers 10m",
"/announce hello chat",
] {
assert_eq!(
parse(line),
Cmd::Send(line.into()),
"{line} must reach the platform"
);
}
}
#[test]
fn ordinary_messages_are_untouched() {
assert_eq!(parse("hello chat"), Cmd::Send("hello chat".into()));
assert_eq!(parse("PagMan"), Cmd::Send("PagMan".into()));
assert_eq!(parse("/"), Cmd::Send("/".into()));
}
#[test]
fn double_slash_sends_a_literal_slash() {
assert_eq!(parse("//join hot"), Cmd::Send("/join hot".into()));
assert_eq!(parse("//ban"), Cmd::Send("/ban".into()));
}
}