Skip to content

Day 2 — Typed Model Output

Today Brandforge reads a saved model response from fixtures/business-profile.json. You will create one new module and replace yesterday’s main.rs with a complete new checkpoint.

Terminal window
cd rust/rust-ai-engineering/learning/brandforge

Run yesterday’s checkpoint once:

Terminal window
cargo run -- --source fixtures/site.md

If that does not work, finish Day 1 before continuing.

ActionFilePurpose
CREATEsrc/profile.rsDefine and validate the exact model-output types
REPLACEsrc/main.rsRead the source and profile, decode JSON, and print the accepted profile
Do not editCargo.tomlserde and serde_json are already installed

Your tree will become:

learning/brandforge/
├── Cargo.toml
├── fixtures/
│ ├── site.md
│ └── business-profile.json
└── src/
├── main.rs # replace
└── profile.rs # create

In the same folder as main.rs, create a file named exactly profile.rs.

CREATE — src/profile.rs — complete file:

use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Palette {
pub primary: String,
pub background: String,
pub accent: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Claim {
pub text: String,
pub evidence_quote: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BusinessProfile {
pub name: String,
pub website: String,
pub tagline: String,
pub service_area: String,
pub services: Vec<String>,
pub palette: Palette,
pub claims: Vec<Claim>,
}
impl BusinessProfile {
pub fn validate(&self) -> Result<(), String> {
require_text("name", &self.name)?;
require_text("website", &self.website)?;
require_text("tagline", &self.tagline)?;
require_text("service_area", &self.service_area)?;
if self.services.is_empty() {
return Err("services must contain at least one item".to_owned());
}
for service in &self.services {
require_text("services[]", service)?;
}
for (name, color) in [
("palette.primary", &self.palette.primary),
("palette.background", &self.palette.background),
("palette.accent", &self.palette.accent),
] {
if !is_hex_color(color) {
return Err(format!(
"{name} must be a six-digit hex color, got `{color}`"
));
}
}
for claim in &self.claims {
require_text("claims[].text", &claim.text)?;
require_text("claims[].evidence_quote", &claim.evidence_quote)?;
}
Ok(())
}
}
fn require_text(field: &str, value: &str) -> Result<(), String> {
if value.trim().is_empty() {
return Err(format!("{field} must not be empty"));
}
Ok(())
}
fn is_hex_color(value: &str) -> bool {
value.len() == 7
&& value.starts_with('#')
&& value.bytes().skip(1).all(|byte| byte.is_ascii_hexdigit())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_a_six_digit_hex_color() {
assert!(is_hex_color("#174D2A"));
}
#[test]
fn rejects_a_color_name() {
assert!(!is_hex_color("green"));
}
}

The pub keyword makes these types visible to main.rs. #[serde(deny_unknown_fields)] makes an unexpected JSON field an error instead of silently ignoring it.

Open src/main.rs, select everything, and replace it with the next complete file.

REPLACE — src/main.rs — complete file:

mod profile;
use std::path::PathBuf;
use std::process::ExitCode;
use clap::Parser;
use profile::BusinessProfile;
#[derive(Debug, Parser)]
#[command(name = "brandforge", version, about)]
struct Cli {
#[arg(long, value_name = "FILE")]
source: PathBuf,
#[arg(long, value_name = "FILE")]
profile: PathBuf,
}
fn main() -> ExitCode {
let cli = Cli::parse();
let source_bytes = match std::fs::read(&cli.source) {
Ok(bytes) => bytes,
Err(error) => {
eprintln!("could not read `{}`: {error}", cli.source.display());
return ExitCode::FAILURE;
}
};
let profile_bytes = match std::fs::read(&cli.profile) {
Ok(bytes) => bytes,
Err(error) => {
eprintln!("could not read `{}`: {error}", cli.profile.display());
return ExitCode::FAILURE;
}
};
let profile: BusinessProfile = match serde_json::from_slice(&profile_bytes) {
Ok(profile) => profile,
Err(error) => {
eprintln!("profile JSON is invalid: {error}");
return ExitCode::FAILURE;
}
};
if let Err(error) = profile.validate() {
eprintln!("profile validation failed: {error}");
return ExitCode::FAILURE;
}
println!("accepted profile: {}", profile.name);
println!("website source bytes: {}", source_bytes.len());
println!("services: {}", profile.services.len());
println!("claims waiting for evidence checks: {}", profile.claims.len());
ExitCode::SUCCESS
}

The first line is important:

mod profile;

It tells Rust to compile src/profile.rs as a module. Without it, use profile::BusinessProfile cannot work.

Terminal window
cargo fmt
cargo check
cargo run -- \
--source fixtures/site.md \
--profile fixtures/business-profile.json

Expected result:

accepted profile: Northstar Gardens
website source bytes: ...
services: 3
claims waiting for evidence checks: 2

This line proves the JSON has the expected fields and Rust types:

let profile: BusinessProfile = serde_json::from_slice(&profile_bytes)?;

The explicit validate call proves application rules such as nonempty services and six-digit colors. Neither operation proves that a promotional claim is true. Day 4 adds that separate evidence gate.

Do not make every field Option<T> just to accept more JSON. name is required because Brandforge cannot produce a named campaign without it. Use Option<T> only when absence is a legitimate product state.

Create a bad copy:

Terminal window
cp fixtures/business-profile.json fixtures/business-profile-bad.json

Open fixtures/business-profile-bad.json, change the primary color from "#174D2A" to "green", save, and run:

Terminal window
cargo run -- \
--source fixtures/site.md \
--profile fixtures/business-profile-bad.json

It must fail with palette.primary must be a six-digit hex color. Then try one mutation at a time:

  1. remove services;
  2. add an unknown customer_count field;
  3. change services from an array to a string;
  4. set name to an empty string.

Classify each failure as JSON syntax, Serde structure, or domain validation.

Terminal window
cargo test

The two profile.rs tests should pass.

You now have exactly two Rust source files:

src/main.rs
src/profile.rs

Tomorrow you will create src/model.rs and replace src/main.rs. You will not modify profile.rs on Day 3.

Next: Day 3 — Provider Boundary →.