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.
Start in the correct directory
Section titled “Start in the correct directory”cd rust/rust-ai-engineering/learning/brandforgeRun yesterday’s checkpoint once:
cargo run -- --source fixtures/site.mdIf that does not work, finish Day 1 before continuing.
Today’s edit map
Section titled “Today’s edit map”| Action | File | Purpose |
|---|---|---|
| CREATE | src/profile.rs | Define and validate the exact model-output types |
| REPLACE | src/main.rs | Read the source and profile, decode JSON, and print the accepted profile |
| Do not edit | Cargo.toml | serde 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 # createCreate src/profile.rs
Section titled “Create src/profile.rs”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.
Replace src/main.rs
Section titled “Replace src/main.rs”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.
Check and run
Section titled “Check and run”cargo fmtcargo checkcargo run -- \ --source fixtures/site.md \ --profile fixtures/business-profile.jsonExpected result:
accepted profile: Northstar Gardenswebsite source bytes: ...services: 3claims waiting for evidence checks: 2What you proved, and what you did not
Section titled “What you proved, and what you did not”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.
Break it deliberately
Section titled “Break it deliberately”Create a bad copy:
cp fixtures/business-profile.json fixtures/business-profile-bad.jsonOpen fixtures/business-profile-bad.json, change the primary color from "#174D2A" to
"green", save, and run:
cargo run -- \ --source fixtures/site.md \ --profile fixtures/business-profile-bad.jsonIt must fail with palette.primary must be a six-digit hex color. Then try one mutation at a time:
- remove
services; - add an unknown
customer_countfield; - change
servicesfrom an array to a string; - set
nameto an empty string.
Classify each failure as JSON syntax, Serde structure, or domain validation.
Run the tests
Section titled “Run the tests”cargo testThe two profile.rs tests should pass.
End-of-day checkpoint
Section titled “End-of-day checkpoint”You now have exactly two Rust source files:
src/main.rssrc/profile.rsTomorrow 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 →.