pub struct CommandInfo {
pub name: &'static str,
pub alias: &'static str,
pub args: &'static str,
pub description: &'static str,
}
pub const COMMANDS: &[CommandInfo] = &[
CommandInfo { name: "/join", alias: "/j", args: "<name>", description: "Switch to a conversation" },
CommandInfo { name: "/part", alias: "/p", args: "", description: "Leave current conversation" },
CommandInfo { name: "/sidebar", alias: "/sb", args: "", description: "Toggle sidebar" },
CommandInfo { name: "/bell", alias: "", args: "[type]", description: "Toggle notifications (direct/group)" },
CommandInfo { name: "/mute", alias: "", args: "", description: "Mute/unmute current chat" },
CommandInfo { name: "/block", alias: "", args: "", description: "Block current contact/group" },
CommandInfo { name: "/unblock", alias: "", args: "", description: "Unblock current contact/group" },
CommandInfo { name: "/attach", alias: "/a", args: "", description: "Attach a file" },
CommandInfo { name: "/paste", alias: "/pa", args: "", description: "Paste from clipboard (text, image, or file)" },
CommandInfo { name: "/search", alias: "/s", args: "<query>", description: "Search messages" },
CommandInfo { name: "/contacts", alias: "/c", args: "", description: "Browse contacts" },
CommandInfo { name: "/settings", alias: "", args: "", description: "Open settings" },
CommandInfo { name: "/disappearing", alias: "/dm", args: "<duration>", description: "Set disappearing timer (off/30s/5m/1h/1d/1w)" },
CommandInfo { name: "/group", alias: "/g", args: "", description: "Group management" },
CommandInfo { name: "/theme", alias: "/t", args: "", description: "Change color theme" },
CommandInfo { name: "/poll", alias: "", args: "\"question\" \"opt1\" \"opt2\" [--single]", description: "Create a poll" },
CommandInfo { name: "/verify", alias: "/v", args: "", description: "Verify contact identity" },
CommandInfo { name: "/profile", alias: "", args: "", description: "Edit your Signal profile" },
CommandInfo { name: "/about", alias: "", args: "", description: "About siggy" },
CommandInfo { name: "/keybindings", alias: "/kb", args: "", description: "Configure keybindings" },
CommandInfo { name: "/help", alias: "/h", args: "", description: "Show help" },
CommandInfo { name: "/quit", alias: "/q", args: "", description: "Exit siggy" },
];
#[derive(Debug, PartialEq)]
pub enum InputAction {
SendText(String),
Join(String),
Part,
Quit,
ToggleSidebar,
ToggleBell(Option<String>),
ToggleMute,
Block,
Unblock,
Help,
Settings,
Contacts,
Attach,
Paste,
Search(String),
SetDisappearing(String),
Group,
Theme,
Poll { question: String, options: Vec<String>, allow_multiple: bool },
Verify,
Profile,
About,
Keybindings,
Unknown(String),
}
pub fn parse_input(input: &str) -> InputAction {
let trimmed = input.trim();
if trimmed.is_empty() {
return InputAction::SendText(String::new());
}
if !trimmed.starts_with('/') {
if trimmed == ":q" || trimmed == ":quit" {
return InputAction::Quit;
}
return InputAction::SendText(trimmed.to_string());
}
let mut parts = trimmed.splitn(2, ' ');
let cmd = parts.next().unwrap_or("");
let arg = parts.next().unwrap_or("").trim().to_string();
match cmd {
"/join" | "/j" => {
if arg.is_empty() {
InputAction::Unknown("/join requires a contact or group name".to_string())
} else {
InputAction::Join(arg)
}
}
"/part" | "/p" => InputAction::Part,
"/quit" | "/q" => InputAction::Quit,
"/sidebar" | "/sb" => InputAction::ToggleSidebar,
"/bell" | "/notify" => {
if arg.is_empty() {
InputAction::ToggleBell(None)
} else {
InputAction::ToggleBell(Some(arg))
}
}
"/mute" => InputAction::ToggleMute,
"/block" => InputAction::Block,
"/unblock" => InputAction::Unblock,
"/attach" | "/a" => InputAction::Attach,
"/paste" | "/pa" => InputAction::Paste, "/search" | "/s" => {
if arg.is_empty() {
InputAction::Unknown("/search requires a query".to_string())
} else {
InputAction::Search(arg)
}
}
"/contacts" | "/c" => InputAction::Contacts,
"/settings" => InputAction::Settings,
"/disappearing" | "/dm" => {
if arg.is_empty() {
InputAction::Unknown("/disappearing requires a duration (e.g. off, 30s, 5m, 1h, 1d, 1w)".to_string())
} else {
InputAction::SetDisappearing(arg)
}
}
"/group" | "/g" => InputAction::Group,
"/theme" | "/t" => InputAction::Theme,
"/poll" => {
match parse_poll_args(&arg) {
Some((question, options, allow_multiple)) if options.len() >= 2 => {
InputAction::Poll { question, options, allow_multiple }
}
_ => InputAction::Unknown("Usage: /poll \"question\" \"option1\" \"option2\" [--single]".into()),
}
}
"/verify" | "/v" => InputAction::Verify,
"/profile" => InputAction::Profile,
"/about" => InputAction::About,
"/keybindings" | "/kb" => InputAction::Keybindings,
"/help" | "/h" => InputAction::Help,
_ => InputAction::Unknown(format!("Unknown command: {cmd}")),
}
}
fn parse_poll_args(input: &str) -> Option<(String, Vec<String>, bool)> {
let mut parts: Vec<String> = Vec::new();
let mut allow_multiple = true;
let mut chars = input.chars().peekable();
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
chars.next();
continue;
}
if c == '"' {
chars.next(); let mut s = String::new();
loop {
match chars.next() {
Some('\\') => {
if let Some(escaped) = chars.next() {
s.push(escaped);
}
}
Some('"') => break,
Some(ch) => s.push(ch),
None => break,
}
}
parts.push(s);
} else {
let mut s = String::new();
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
break;
}
s.push(c);
chars.next();
}
if s == "--single" {
allow_multiple = false;
} else {
parts.push(s);
}
}
}
if parts.len() < 3 {
return None;
}
let question = parts.remove(0);
Some((question, parts, allow_multiple))
}
pub fn format_compact_duration(seconds: i64) -> String {
if seconds < 60 {
format!("{seconds}s")
} else if seconds < 3600 {
format!("{}m", seconds / 60)
} else if seconds < 86400 {
format!("{}h", seconds / 3600)
} else if seconds < 604800 {
format!("{}d", seconds / 86400)
} else {
format!("{}w", seconds / 604800)
}
}
pub fn parse_duration_to_seconds(s: &str) -> Result<i64, String> {
let s = s.trim().to_lowercase();
if s == "off" || s == "0" {
return Ok(0);
}
let (num_str, multiplier) = if let Some(n) = s.strip_suffix('s') {
(n, 1i64)
} else if let Some(n) = s.strip_suffix('m') {
(n, 60)
} else if let Some(n) = s.strip_suffix('h') {
(n, 3600)
} else if let Some(n) = s.strip_suffix('d') {
(n, 86400)
} else if let Some(n) = s.strip_suffix('w') {
(n, 604800)
} else {
return Err(format!("Invalid duration: {s}. Use off/30s/5m/1h/1d/1w/4w"));
};
match num_str.parse::<i64>() {
Ok(n) if n > 0 => Ok(n * multiplier),
_ => Err(format!("Invalid duration: {s}. Use off/30s/5m/1h/1d/1w/4w")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case("/part", InputAction::Part)]
#[case("/p", InputAction::Part)]
#[case("/quit", InputAction::Quit)]
#[case("/q", InputAction::Quit)]
#[case("/sidebar", InputAction::ToggleSidebar)]
#[case("/sb", InputAction::ToggleSidebar)]
#[case("/mute", InputAction::ToggleMute)]
#[case("/settings", InputAction::Settings)]
#[case("/attach", InputAction::Attach)]
#[case("/a", InputAction::Attach)]
#[case("/paste", InputAction::Paste)]
#[case("/pa", InputAction::Paste)]
#[case("/contacts", InputAction::Contacts)]
#[case("/c", InputAction::Contacts)]
#[case("/help", InputAction::Help)]
#[case("/h", InputAction::Help)]
#[case("/block", InputAction::Block)]
#[case("/unblock", InputAction::Unblock)]
#[case("/group", InputAction::Group)]
#[case("/g", InputAction::Group)]
#[case("/verify", InputAction::Verify)]
#[case("/v", InputAction::Verify)]
#[case("/profile", InputAction::Profile)]
#[case("/about", InputAction::About)]
#[case("/keybindings", InputAction::Keybindings)]
#[case("/kb", InputAction::Keybindings)]
#[case("/bell", InputAction::ToggleBell(None))]
fn command_returns_expected_action(#[case] input: &str, #[case] expected: InputAction) {
assert_eq!(parse_input(input), expected);
}
#[rstest]
#[case("/join Alice", InputAction::Join("Alice".to_string()))]
#[case("/j +1234567890", InputAction::Join("+1234567890".to_string()))]
#[case("/search hello", InputAction::Search("hello".to_string()))]
#[case("/s world", InputAction::Search("world".to_string()))]
#[case("/disappearing 30s", InputAction::SetDisappearing("30s".to_string()))]
#[case("/dm off", InputAction::SetDisappearing("off".to_string()))]
#[case("/bell direct", InputAction::ToggleBell(Some("direct".to_string())))]
#[case("/notify group", InputAction::ToggleBell(Some("group".to_string())))]
fn command_with_argument(#[case] input: &str, #[case] expected: InputAction) {
assert_eq!(parse_input(input), expected);
}
#[rstest]
#[case("/join")]
#[case("/search")]
#[case("/disappearing")]
fn command_without_required_arg_returns_unknown(#[case] input: &str) {
let InputAction::Unknown(s) = parse_input(input) else {
panic!("expected Unknown for {input}");
};
assert!(s.contains("requires"), "error for {input} should mention 'requires': {s}");
}
#[rstest]
#[case("hello world", "hello world")]
#[case("", "")]
#[case(" ", "")]
#[case(" hello ", "hello")]
fn send_text_variants(#[case] input: &str, #[case] expected: &str) {
let InputAction::SendText(s) = parse_input(input) else {
panic!("expected SendText for {input:?}");
};
assert_eq!(s, expected);
}
#[test]
fn unknown_command() {
let InputAction::Unknown(s) = parse_input("/foo") else { panic!("expected Unknown") };
assert!(s.contains("/foo"));
}
#[rstest]
#[case("off", 0)]
#[case("0", 0)]
#[case("30s", 30)]
#[case("5m", 300)]
#[case("1h", 3600)]
#[case("8h", 28800)]
#[case("1d", 86400)]
#[case("1w", 604800)]
#[case("4w", 2419200)]
fn duration_parser_valid(#[case] input: &str, #[case] expected: i64) {
assert_eq!(parse_duration_to_seconds(input).unwrap(), expected);
}
#[rstest]
#[case("abc")]
#[case("")]
#[case("0s")]
#[case("-1h")]
fn duration_parser_invalid(#[case] input: &str) {
assert!(parse_duration_to_seconds(input).is_err(), "expected error for {input:?}");
}
#[test]
fn poll_command_basic() {
let result = parse_input(r#"/poll "What for lunch?" "Pizza" "Sushi""#);
match result {
InputAction::Poll { question, options, allow_multiple } => {
assert_eq!(question, "What for lunch?");
assert_eq!(options, vec!["Pizza", "Sushi"]);
assert!(allow_multiple);
}
other => panic!("expected Poll, got {other:?}"),
}
}
#[test]
fn poll_command_single_select() {
let result = parse_input(r#"/poll "Q" "A" "B" --single"#);
match result {
InputAction::Poll { allow_multiple, options, .. } => {
assert!(!allow_multiple);
assert_eq!(options, vec!["A", "B"]);
}
other => panic!("expected Poll, got {other:?}"),
}
}
#[test]
fn poll_command_too_few_options() {
let result = parse_input(r#"/poll "Q" "A""#);
assert!(matches!(result, InputAction::Unknown(_)));
}
#[test]
fn poll_command_no_args() {
let result = parse_input("/poll");
assert!(matches!(result, InputAction::Unknown(_)));
}
}