teamtalk 6.0.0

TeamTalk SDK for Rust
Documentation
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Command {
    pub prefix: char,
    pub name: String,
    pub args: Vec<String>,
    pub raw: String,
}

impl Command {
    pub fn arg(&self, index: usize) -> Option<&str> {
        self.args.get(index).map(String::as_str)
    }

    pub(crate) fn tokens(&self) -> Vec<&str> {
        let mut tokens = Vec::with_capacity(self.args.len() + 1);
        tokens.push(self.name.as_str());
        tokens.extend(self.args.iter().map(String::as_str));
        tokens
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandArgPattern {
    name: String,
    required: bool,
    variadic: bool,
}

impl CommandArgPattern {
    pub fn name(&self) -> &str {
        &self.name
    }

    pub fn required(&self) -> bool {
        self.required
    }

    pub fn variadic(&self) -> bool {
        self.variadic
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandPattern {
    command: String,
    command_parts: Vec<String>,
    args: Vec<CommandArgPattern>,
}

impl CommandPattern {
    pub fn parse(input: impl AsRef<str>) -> Result<Self, CommandPatternError> {
        let trimmed = input.as_ref().trim();
        if trimmed.is_empty() {
            return Err(CommandPatternError::EmptyPattern);
        }

        let mut command_parts = Vec::new();
        let mut args = Vec::new();
        let mut optional_seen = false;
        let mut variadic_seen = false;

        for token in trimmed.split_whitespace() {
            if variadic_seen {
                return Err(CommandPatternError::VariadicMustBeLast {
                    token: token.to_owned(),
                });
            }

            match parse_arg_token(token)? {
                Some(arg) => {
                    if optional_seen && arg.required {
                        return Err(CommandPatternError::RequiredAfterOptional {
                            token: token.to_owned(),
                        });
                    }

                    optional_seen |= !arg.required;
                    variadic_seen = arg.variadic;
                    args.push(arg);
                }
                None => {
                    if !args.is_empty() {
                        return Err(CommandPatternError::CommandTokenAfterArgs {
                            token: token.to_owned(),
                        });
                    }
                    command_parts.push(token.to_ascii_lowercase());
                }
            }
        }

        if command_parts.is_empty() {
            return Err(CommandPatternError::MissingCommandName);
        }

        Ok(Self {
            command: command_parts.join(" "),
            command_parts,
            args,
        })
    }

    pub fn command(&self) -> &str {
        &self.command
    }

    pub fn command_parts(&self) -> &[String] {
        &self.command_parts
    }

    pub fn args(&self) -> &[CommandArgPattern] {
        &self.args
    }

    pub fn min_args(&self) -> usize {
        self.args.iter().filter(|arg| arg.required).count()
    }

    pub fn max_args(&self) -> Option<usize> {
        if self.args.iter().any(CommandArgPattern::variadic) {
            None
        } else {
            Some(self.args.len())
        }
    }

    pub fn accepts(&self, args: &[String]) -> bool {
        if args.len() < self.min_args() {
            return false;
        }
        if let Some(max_args) = self.max_args() {
            args.len() <= max_args
        } else {
            true
        }
    }

    pub fn usage_with_prefix(&self, prefix: char) -> String {
        let mut usage = self.usage();
        usage.insert(0, prefix);
        usage
    }

    pub fn usage(&self) -> String {
        let mut usage = self.command.clone();
        for arg in &self.args {
            usage.push(' ');
            if arg.required {
                usage.push('<');
            } else {
                usage.push('[');
            }
            usage.push_str(&arg.name);
            if arg.variadic {
                usage.push_str("...");
            }
            if arg.required {
                usage.push('>');
            } else {
                usage.push(']');
            }
        }
        usage
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommandPatternError {
    EmptyPattern,
    MissingCommandName,
    InvalidArgumentToken { token: String },
    RequiredAfterOptional { token: String },
    VariadicMustBeLast { token: String },
    CommandTokenAfterArgs { token: String },
}

impl std::fmt::Display for CommandPatternError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::EmptyPattern => f.write_str("command pattern is empty"),
            Self::MissingCommandName => f.write_str("command pattern is missing command name"),
            Self::InvalidArgumentToken { token } => {
                write!(f, "invalid command argument token: {token}")
            }
            Self::RequiredAfterOptional { token } => {
                write!(
                    f,
                    "required argument appears after optional argument: {token}"
                )
            }
            Self::VariadicMustBeLast { token } => {
                write!(
                    f,
                    "variadic argument must be the final token, found: {token}"
                )
            }
            Self::CommandTokenAfterArgs { token } => {
                write!(f, "command token appears after arguments: {token}")
            }
        }
    }
}

impl std::error::Error for CommandPatternError {}

fn parse_arg_token(token: &str) -> Result<Option<CommandArgPattern>, CommandPatternError> {
    let (required, inner) =
        if let Some(inner) = token.strip_prefix('<').and_then(|s| s.strip_suffix('>')) {
            (true, inner)
        } else if let Some(inner) = token.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
            (false, inner)
        } else {
            return Ok(None);
        };

    if inner.is_empty() {
        return Err(CommandPatternError::InvalidArgumentToken {
            token: token.to_owned(),
        });
    }

    let (name, variadic) = if let Some(name) = inner.strip_suffix("...") {
        (name, true)
    } else {
        (inner, false)
    };

    if name.is_empty()
        || !name
            .chars()
            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
    {
        return Err(CommandPatternError::InvalidArgumentToken {
            token: token.to_owned(),
        });
    }

    Ok(Some(CommandArgPattern {
        name: name.to_owned(),
        required,
        variadic,
    }))
}

pub fn parse_command(text: &str, prefixes: &[char]) -> Option<Command> {
    let trimmed = text.trim();
    let mut chars = trimmed.chars();
    let prefix = chars.next()?;
    if !prefixes.contains(&prefix) {
        return None;
    }

    let body = chars.as_str().trim();
    if body.is_empty() {
        return None;
    }

    let mut parts = body.split_whitespace();
    let name = parts.next()?.to_ascii_lowercase();
    if name.is_empty() {
        return None;
    }

    let args = parts.map(ToOwned::to_owned).collect();
    Some(Command {
        prefix,
        name,
        args,
        raw: body.to_owned(),
    })
}

#[cfg(test)]
mod tests {
    use super::{CommandPattern, CommandPatternError};

    #[test]
    fn command_pattern_parses_command_and_args() {
        let pattern = CommandPattern::parse("ban <user> [reason...]").expect("pattern");
        assert_eq!(pattern.command(), "ban");
        assert_eq!(pattern.min_args(), 1);
        assert_eq!(pattern.max_args(), None);
        assert_eq!(pattern.usage_with_prefix('/'), "/ban <user> [reason...]");
    }

    #[test]
    fn command_pattern_rejects_required_after_optional() {
        let err = CommandPattern::parse("ban [reason] <user>").expect_err("must fail");
        assert!(matches!(
            err,
            CommandPatternError::RequiredAfterOptional { .. }
        ));
    }

    #[test]
    fn command_pattern_rejects_command_after_args() {
        let err = CommandPattern::parse("ban <user> now").expect_err("must fail");
        assert!(matches!(
            err,
            CommandPatternError::CommandTokenAfterArgs { .. }
        ));
    }

    #[test]
    fn command_pattern_accepts_argument_bounds() {
        let pattern = CommandPattern::parse("ban <user> [reason]").expect("pattern");
        assert!(!pattern.accepts(&[]));
        assert!(pattern.accepts(&[String::from("alice")]));
        assert!(pattern.accepts(&[String::from("alice"), String::from("spam")]));
        assert!(!pattern.accepts(&[
            String::from("alice"),
            String::from("spam"),
            String::from("extra"),
        ]));
    }
}