Skip to content

Config and the Model Catalog ​

In Chapter 8 you read Cargo.toml line by line — that's the manifest Cargo reads. jackdaw.toml is configuration jackdaw reads about itself. By the end of this chapter, you should be able to answer:

  • You typo strategy as stratagy. Which step of load() catches it, and why does jackdaw refuse to start instead of quietly falling back to defaults?
  • api_key_env = "OPENAI_API_KEY" stores a name, not the key. What two promises does that buy?
  • thiserror and anyhow live in the same project. Who owns which territory?

From TOML to Types ​

First, the input — a sample jackdaw.toml:

toml
[gateway]
port = 3426
auth_token_env = "JACKDAW_TOKEN"

[providers.openai]
base_url = "https://api.openai.com/v1"
api_key_env = "OPENAI_API_KEY"

[providers.openrouter]
base_url = "https://openrouter.ai/api/v1"
api_key_env = "OPENROUTER_API_KEY"

[groups.coder]
strategy = "order"
models = ["openrouter/deepseek/deepseek-chat", "openai/gpt-5.2"]

[groups.fast]
strategy = "rotate"
models = ["openai/gpt-5-mini", "openrouter/meta-llama/llama-3.3-70b"]

Three top-level tables: [gateway] holds the port and token, each [providers.X] table is one provider, and [groups.coder] is a routing group. Without a typed layer, code would fish strings out of the parsed tree all over the place, and a typo'd key would blow up far from its cause. jackdaw instead uses serde — you met it in Chapter 6 translating between data structures and text — to pour the whole file into structs in one pass:

rust
#[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 ProviderConfig {
    /// OpenAI-compatible base URL, without a trailing slash.
    pub base_url: String,
    /// Name of the env var holding the provider API key.
    pub api_key_env: String,
}

Read it: Config is the same skeleton Chapter 6 took apart — only the new ProviderConfig matters here. Start at the providers field. Its type, BTreeMap<String, ProviderConfig>, means every [providers.X] table in the TOML becomes one map entry: the table name X is the key, and the fields inside fill a ProviderConfig. BTreeMap is a collection kept sorted by key, so iteration order is stable — the order jackdaw models lists providers never drifts. #[serde(default)] lets the whole [groups] section be absent. #[derive(Deserialize)] is the derive machinery from Chapter 6 at work. And deny_unknown_fields is the deliberate decision spelled out in the module comment at the top of the file: unknown fields — which usually means typo'd fields — must fail loudly. That's where stratagy dies.

The strategy field has exactly two legal values, and it's an enum:

rust
/// 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,
}

Read it at the attribute: rename_all = "lowercase" makes the variant Order match the TOML string "order". Of the two, order always starts at the first member of the list; rotate moves the starting point forward one slot per request (round-robin). What happens if you write "smart"? Loading fails on the spot — magpie has smart / usage and other strategies; jackdaw deliberately implements only these two. Because of the enum, an unsupported strategy can never become a Config value.

This same Config is the model catalog: the jackdaw models output isn't separately maintained data, it's generated straight from the config:

text
group/coder  order  openrouter/deepseek/deepseek-chat -> openai/gpt-5.2
group/fast   rotate openai/gpt-5-mini -> openrouter/meta-llama/llama-3.3-70b
providers: openai, openrouter   (direct reference format: <provider>/<model-id>)

Read the output: one line per group — name, strategy, members in try order; the last line lists the providers you can reference directly. Catalog and config come from one source, so they can't drift apart.

Finding the File: Two-Level Fallback ​

load() does four things: find the file, read the bytes, parse them into types, run semantic checks. Without --config, step one has a question to answer — where do you look?

rust
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
}

/// Load, parse and validate a config. `explicit` is the `--config` path.
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 it at config_candidates(), which returns the paths to try: first jackdaw.toml in the current directory, then ~/.config/jackdaw/jackdaw.toml. The order is the priority — a project-local file overrides the user-level one. Inside load(), an explicit path is used as-is; otherwise find takes the first path that actually exists as a file. The line let Some(path) = path else { ... } is match shorthand: no Some, and the else block writes both attempted paths — plus the suggestion to copy the example file or pass --config — into the error and returns it. The last three steps each get their own error variant: read failure Read, parse failure Parse, validation failure Validation; the ? at the end of each line unwraps success and sends errors back to the caller early (the ? operator).

validate(): Every Error at Once ​

serde guards the shape, not the semantics — a group member naming a provider that doesn't exist, a base_url that isn't http(s), a provider squatting on the reserved name group. Those checks are ours to write:

rust
    /// All semantic checks the loader cannot express with types alone.
    /// Returns *every* problem found, not just the first, so users fix the
    /// whole file in one pass. Each message names the table, the field, the
    /// reason, and a suggested fix.
    pub fn validate(&self) -> Result<(), Vec<String>> {
        let mut errors = Vec::new();

        // ...

        for (id, group) in &self.groups {
            if group.models.is_empty() {
                errors.push(format!(
                    "[groups.{id}] models: must list at least one `<provider>/<model-id>` member"
                ));
            }
            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(", ")
                            ));
                        }
                    }
                }
            }
        }

        if errors.is_empty() {
            Ok(())
        } else {
            Err(errors)
        }
    }

Read the signature first: the error side is Vec<String>, not a single value — the checks don't stop at the first problem, they collect everything wrong with the file so you fix it in one pass. The inner loop hands each member to ModelRef::parse (which yields either a provider reference or a group reference), then pattern matching routes the result: the Group variant means someone nested a group — magpie nests up to 8 levels, jackdaw deliberately keeps one; the Provider variant checks whether the name exists in self.providers. The :? in {member:?} asks for Debug formatting, which is why the string shows up quoted.

Write the member as "nope/gpt-5.2" and jackdaw check prints:

text
config validation failed:
- [groups.coder] models: member "nope/gpt-5.2" names unknown provider `nope`. Known providers: openai, openrouter. Add a [providers.nope] table or fix the typo.

The comment above the code sets the standard for these messages: each one names the table, the field, the reason, and a suggested fix. Match them against the output — [groups.coder] is the table, models is the field, names unknown provider is the reason, Add a [providers.nope] table or fix the typo is the fix, and the known-provider list rides along. That's what actionable means: the next step is already written into the message.

Secrets: Names, Not Values ​

The module comment at the top of config.rs states the design rule: the file stores the name of the environment variable holding the secret, never the secret itself. jackdaw check accordingly checks presence only:

rust
/// Verify that every environment variable named by the config actually
/// exists. Presence only — values are never inspected or printed.
pub fn check_env(config: &Config) -> Result<(), ConfigError> {
    let missing: Vec<String> = config
        .required_env_names()
        .into_iter()
        .filter(|name| std::env::var(name).is_err())
        .collect();
    if missing.is_empty() {
        Ok(())
    } else {
        Err(ConfigError::MissingEnv(missing.join(", ")))
    }
}

Read it at required_env_names(), which gathers the gateway's auth_token_env plus each provider's api_key_env; filter keeps the ones where env::var fails to read. The test is "can I read it?", not "what is it?" — that's what Presence only in the comment means. Two promises follow: jackdaw.toml holds no secrets, so it's safe to commit and share; and jackdaw check will never print your API key to the terminal. For contrast: magpie keeps its keys in ~/.config/magpie/providers.json; jackdaw deliberately doesn't. The moment a key is actually read comes when a request goes out (Chapter 12).

thiserror vs anyhow: Splitting the Error Work ​

You met anyhow in Chapter 10's main.rs: wrap any error into one value, carry it with context to the top, print, exit. So why does config.rs define its own error type? Because main has to tell exit codes apart — configuration and environment problems exit 2, everything else exits 1 — and telling them apart takes an enum you can match on:

rust
/// Everything that can go wrong before the gateway opens a socket.
/// `main` maps every variant of this type to exit code 2.
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    #[error("no config file found — tried:\n{0}")]
    MissingConfig(String),
    #[error("cannot read config at {0}")]
    Read(PathBuf, #[source] std::io::Error),
    #[error("cannot parse config:\n{0}")]
    Parse(String),
    #[error("config validation failed:\n{0}")]
    Validation(String),
    #[error(
        "required environment variable(s) missing: {0}.\njackdaw stores only variable names in its config; export the values and retry."
    )]
    MissingEnv(String),
}

Read it at the derive: #[derive(Debug, thiserror::Error)] gives this enum the standard library's Error trait. The #[error("...")] above each variant is its print template — {0} fills in the first field — and #[source] marks the underlying cause, so the error chain carries the original io::Error. The variant list doubles as an inventory of everything that can go wrong before startup; the comment on top says it plainly: main maps every variant to exit code 2.

The split: when the caller needs to distinguish errors — branch on variants, pick an exit code, decide whether to retry — define a shaped enum with thiserror. When you only need to report, hauling the error to the top, anyhow does the job. jackdaw lands it this way: library-style modules like config and provider each define their own thiserror types (provider errors further split into "worth trying the next model or not", Chapter 12), and the binary edge collects everything with anyhow.

Summary ​

stratagy dies at deny_unknown_fields; smart dies at the enum — an illegal config never becomes a Config value and never reaches runtime. A missing file gets you both attempted paths in the error; validate() collects every problem, each message carrying table, field, reason, fix; the config stores environment-variable names only, so secrets never touch disk and never get printed. thiserror gives errors their shape; anyhow wraps up at the edge. Next up: the Provider abstraction and one real HTTP call — that's where a key finally gets used.

Self-Check ​

  1. A configuration system you've used before ignores unknown fields and carries on. You write stratagy = "order" into jackdaw.toml. What does jackdaw do? Which attribute guarantees it, and at which step of load()?
  2. A member of [groups.coder] reads "nope/gpt-5.2". Write out the error jackdaw check prints, and point at where each of the four elements lands.
  3. Suppose ProviderConfig were changed to api_key: String, storing the key itself. Which promise of jackdaw check breaks immediately? What else do you lose?
Answers
  1. Startup fails with a Parse error whose message contains stratagy; the guarantee comes from #[serde(deny_unknown_fields)] on the structs, enforced at the toml::from_str step inside load(). The module comment states this is deliberate: fail loudly rather than silently fall back to defaults.

  2. The output:

    text
    config validation failed:
    - [groups.coder] models: member "nope/gpt-5.2" names unknown provider `nope`. Known providers: openai, openrouter. Add a [providers.nope] table or fix the typo.

    Four elements: table [groups.coder], field models, reason names unknown provider, fix Add a [providers.nope] table or fix the typo.

  3. "Presence only — values never inspected or printed" breaks on the spot: check_env would have to read actual values to work. You also lose: jackdaw.toml can't go into a repo, the sample can't be shared as-is, and rotating a key means editing the file instead of re-exporting.