Building a Chat Server in Rust #4: Commands and Plugins
Post 4 of 6 in Building a Chat Server in Rust. Companion series: Rust Patterns That Matter.
Previous: #3: Parsing and Performance | Next: #5: Going Multi-threaded
Our server parses a wire protocol, but all
the "commands" are baked into the frame parser. Now we build a real command system
(/join, /nick, /kick), a plugin system
for message filters, a builder for server configuration, and typestate connections.
Five patterns in one post.
The code is on the
04-commands
branch.
Pattern #14: Enum dispatch - the command system
Commands are a closed set: /join, /nick,
/kick, /quit, /help, /list.
We know every variant at compile time. That makes them a perfect fit for an enum:
pub enum Command {
Join { room: String },
Nick { name: String },
Kick { target: String },
Quit,
Help,
List,
}
impl Command {
pub fn execute(self, current_room: RoomId) -> CommandResult {
match self {
Command::Join { room } => CommandResult::JoinRoom { room },
Command::Nick { name } => CommandResult::ChangeNick { new_name: name },
Command::Kick { target } => CommandResult::KickUser { target, room_id: current_room },
Command::Quit => CommandResult::Quit,
Command::Help => CommandResult::Reply("Commands: /join, /nick, /kick, /list, /quit".into()),
Command::List => CommandResult::Reply("(room listing)".into()),
}
}
}
Enum dispatch is faster than trait objects (no vtable, no indirection) and
exhaustive - if you add a variant and forget a match arm, it won't compile.
Use enums when the set of variants is closed. Use trait objects
(dyn Trait) when the set is open - when users or plugins can
add new types at runtime.
Deep dive: Rust Patterns #14: Enum Dispatch vs Trait Objects.
Patterns #15 & #16: Closures and the plugin system
Commands are a closed set, but message filters are an open set - the server needs profanity filtering, rate limiting, logging. Each filter is different logic, but all share the same shape: take a username and message body, return allow/modify/block. Closures capture the state each filter needs.
The question is which closure trait to use. Rust has three:
Fn- can be called multiple times, only reads capturesFnMut- can be called multiple times, can mutate capturesFnOnce- can only be called once, consumes captures
Our filters need to be called on every message (not once), and they may track
state (like a message counter). That means FnMut:
pub struct FilterRegistry {
filters: Vec<Box<dyn FnMut(&str, &str) -> FilterAction>>,
}
impl FilterRegistry {
pub fn add<F>(&mut self, filter: F)
where
F: FnMut(&str, &str) -> FilterAction + 'static,
{
self.filters.push(Box::new(filter));
}
}
Box<dyn FnMut(...)> is the key. Closures have anonymous types
- each closure is a unique, unnameable type. Box<dyn>
erases the type so different closures can live in the same Vec.
Registration looks like this:
let mut count = 0u64;
server.filters.add(move |_username: &str, _body: &str| {
count += 1; // mutates captured variable — needs FnMut
println!("[filter] message #{count} processed");
FilterAction::Allow
});
Deep dive: Rust Patterns #15: Fn, FnMut, FnOnce and Rust Patterns #16: Storing Closures.
Commands and filters handle messages. But configuring the server itself - ports, limits, MOTD - needs its own pattern.
Pattern #17: Builder - server configuration
Our server has grown: address, port, max users, max rooms, message of the day. Without a builder, construction looks like this:
ServerConfig::new("127.0.0.1", 8080, 100, 50, Some("Welcome!"))
Which number is the port? Which is max_users? A builder gives named, optional, chainable configuration:
let config = ServerConfig::builder()
.addr("127.0.0.1")
.port(8080)
.max_users(100)
.motd("Welcome to the Rust chat server!")
.build();
The builder is a separate struct that accumulates optional values and produces
the final config. Defaults are set in builder(), overrides are
chained, and build() validates and returns the config:
impl ServerConfig {
pub fn builder() -> ServerConfigBuilder {
ServerConfigBuilder {
addr: "127.0.0.1".to_string(),
port: 8080,
max_users: 100,
max_rooms: 50,
motd: None,
}
}
}
impl ServerConfigBuilder {
pub fn port(mut self, port: u16) -> Self {
self.port = port;
self
}
// ... other fields follow the same pattern
pub fn build(self) -> ServerConfig { /* ... */ }
}
Deep dive: Rust Patterns #17: Builder Pattern.
Configuration builds the server. But what about the connections themselves? Each client goes through a lifecycle, and we can enforce it at compile time.
Pattern #18: Typestate - connections that can't be misused
A connection goes through stages: accepted -> authenticated -> in a room. Without typestate, every method that depends on state needs a runtime check:
fn send(&mut self, text: &str) -> Result<(), ChatError> {
if !self.authenticated { return Err(ChatError::NotAuthenticated); }
// ...
}
Forget a check and you have a bug. We can encode the stages in the type system instead:
pub struct Unauthenticated;
pub struct Authenticated;
pub struct InRoom;
pub struct Connection<S> {
pub stream: TcpStream,
pub reader: BufReader<TcpStream>,
// ...
_state: PhantomData<S>,
}
Each state only exposes the methods that make sense:
impl Connection<Unauthenticated> {
// Consumes self, returns Connection<Authenticated>.
pub fn authenticate(mut self) -> Result<Connection<Authenticated>, ChatError> {
writeln!(self.stream, "Enter your username:")?;
// ... read username, transition state
}
}
impl Connection<InRoom> {
// Only available after joining a room.
pub fn send(&mut self, text: &str) { /* ... */ }
pub fn read_line(&mut self) -> Result<Option<String>, ChatError> { /* ... */ }
}
authenticate() consumes Connection<Unauthenticated>
and returns Connection<Authenticated>. You can't call
send() on an unauthenticated connection - it doesn't exist on
that type. Invalid transitions are compile errors, not runtime checks.
Deep dive: Rust Patterns #18: Typestate.
Try it
# Terminal 1
git checkout 04-commands
cargo run
# Terminal 2
nc 127.0.0.1 8080
alice
hello everyone # plain text chat
/join general # → * You joined #general
/nick alicia # → * You are now alicia (was alice)
/help # → Commands: /join, /nick, /kick, /list, /quit
/quit # → * Goodbye!
What we have, what's missing
Five more patterns are working:
- Enum dispatch - commands as a closed enum, exhaustive matching.
- Fn/FnMut/FnOnce - filters use
FnMutbecause they mutate state. - Storing closures -
Box<dyn FnMut(...)>in aVec. - Builder -
ServerConfig::builder().port(8080).build(). - Typestate -
Connection<Unauthenticated>->Connection<Authenticated>->Connection<InRoom>.
What's missing: the server still handles one client at a time.
Next time we go multi-threaded with
Arc<Mutex> and channels.
Telex