Add a module (plugin) system for bot features
Introduce a `Module` trait (src/bot/module.rs): a feature declares the commands it provides and turns each invocation into a `Vec<Action>`, without touching the connection. Modules are pure — context in, actions out — so they are unit-testable in isolation and avoid borrowing the `Bot` that owns them. Bot::new builds the module set and a command -> module route table; the `commands` dispatcher routes to the owning module and executes its actions, and `help` is auto-generated from every module's CommandSpec metadata. The existing ping/echo/hello move into a `builtins` module, and a stateful `dice` module (`roll [NdM]`, with its own dependency-free xorshift PRNG) is added as a worked example. Each network gets its own module set, so stateful modules keep independent per-network state with no locking. Adding a feature is now a new file in bot/modules/ plus one line in modules::all(). Tests: new tests/modules.rs drives modules through the trait with no network; end-to-end dispatch and auto-help verified against a fake server. No new deps.
This commit is contained in:
parent
a15fb4f9a9
commit
a5e58493d8
8 changed files with 426 additions and 21 deletions
41
README.md
41
README.md
|
|
@ -10,7 +10,7 @@ rustbot is a **library crate** (`src/lib.rs`) with a thin binary on top:
|
|||
| Path | Layer | Responsibility |
|
||||
|-----------------|----------------|-------------------------------------------------------------------|
|
||||
| `src/irc/` | protocol + I/O | `message` (parse IRCv3 wire format), `connection` (TCP/TLS transport), `encoding` (base64, server-time) |
|
||||
| `src/bot/` | behavior | `mod` (state + event loop), `caps` (IRCv3 cap negotiation + SASL), `commands` (message routing + command table) |
|
||||
| `src/bot/` | behavior | `mod` (state + loop), `caps` (IRCv3 + SASL), `commands` (routing + help), `module` (plugin trait), `modules/` (feature modules) |
|
||||
| `src/config.rs` | configuration | Layered `Config`; multi-network `[section]` parsing |
|
||||
| `src/lib.rs` | crate root | Ties the modules into the reusable `rustbot` library |
|
||||
| `src/main.rs` | binary | Thin per-network connect/reconnect supervisor |
|
||||
|
|
@ -18,7 +18,44 @@ rustbot is a **library crate** (`src/lib.rs`) with a thin binary on top:
|
|||
|
||||
The layers are deliberately separable: `irc` knows nothing about the bot, `bot`
|
||||
knows nothing about how the process is launched, and `main` only supervises.
|
||||
Adding a command is usually a one-file change in `src/bot/commands.rs`.
|
||||
Adding a feature is a new **module** — see [Modules](#modules).
|
||||
|
||||
## Modules
|
||||
|
||||
Features are **modules**: self-contained units behind the `Module` trait
|
||||
(`src/bot/module.rs`). A module declares the commands it provides and turns each
|
||||
invocation into `Action`s — it never touches the socket, so it stays pure and
|
||||
unit-testable:
|
||||
|
||||
```rust
|
||||
pub trait Module {
|
||||
fn name(&self) -> &'static str;
|
||||
fn commands(&self) -> &'static [CommandSpec];
|
||||
fn on_command(&mut self, cmd: &Command) -> Vec<Action>;
|
||||
}
|
||||
```
|
||||
|
||||
At startup the bot builds a `command → module` route table and auto-generates
|
||||
`help` from every module's `CommandSpec`s. Each network gets its own fresh set
|
||||
of modules, so stateful ones (like the dice roller's PRNG) keep per-network
|
||||
state with no locking.
|
||||
|
||||
**To add a module**, create `src/bot/modules/<name>.rs` implementing `Module`,
|
||||
then add one line to `all()` in `src/bot/modules/mod.rs`:
|
||||
|
||||
```rust
|
||||
pub fn all() -> Vec<Box<dyn Module>> {
|
||||
vec![
|
||||
Box::new(builtins::Builtins),
|
||||
Box::new(dice::Dice::new()),
|
||||
Box::new(myfeature::MyFeature::new()), // <- your module
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Shipped modules: **`builtins`** (`ping`, `echo`, `hello`) and **`dice`**
|
||||
(`roll [NdM]`). Module tests live in `tests/modules.rs` — construct a module and
|
||||
assert on the `Action`s it returns, no network required.
|
||||
|
||||
## Build & run
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
use std::io;
|
||||
|
||||
use super::module::{Action, Command};
|
||||
use super::Bot;
|
||||
use crate::irc::{now_unix, parse_server_time, Message};
|
||||
|
||||
|
|
@ -83,7 +84,8 @@ impl Bot {
|
|||
self.handle_command(&reply_to, sender, &command, &args)
|
||||
}
|
||||
|
||||
/// The command table. Add new bot commands here.
|
||||
/// Route a command to the module that declares it and execute the actions
|
||||
/// it returns. `help` is handled here, aggregating every module's commands.
|
||||
fn handle_command(
|
||||
&mut self,
|
||||
reply_to: &str,
|
||||
|
|
@ -91,22 +93,54 @@ impl Bot {
|
|||
command: &str,
|
||||
args: &[&str],
|
||||
) -> io::Result<()> {
|
||||
match command {
|
||||
"ping" => self.conn.privmsg(reply_to, "pong")?,
|
||||
"echo" => self.conn.privmsg(reply_to, &args.join(" "))?,
|
||||
"hello" => {
|
||||
let text = format!("hello, {sender}!");
|
||||
self.conn.privmsg(reply_to, &text)?;
|
||||
if command == "help" {
|
||||
let text = self.help_text(args.first().copied());
|
||||
return self.conn.privmsg(reply_to, &text);
|
||||
}
|
||||
"help" => {
|
||||
let p = &self.config.command_prefix;
|
||||
let text = format!("commands: {p}ping, {p}echo <text>, {p}hello, {p}help");
|
||||
self.conn.privmsg(reply_to, &text)?;
|
||||
|
||||
let Some(&idx) = self.routes.get(command) else {
|
||||
return Ok(()); // Unknown command: stay silent rather than spam channels.
|
||||
};
|
||||
|
||||
let cmd = Command {
|
||||
sender,
|
||||
reply_to,
|
||||
name: command,
|
||||
args,
|
||||
prefix: &self.config.command_prefix,
|
||||
network: &self.config.name,
|
||||
};
|
||||
let actions = self.modules[idx].on_command(&cmd);
|
||||
for action in actions {
|
||||
match action {
|
||||
Action::Reply(text) => self.conn.privmsg(reply_to, &text)?,
|
||||
Action::Raw(line) => self.conn.send_raw(&line)?,
|
||||
}
|
||||
_ => {} // Unknown command: stay silent rather than spam channels.
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the `help` reply: with no argument, list every module's commands;
|
||||
/// with a command name, show that command's description.
|
||||
fn help_text(&self, arg: Option<&str>) -> String {
|
||||
let p = &self.config.command_prefix;
|
||||
if let Some(name) = arg {
|
||||
for m in &self.modules {
|
||||
if let Some(spec) = m.commands().iter().find(|s| s.name == name) {
|
||||
return format!("{p}{}: {}", spec.usage, spec.about);
|
||||
}
|
||||
}
|
||||
return format!("no such command '{name}'; try {p}help");
|
||||
}
|
||||
let mut items: Vec<String> = Vec::new();
|
||||
for m in &self.modules {
|
||||
for spec in m.commands() {
|
||||
items.push(format!("{p}{}", spec.usage));
|
||||
}
|
||||
}
|
||||
items.push(format!("{p}help"));
|
||||
format!("commands: {}", items.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
/// Channels start with one of the standard channel-type prefixes.
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
//! Bot behavior: the [`Bot`] state, registration, and the event loop.
|
||||
//!
|
||||
//! This layer is transport-agnostic in spirit: it drives an [`crate::irc::Connection`]
|
||||
//! and turns incoming [`Message`]s into actions. The behaviour is split across
|
||||
//! sibling modules — capability negotiation and SASL live in [`caps`], and
|
||||
//! message routing plus the command table live in [`commands`]. To add a
|
||||
//! feature you usually only touch [`commands`].
|
||||
//! Behaviour is split across sibling modules: capability negotiation and SASL
|
||||
//! live in [`caps`], and message routing in [`commands`]. Features live behind
|
||||
//! the [`Module`] plugin trait ([`module`]) and are registered in [`modules`],
|
||||
//! so adding a command is a new file in `bot/modules/` plus one line in
|
||||
//! [`modules::all`] — no changes to the core dispatch.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io;
|
||||
|
|
@ -14,8 +14,13 @@ use crate::irc::{Connection, Message};
|
|||
|
||||
mod caps;
|
||||
mod commands;
|
||||
pub mod module;
|
||||
pub mod modules;
|
||||
|
||||
/// The bot: owns its configuration and connection, and runs one session.
|
||||
pub use module::{Action, Command, CommandSpec, Module};
|
||||
|
||||
/// The bot: owns its configuration, connection, and feature modules, and runs
|
||||
/// one session.
|
||||
pub struct Bot {
|
||||
config: Config,
|
||||
conn: Connection,
|
||||
|
|
@ -31,11 +36,31 @@ pub struct Bot {
|
|||
batches: HashMap<String, String>,
|
||||
/// Whether the SASL exchange has begun this session.
|
||||
sasl_started: bool,
|
||||
/// Registered feature modules (a fresh set per network/session).
|
||||
modules: Vec<Box<dyn Module>>,
|
||||
/// Command word -> index into `modules`, built from each module's specs.
|
||||
routes: HashMap<&'static str, usize>,
|
||||
}
|
||||
|
||||
impl Bot {
|
||||
pub fn new(config: Config, conn: Connection) -> Bot {
|
||||
let current_nick = config.nick.clone();
|
||||
|
||||
// Build the module set and the command -> module route table.
|
||||
let mods = modules::all();
|
||||
let mut routes: HashMap<&'static str, usize> = HashMap::new();
|
||||
for (i, m) in mods.iter().enumerate() {
|
||||
for spec in m.commands() {
|
||||
if routes.insert(spec.name, i).is_some() {
|
||||
crate::log(&format!(
|
||||
"module '{}' overrides command '{}'",
|
||||
m.name(),
|
||||
spec.name
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Bot {
|
||||
config,
|
||||
conn,
|
||||
|
|
@ -45,6 +70,8 @@ impl Bot {
|
|||
enabled_caps: HashSet::new(),
|
||||
batches: HashMap::new(),
|
||||
sasl_started: false,
|
||||
modules: mods,
|
||||
routes,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
71
src/bot/module.rs
Normal file
71
src/bot/module.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
//! The module (plugin) framework: the [`Module`] trait plus the [`Command`]
|
||||
//! context it receives and the [`Action`]s it returns.
|
||||
//!
|
||||
//! Modules are pure in spirit: given a [`Command`], a module returns the
|
||||
//! [`Action`]s to perform without ever touching the connection. That keeps them
|
||||
//! trivially unit-testable and sidesteps borrow conflicts with the [`super::Bot`]
|
||||
//! that owns them (a module never needs `&mut` access to the bot).
|
||||
|
||||
/// Metadata describing one command a module provides. Used to route commands to
|
||||
/// modules and to auto-generate `help`.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct CommandSpec {
|
||||
/// The command word, lowercase, without the prefix (e.g. `"roll"`).
|
||||
pub name: &'static str,
|
||||
/// Usage shown in help, without the prefix (e.g. `"roll [NdM]"`).
|
||||
pub usage: &'static str,
|
||||
/// One-line description shown by `help <command>`.
|
||||
pub about: &'static str,
|
||||
}
|
||||
|
||||
/// The context passed to a module for one command invocation. All fields borrow
|
||||
/// from the current message and configuration.
|
||||
pub struct Command<'a> {
|
||||
/// Nick of the sender.
|
||||
pub sender: &'a str,
|
||||
/// Where a reply should go: the channel for channel messages, or the
|
||||
/// sender's nick for private messages.
|
||||
pub reply_to: &'a str,
|
||||
/// The command word (lowercased, prefix stripped).
|
||||
pub name: &'a str,
|
||||
/// Arguments after the command word (whitespace-split).
|
||||
pub args: &'a [&'a str],
|
||||
/// The configured command prefix (e.g. `">"`), for building help text.
|
||||
pub prefix: &'a str,
|
||||
/// The network's name (`Config::name`), for network-aware modules.
|
||||
pub network: &'a str,
|
||||
}
|
||||
|
||||
/// Something a module asks the bot to do in response to a command. Modules
|
||||
/// return these instead of performing I/O themselves.
|
||||
pub enum Action {
|
||||
/// Send a `PRIVMSG` back to [`Command::reply_to`].
|
||||
Reply(String),
|
||||
/// Send an arbitrary raw IRC line (for power modules, e.g. an admin module).
|
||||
Raw(String),
|
||||
}
|
||||
|
||||
impl Action {
|
||||
/// Convenience constructor for a reply from anything string-like.
|
||||
pub fn reply(text: impl Into<String>) -> Action {
|
||||
Action::Reply(text.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// A bot feature: a self-contained unit that declares the commands it handles
|
||||
/// and turns each invocation into [`Action`]s.
|
||||
///
|
||||
/// Add one by creating a file in `bot/modules/` that implements this trait and
|
||||
/// listing its constructor in [`super::modules::all`].
|
||||
pub trait Module {
|
||||
/// Short identifier for logs and diagnostics (e.g. `"dice"`).
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// The commands this module provides. The registry routes matching command
|
||||
/// words here and builds `help` from these specs.
|
||||
fn commands(&self) -> &'static [CommandSpec];
|
||||
|
||||
/// Handle one command (only ever called for a name in [`Module::commands`]).
|
||||
/// Return the actions to perform; an empty vec does nothing.
|
||||
fn on_command(&mut self, cmd: &Command) -> Vec<Action>;
|
||||
}
|
||||
33
src/bot/modules/builtins.rs
Normal file
33
src/bot/modules/builtins.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
//! The built-in commands: `ping`, `echo`, `hello`. (`help` is provided by the
|
||||
//! dispatcher itself, since it aggregates every module's commands.)
|
||||
|
||||
use crate::bot::module::{Action, Command, CommandSpec, Module};
|
||||
|
||||
/// Core commands that ship with the bot.
|
||||
pub struct Builtins;
|
||||
|
||||
const COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec { name: "ping", usage: "ping", about: "reply with 'pong'" },
|
||||
CommandSpec { name: "echo", usage: "echo <text>", about: "echo the text back" },
|
||||
CommandSpec { name: "hello", usage: "hello", about: "greet the sender" },
|
||||
];
|
||||
|
||||
impl Module for Builtins {
|
||||
fn name(&self) -> &'static str {
|
||||
"builtins"
|
||||
}
|
||||
|
||||
fn commands(&self) -> &'static [CommandSpec] {
|
||||
COMMANDS
|
||||
}
|
||||
|
||||
fn on_command(&mut self, cmd: &Command) -> Vec<Action> {
|
||||
let reply = match cmd.name {
|
||||
"ping" => "pong".to_string(),
|
||||
"echo" => cmd.args.join(" "),
|
||||
"hello" => format!("hello, {}!", cmd.sender),
|
||||
_ => return Vec::new(),
|
||||
};
|
||||
vec![Action::Reply(reply)]
|
||||
}
|
||||
}
|
||||
99
src/bot/modules/dice.rs
Normal file
99
src/bot/modules/dice.rs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
//! A dice roller: `roll 2d6`, `roll d20`, or `roll` (defaults to `1d6`).
|
||||
//!
|
||||
//! Demonstrates a *stateful* module — it carries its own tiny PRNG, seeded once
|
||||
//! at construction — while keeping the crate dependency-free (no `rand`).
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::bot::module::{Action, Command, CommandSpec, Module};
|
||||
|
||||
const COMMANDS: &[CommandSpec] = &[CommandSpec {
|
||||
name: "roll",
|
||||
usage: "roll [NdM]",
|
||||
about: "roll N dice of M sides (default 1d6), e.g. roll 2d20",
|
||||
}];
|
||||
|
||||
/// Upper bounds to keep output (and the channel) sane.
|
||||
const MAX_DICE: u64 = 100;
|
||||
const MAX_SIDES: u64 = 1000;
|
||||
|
||||
/// Dice roller with a self-contained xorshift PRNG — fine for dice, not for
|
||||
/// anything security-sensitive.
|
||||
pub struct Dice {
|
||||
rng: u64,
|
||||
}
|
||||
|
||||
impl Dice {
|
||||
pub fn new() -> Dice {
|
||||
let seed = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0x9E37_79B9_7F4A_7C15);
|
||||
Dice { rng: seed | 1 } // never seed to 0 (xorshift would stay stuck)
|
||||
}
|
||||
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
let mut x = self.rng;
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
self.rng = x;
|
||||
x
|
||||
}
|
||||
|
||||
/// Roll a single die in `1..=sides`.
|
||||
fn die(&mut self, sides: u64) -> u64 {
|
||||
self.next_u64() % sides + 1
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Dice {
|
||||
fn default() -> Dice {
|
||||
Dice::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Module for Dice {
|
||||
fn name(&self) -> &'static str {
|
||||
"dice"
|
||||
}
|
||||
|
||||
fn commands(&self) -> &'static [CommandSpec] {
|
||||
COMMANDS
|
||||
}
|
||||
|
||||
fn on_command(&mut self, cmd: &Command) -> Vec<Action> {
|
||||
let spec = cmd.args.first().copied().unwrap_or("1d6");
|
||||
let reply = match parse_spec(spec) {
|
||||
Some((count, sides)) => {
|
||||
let rolls: Vec<u64> = (0..count).map(|_| self.die(sides)).collect();
|
||||
let total: u64 = rolls.iter().sum();
|
||||
if count == 1 {
|
||||
format!("{count}d{sides}: {total}")
|
||||
} else {
|
||||
let parts: Vec<String> = rolls.iter().map(u64::to_string).collect();
|
||||
format!("{count}d{sides}: {} = {total}", parts.join(" + "))
|
||||
}
|
||||
}
|
||||
None => format!("usage: {p}roll [NdM], e.g. {p}roll 2d6", p = cmd.prefix),
|
||||
};
|
||||
vec![Action::Reply(reply)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `NdM` / `dM` / `M` into `(count, sides)` within sane bounds. A bare
|
||||
/// number `M` means `1dM`.
|
||||
fn parse_spec(s: &str) -> Option<(u64, u64)> {
|
||||
let (count, sides) = match s.split_once('d') {
|
||||
Some((c, m)) => {
|
||||
let count = if c.is_empty() { 1 } else { c.parse().ok()? };
|
||||
(count, m.parse().ok()?)
|
||||
}
|
||||
None => (1, s.parse().ok()?),
|
||||
};
|
||||
if (1..=MAX_DICE).contains(&count) && (2..=MAX_SIDES).contains(&sides) {
|
||||
Some((count, sides))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
17
src/bot/modules/mod.rs
Normal file
17
src/bot/modules/mod.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
//! The registry of feature modules. Add a feature by creating a file here that
|
||||
//! implements [`super::module::Module`] and listing its constructor in [`all`].
|
||||
|
||||
pub mod builtins;
|
||||
pub mod dice;
|
||||
|
||||
use super::module::Module;
|
||||
|
||||
/// Construct the default set of modules, in priority order (earlier modules win
|
||||
/// command-name collisions). Each network's [`super::Bot`] gets its own fresh
|
||||
/// set, so stateful modules keep independent per-network state.
|
||||
pub fn all() -> Vec<Box<dyn Module>> {
|
||||
vec![
|
||||
Box::new(builtins::Builtins),
|
||||
Box::new(dice::Dice::new()),
|
||||
]
|
||||
}
|
||||
87
tests/modules.rs
Normal file
87
tests/modules.rs
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
//! Unit tests for feature modules — constructed directly and driven through the
|
||||
//! public `Module` trait, with no network. This is the payoff of the module
|
||||
//! system: features are testable in isolation.
|
||||
|
||||
use rustbot::bot::module::{Action, Command, Module};
|
||||
use rustbot::bot::modules::{builtins::Builtins, dice::Dice};
|
||||
|
||||
fn cmd<'a>(name: &'a str, args: &'a [&'a str]) -> Command<'a> {
|
||||
Command { sender: "alice", reply_to: "#chan", name, args, prefix: ">", network: "test" }
|
||||
}
|
||||
|
||||
/// Assert the module produced exactly one reply and return its text.
|
||||
fn reply(actions: &[Action]) -> &str {
|
||||
match actions {
|
||||
[Action::Reply(s)] => s,
|
||||
_ => panic!("expected exactly one Reply action"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtins_ping_pongs() {
|
||||
let mut m = Builtins;
|
||||
assert_eq!(reply(&m.on_command(&cmd("ping", &[]))), "pong");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtins_echo_joins_args() {
|
||||
let mut m = Builtins;
|
||||
assert_eq!(reply(&m.on_command(&cmd("echo", &["hello", "world"]))), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtins_hello_greets_sender() {
|
||||
let mut m = Builtins;
|
||||
assert_eq!(reply(&m.on_command(&cmd("hello", &[]))), "hello, alice!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dice_declares_roll_command() {
|
||||
let m = Dice::new();
|
||||
assert_eq!(m.name(), "dice");
|
||||
assert!(m.commands().iter().any(|c| c.name == "roll"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dice_2d6_total_is_in_range() {
|
||||
let mut d = Dice::new();
|
||||
for _ in 0..300 {
|
||||
let r = reply(&d.on_command(&cmd("roll", &["2d6"]))).to_string();
|
||||
// Format: "2d6: a + b = total"
|
||||
let total: u64 = r.rsplit('=').next().unwrap().trim().parse().unwrap();
|
||||
assert!((2..=12).contains(&total), "2d6 total {total} out of range: {r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dice_defaults_to_1d6() {
|
||||
let mut d = Dice::new();
|
||||
let r = reply(&d.on_command(&cmd("roll", &[]))).to_string();
|
||||
// Format: "1d6: N"
|
||||
assert!(r.starts_with("1d6: "), "unexpected format: {r:?}");
|
||||
let n: u64 = r.rsplit(':').next().unwrap().trim().parse().unwrap();
|
||||
assert!((1..=6).contains(&n), "1d6 {n} out of range: {r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dice_bare_number_means_one_die() {
|
||||
let mut d = Dice::new();
|
||||
let r = reply(&d.on_command(&cmd("roll", &["20"]))).to_string();
|
||||
assert!(r.starts_with("1d20: "), "expected 1d20, got {r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dice_rejects_garbage_with_usage() {
|
||||
let mut d = Dice::new();
|
||||
let actions = d.on_command(&cmd("roll", &["banana"]));
|
||||
let r = reply(&actions);
|
||||
assert!(r.contains("usage"), "expected a usage hint, got {r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dice_rejects_out_of_bounds() {
|
||||
let mut d = Dice::new();
|
||||
// 999 dice exceeds the cap; 1-sided die is below the minimum.
|
||||
assert!(reply(&d.on_command(&cmd("roll", &["999d6"]))).contains("usage"));
|
||||
assert!(reply(&d.on_command(&cmd("roll", &["1d1"]))).contains("usage"));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue