Telex logo Telex

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:

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:

What's missing: the server still handles one client at a time. Next time we go multi-threaded with Arc<Mutex> and channels.