Skip to content

Rust Foundations: Variables, Functions, Expressions ​

In magpie's world, wherever a model is expected, you write one string: openai/gpt-5.2 points at a concrete model from a provider; group/coder points at a routing group (you met both in the Chapter 2 topology). Before jackdaw can use that string, it has to split it. This chapter reads, line by line, the function that does exactly that. By the end you can answer:

  • In let raw = raw.trim();, what do the two raws each refer to? Why is there no mut?
  • In pub fn parse(raw: &str) -> Result<Self, String>, what is each chunk, left to right?
  • When you run into fn main() -> Result<(), Box<dyn Error>>, can you take it apart on the spot and explain it to someone?

The running example: a string that must be split ​

Never trust a user-supplied string: it may carry surrounding whitespace, lack a /, or have an empty side of the slash. ModelRef::parse in jackdaw/src/model.rs settles those questions before any routing happens. Splitting needs a few temporary names, and Rust makes names with let — a let binding, immutable by default; you add mut (mutability) only to change a value in place. Notice the excerpt below contains not a single mut: every "change" makes a new value and rebinds the name; nothing gets modified in place.

rust
// ...
    pub fn parse(raw: &str) -> Result<Self, String> {
        let raw = raw.trim();
        let Some((head, rest)) = raw.split_once('/') else {
            return Err(format!(
                "invalid model reference {raw:?}: no `/` found. \
                 Expected `<provider>/<model-id>` or `group/<id>`"
            ));
        };
        if head.is_empty() || rest.is_empty() {
            return Err(format!(
                "invalid model reference {raw:?}: neither side of `/` may be empty"
            ));
        }

How to read it: Start at let raw = raw.trim();. The parameter raw is a string slice (&str) — a read-only view over a string's bytes; trim() hands back a new slice with the whitespace stripped, and the code binds it right back to raw. That is shadowing: a new let covers the old name, and the type may even change — a different thing from mutating with mut. The line carries no type annotation because the compiler infers &str from trim()'s return type (type inference). The next line is a let-else: split_once('/') returns an Option — either Some((before the slash, after the slash)) or None; when the value is None, the pattern Some((head, rest)) fails and the else branch returns early with Err(...), and the string in those parens is exactly the message the user sees verbatim. format! ends in ! because it's a macro — treat it as a function for now and skip the details. The if right after plays the same trick again: if either side of the slash is empty (/gpt-5.2, say), parse returns early with a different Err message.

Meet the primitive types while we're here: u32/i64 integers, f64, bool, char, and the unit type () — a type with exactly one value, written (), that carries no information. Integer literals default to i32; annotate for anything else, e.g. let port: u16 = 3426;.

A signature is a contract: reading one fn line ​

A function's signature line is a contract written for the reader. Take pub fn parse(raw: &str) -> Result<Self, String> apart chunk by chunk: pub — other modules may use it (Chapter 8); inside the parens it's name: type, and parameter types are mandatory; after -> comes the return type, equally mandatory. The return is a Result: the success half is Self — the type the surrounding impl ModelRef block belongs to, that is, the thing parse produces; the failure half is a String, a message meant for humans. What does Self look like? The definition sits in the same file:

rust
// ...
/// A parsed model reference.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModelRef {
    /// Direct reference: name of a `[providers.<name>]` table plus the
    /// upstream model id.
    Provider { provider: String, model_id: String },
    /// Routing-group reference: `group/<id>`.
    Group { id: String },
}

How to read it: Start at pub enum. An enum is one type with a known set of shapes: either Provider (carrying the named fields provider and model_id) or Group (carrying id). The fields are Strings — heap-allocated, owning their data — not read-only &str, because the split pieces must be stored and handed to routing later; who owns that data is the ownership question, Chapter 5. #[derive(...)] has the compiler implement "can print, can compare" for free (derive; Chapter 6). One sentence to walk away with on a first read: what parse produces is an either/or value.

Expressions: the last line has no semicolon ​

In most languages if is a statement and cannot act as a value; in Rust, if, match, and {} blocks are all expressions — they have values (expression vs statement). let and return are statements; a block's value equals the value of its last expression; appending a ; to an expression turns it into a statement and throws the value away (you get ()). So when the last line of a function body carries no semicolon, that line is the return value:

rust
// ...
        if head == RESERVED_PROVIDER_NAME {
            // The reserved name routes to groups, so `group/coder` means
            // "group with id `coder`", never "provider named group".
            Ok(ModelRef::Group {
                id: rest.to_string(),
            })
        } else {
            Ok(ModelRef::Provider {
                provider: head.to_string(),
                model_id: rest.to_string(),
            })
        }
    }

How to read it: Start at the if line. head and rest are the two names the let-else bound in the previous section, consumed here. RESERVED_PROVIDER_NAME is the constant at the top of the file, pub const RESERVED_PROVIDER_NAME: &str = "group"; — the name group is reserved for routing groups, so a provider called group could never be reached, and config validation rejects it. Both branches produce Ok(...) wrapping an expression that constructs an enum variant, and rest.to_string() builds a new String from the slice. Note what does not follow the else block's closing }: no semicolon, no return. The whole if/else is parse's tail expression, and its value is the function's return value.

The same file holds one more pattern matching site, gluing values back into strings:

rust
// ...
impl fmt::Display for ModelRef {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ModelRef::Provider { provider, model_id } => {
                write!(f, "{provider}/{model_id}")
            }
            ModelRef::Group { id } => write!(f, "{RESERVED_PROVIDER_NAME}/{id}"),
        }
    }
}

How to read it: Start at match self: dispatch on the shape of the value — a Provider writes its two fields back out, a Group writes group/<id>; the right side of each => arm is an expression, and the match's value in turn becomes fmt's tail expression. Read impl fmt::Display for ModelRef for now as boilerplate meaning "define how this type prints"; what a trait is lands in Chapter 7, and why match must cover every variant (exhaustiveness) in Chapter 6.

Taking apart fn main() -> Result<(), Box<dyn Error>> ​

Countless Rust projects — and plenty of AI-generated first drafts — open with this line. Now you can take it apart yourself:

  • fn main(): the entry point; the runtime calls it, not you;
  • ->: introduces the return type — main may return a value too;
  • Result<(), Box<dyn Error>>: the success half is () — "nothing to report"; the failure half is Box<dyn Error> — a box holding any error (Box is a smart pointer; dyn Error waits for Chapter 7).

Read the whole line as: "on success, nothing to report; on failure, exit carrying an error." Writing it this way buys you the ? operator inside main:

rust
// Illustrative code, not a jackdaw source file; assumes ModelRef is in scope
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    let parsed = ModelRef::parse("openai/gpt-5.2")?;
    println!("{parsed:?}");
    Ok(())
}

How to read it: Start at the line with ?: when parse returns Ok, ? unwraps the ModelRef inside and binds it to parsed; when parse returns Err, ? makes main return that Err on the spot — the runtime prints the error and the process exits with a non-zero code (exit codes: Chapter 10). {parsed:?} can print precisely because of #[derive(Debug)]. The last line, Ok(()), is the tail expression: success, no value. ? also quietly converts the String error into a Box<dyn Error>; skip that detail on a first pass.

Summary ​

The two raws are shadowing, not mutation; read signatures left to right as pub fn name(param: Type) -> ReturnType; an enum is an either/or value; the last line without a semicolon is the return value; a main returning Result<(), Box<dyn Error>> means "success says nothing, failure carries the error." Next chapter we chase the difference between &str going in and String coming out of parse: who owns that data.

Self-Check ​

  1. Coming from JavaScript, you'd write raw = raw.trim(). Against let raw = raw.trim();: what is the relationship between the two raws, and why does this need no mut?
  2. Without running anything: inside the Ok from ModelRef::parse("openrouter/deepseek/deepseek-chat"), what are provider and model_id? What does ModelRef::parse("/gpt-5.2") return?
  3. The if/else at the end of parse has no trailing semicolon. What happens if someone adds one? What does that tell you about the last line of a function body in Rust?
Answers
  1. The second let creates a new binding that shadows the parameter; trim() returns a new slice and the original is never modified. No mut needed, because nothing is mutated in place.
  2. provider is openrouter; model_id is deepseek/deepseek-chat — the split happens at the first slash only and the rest is kept verbatim. "/gpt-5.2" has an empty left side, so the second check fires and parse returns an Err; the message reads "neither side of / may be empty".
  3. The semicolon turns the if/else into a statement that discards its value; the body's tail expression becomes (), which does not match the declared Result<Self, String>, so compilation fails. The last line is an expression — "the function's value" — not merely the end of a sequence of actions.