Types That Tell a Story: Structs and Enums
Chapter 4 taught you to read a single function; this chapter zooms out to the data itself. jackdaw uses structs to say "here are the fields this thing has" and enums to say "this value can only be one of a few shapes". By the end you can answer:
- Where does the string
strategy = "order"injackdaw.tomlbecomeStrategy::Order, and by what rule? - If someone adds a third variant to
Strategy, which code stops compiling immediately? - Reading
load(): which step can produce aNone, which can produce anErr— and who assembles the error message?
Structs: turning a TOML table into a type
Start with the file jackdaw eats. This one is a valid config assembled in a config.rs test: a gateway, one provider, one routing group — three top-level tables.
[gateway]
port = 3426
auth_token_env = "JACKDAW_TOKEN"
[providers.openai]
base_url = "https://api.openai.com/v1"
api_key_env = "OPENAI_API_KEY"
[groups.coder]
strategy = "order"
models = ["openai/gpt-5.2"]Without structs, this file enters the program as a pile of nested string tables: every call site fetches its own config["gateway"]["port"], guesses its own types, and a typo in a key waits until that exact line runs before it blows up. A struct declares "here's what the config looks like" once, and the compiler checks every use against it:
/// The full top-level configuration.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub gateway: GatewayConfig,
pub providers: BTreeMap<String, ProviderConfig>,
#[serde(default)]
pub groups: BTreeMap<String, GroupConfig>,
}
// ...
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GroupConfig {
pub strategy: Strategy,
/// Group members: `<provider>/<model-id>` references, tried in order.
pub models: Vec<String>,
}Read from pub struct Config: the three fields mirror the three top-level tables, and gateway: GatewayConfig is a struct inside a struct. The // ... is this book's excerpt convention — it hides GatewayConfig (holding port and auth_token_env) and ProviderConfig (base_url and api_key_env). The type of providers, BTreeMap<String, ProviderConfig>, reads as "name → one config": a BTreeMap is a key-sorted map, a cousin of HashMap from the collections family. Treat it as a plain mapping here; how it differs from HashMap doesn't matter for this chapter. #[serde(default)] lets the whole [groups] table be absent: delete the coder group from the sample and loading still succeeds.
derive and serde: the parts the compiler writes for you
#[derive(Debug, Clone, Deserialize)] is the chapter's second protagonist: derive. It has the compiler implement common abilities for your type by general rules: Debug lets a value print with {:?} — {member:?} in validate's errors below prints a String, whose Debug ships with the standard library, while your own types get that ability from this derive. Clone allows an explicit .clone(); and Deserialize comes from the serialization library serde — an external crate (where crates come from is Chapters 8–9); for now you only need to know it declares "I know how to assemble this type from TOML text". That's why one line in load(), toml::from_str(&text), turns file text into a typed Config.
Note #[serde(deny_unknown_fields)] on its own: a field the structs don't know (say strategy misspelled as stratagy) fails the load instead of being silently ignored — the test unknown_field_is_a_load_error pins that down.
Reviewing AI-generated config code
When an AI defines a config struct with only #[derive(Deserialize)] and no deny_unknown_fields, ask: is a silently swallowed typo really what this program wants?
Enums: one type, a handful of known shapes
port: u16 says any u16 is fine; strategy has exactly two legal values. Store it as a string and "smart" compiles too — it only explodes at runtime. An enum writes "only these shapes are possible" into the type:
/// How a group picks its starting member. Serde matches the lowercase TOML
/// spelling, so an unknown strategy (say, magpie's `smart`) is a load error.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Strategy {
/// Always start at the first member on the list.
Order,
/// Move the starting member forward on every request.
Rotate,
}Start at the doc comment — it states the whole mechanism: serde matches lowercase spellings, and #[serde(rename_all = "lowercase")] maps the variant Order to "order" in TOML. Walk the middle states: the file says strategy = "order" → from_str reads the string "order" → the string is matched against variants in lowercase → out comes Strategy::Order, stored in GroupConfig.strategy. Compare with magpie: its routing strategies are smart / order / rotate / usage, and jackdaw deliberately implements only order and rotate (the tutorial keeps the minimal core). The type absorbs that difference — writing strategy = "smart" buys you a load error, which is exactly what the test unknown_strategy_is_rejected checks.
Turn the same lens on the chat request an agent sends (OpenAI-compatible shape): role has a closed set of values — enum; messages is a list — Vec; temperature may or may not be there — Option; the whole body moves in and out through serde. jackdaw's real message types unfold with the gateway in chapter 14; for now you only need to answer the reading question "which key should be which type".
match and exhaustiveness: the compiler checks your homework
In pattern matching, match takes a value apart by structure, and its hardest guarantee is exhaustiveness: every possible variant needs an arm; miss one and the code won't compile. Suppose a third variant joins Strategy — every match over it in config.rs fails on the spot. You don't have to remember "what else needs updating"; the compiler makes the list. An if-else chain promises no such thing. Enum variants can also carry data, and match unpacks it on the spot. validate() checking group members is the example:
// ...
for member in &group.models {
match ModelRef::parse(member) {
Err(why) => {
errors.push(format!("[groups.{id}] models: member {member:?}: {why}"));
}
// Groups stay flat on purpose (magpie nests up to 8 levels;
// jackdaw keeps one level to stay readable).
Ok(ModelRef::Group { .. }) => {
errors.push(format!(
"[groups.{id}] models: member {member:?} is a group — nested \
groups are not supported. Use `<provider>/<model-id>` members only."
));
}
Ok(ModelRef::Provider { provider, .. }) => {
if !self.providers.contains_key(&provider) {
errors.push(format!(
"[groups.{id}] models: member {member:?} names unknown \
provider `{provider}`. Known providers: {}. Add a \
[providers.{provider}] table or fix the typo.",
self.provider_names().join(", ")
));
}
}
}
}
// ...Read from ModelRef::parse(member): it parses "openai/gpt-5.2" into the ModelRef enum from model.rs, wrapped in Ok/Err (you met this parse function in chapter 4). Each arm binds its own names: Err(why) binds the failure reason to why and splices it into an error; Ok(ModelRef::Group { .. }) claims only "this is the Group variant, which carries fields" and ignores them all; Ok(ModelRef::Provider { provider, .. }) pulls out just the provider field and looks it up in the providers table — read { .. } as "the remaining fields don't concern me". Walk the middle states: the member "group/other" lands in the second arm and is rejected (magpie nests groups up to 8 levels; jackdaw keeps one on purpose); "nope/gpt-5.2" lands in the third — no such provider. Both outcomes produce an actionable error, not a crash.
Option and Result: absence and failure get types
"Maybe missing" and "maybe failed" — plenty of languages handle these with null, exceptions, or magic values. Rust gives each its own type: Option<T> is Some(value) or None; Result<T, E> is Ok(value) or Err(error). Both are enums, so everything above applies unchanged.
While hunting for the config file, jackdaw may not get a home directory at all:
/// Where to look when `--config` was not given: local file first, then the
/// per-user location. Order matters — a local file overrides the user one.
fn config_candidates() -> Vec<PathBuf> {
let mut tried = vec![PathBuf::from(LOCAL_CONFIG_NAME)];
match std::env::home_dir() {
Some(home) => tried.push(home.join(".config").join("jackdaw").join("jackdaw.toml")),
None => tried.push(PathBuf::from("~/.config/jackdaw/jackdaw.toml")),
}
tried
}Read from std::env::home_dir(): it returns an Option because the system may not give you a home directory. Some(home) binds the path to home and joins the real location; None holds no value at all, so it pushes a placeholder path into the list — that way the error message still shows "we looked here".
The load pipeline chains both types:
pub fn load(explicit: Option<&Path>) -> Result<Config, ConfigError> {
let path = match explicit {
Some(given) => Some(given.to_path_buf()),
None => config_candidates().into_iter().find(|path| path.is_file()),
};
let Some(path) = path else {
return Err(ConfigError::no_config(&config_candidates()));
};
let text = std::fs::read_to_string(&path).map_err(|error| ConfigError::Read(path, error))?;
let config: Config =
toml::from_str(&text).map_err(|error| ConfigError::Parse(error.to_string()))?;
config
.validate()
.map_err(|errors| ConfigError::Validation(format!("- {}", errors.join("\n- "))))?;
Ok(config)
}Read from the signature: explicit is the path from --config, possibly absent — hence Option. The first match turns it into "the first candidate that exists as a file"; if there is none, let Some(path) = path else { ... } returns early — pattern matching in another outfit: a Some binds its value to path and continues, a None takes the else branch. After that, every ? means the same thing: on Err, hand the error straight back to the caller; on Ok, unwrap and continue (the ? operator). The three map_err calls each swap the underlying error into the matching ConfigError variant. That ConfigError in the return type is yet another data-carrying enum — Read(PathBuf, std::io::Error) holds both which file and what went wrong underneath; its definition and the error-handling story unfold in chapter 11. Once failure is a type too, callers can't pretend it away. Revisit the third opening question — which step yields None (the let-else), which yields Err (the three ?s), who assembles the messages (the map_err closures) — you have all three answers now.
Summary
- Structs write a TOML table's shape into a type; enums write "only these shapes" into a type; serde's derive handles the text-to-type assembly.
- match exhaustiveness turns "added a variant, forgot a spot" from a runtime incident into a compile error.
- Option and Result put absence and failure into return types, and
?is their fast lane. - Next up: traits — contracts of behavior, and the spine of jackdaw.
Self-Check
- Express "strategy can only be order or rotate" in the language you know best (TypeScript, say), then compare against
Strategyplusrename_all: what extra protection does the Rust setup buy, and what does it cost? - Change the coder group's member in the sample config to
group/coder(a reference to itself): which match arm doesvalidate()hit, and what happens? - In
load(), drop.map_err(...)from theread_to_stringline and keep only?: why doesn't it compile?
Answers
- A TypeScript union like
'order' | 'rotate'constrains values inside your program. The difference sits at the boundary: the moment TOML text arrives, Rust'sDeserializemerges validation and construction into one step, and everything inside the program is one of the twoStrategyvariants — with match exhaustiveness on top. Cost: an extra layer of attributes, and variant names and config spellings must stay in sync in two places. ModelRef::parseparsesgroup/coderintoOk(ModelRef::Group { .. }), which lands in the second arm and adds "nested groups are not supported" toerrors. With errors non-empty,validate()returnsErr,loadwraps it asConfigError::Validation, and startup fails. jackdaw's groups stay flat on purpose (magpie nests up to 8 levels).read_to_stringfails withstd::io::Error, butloaddeclaresResult<_, ConfigError>;?requires the error type here to match the function's (or convert automatically — it doesn't here)..map_err(|error| ConfigError::Read(path, error))first swapsio::ErrorforConfigError::Read; only then can?return early.