Day 3 — Scripted First, Live Provider Second
Today the profile producer becomes replaceable. The required implementation still reads saved JSON, so
your tests stay offline. A future live provider must return the same BusinessProfile.
Start in the correct directory
Section titled “Start in the correct directory”cd rust/rust-ai-engineering/learning/brandforgecargo testDo not continue until the Day 2 tests pass.
Today’s edit map
Section titled “Today’s edit map”| Action | File | Purpose |
|---|---|---|
| CREATE | src/model.rs | Define ProfileModel and the offline scripted adapter |
| REPLACE | src/main.rs | Ask the adapter for a typed profile |
| Do not edit | src/profile.rs | Yesterday’s data contract remains unchanged |
After the edits:
src/├── main.rs # replace├── model.rs # create└── profile.rs # unchangedCreate src/model.rs
Section titled “Create src/model.rs”CREATE — src/model.rs — complete file:
use thiserror::Error;
use crate::profile::BusinessProfile;
pub trait ProfileModel { fn extract(&self, source: &str) -> Result<BusinessProfile, ModelError>;}
pub struct ScriptedProfileModel { response: Vec<u8>,}
impl ScriptedProfileModel { pub fn new(response: Vec<u8>) -> Self { Self { response } }}
impl ProfileModel for ScriptedProfileModel { fn extract(&self, _source: &str) -> Result<BusinessProfile, ModelError> { let profile: BusinessProfile = serde_json::from_slice(&self.response)?; profile.validate().map_err(ModelError::InvalidProfile)?; Ok(profile) }}
#[derive(Debug, Error)]pub enum ModelError { #[error("model output is not valid profile JSON")] Json(#[from] serde_json::Error),
#[error("model produced an invalid profile: {0}")] InvalidProfile(String),}The trait contains only what Brandforge needs. It does not expose a provider’s entire SDK.
The underscore in _source is intentional. A real model will use the source text. The scripted model
returns a saved response and names the parameter with _ so Rust knows the unused value is deliberate.
Replace src/main.rs
Section titled “Replace src/main.rs”REPLACE — src/main.rs — complete file:
mod model;mod profile;
use std::path::PathBuf;use std::process::ExitCode;
use clap::Parser;use model::{ProfileModel, ScriptedProfileModel};
#[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 source = String::from_utf8_lossy(&source_bytes);
let response_bytes = match std::fs::read(&cli.profile) { Ok(bytes) => bytes, Err(error) => { eprintln!("could not read `{}`: {error}", cli.profile.display()); return ExitCode::FAILURE; } };
let model = ScriptedProfileModel::new(response_bytes); let profile = match model.extract(&source) { Ok(profile) => profile, Err(error) => { eprintln!("profile model failed: {error}"); return ExitCode::FAILURE; } };
println!("model adapter: scripted fixture"); println!("accepted profile: {}", profile.name); println!("services: {}", profile.services.len());
ExitCode::SUCCESS}The first two lines connect filenames to modules:
mod model; // compiles src/model.rsmod profile; // compiles src/profile.rsThe use line brings the trait into scope. Without ProfileModel, Rust will not let you call the
trait’s extract method on ScriptedProfileModel.
Check and run
Section titled “Check and run”cargo fmtcargo checkcargo run -- \ --source fixtures/site.md \ --profile fixtures/business-profile.jsonExpected result:
model adapter: scripted fixtureaccepted profile: Northstar Gardensservices: 3Why the fixture comes first
Section titled “Why the fixture comes first”A live request combines several failure sources:
- credentials and network;
- provider and model release;
- prompt text;
- response decoding;
- profile validation.
The scripted adapter isolates the last two. Once those are green, a provider timeout cannot masquerade as a Rust struct problem.
Where live-provider code would go
Section titled “Where live-provider code would go”Do not paste the next block today. It is a file-location map for the optional extension.
READ ONLY — future additions:
src/model.rs # add OpenAiProfileModel and implement ProfileModelsrc/config.rs # create configuration that reads an environment variablesrc/main.rs # select scripted or live adapter from a CLI flagtests/provider_contract.rs # create a small ignored live contract testA live extract implementation must handle missing credentials, timeout, transport failure,
non-success response, refusal, incomplete output, malformed JSON, and domain-invalid JSON as different
errors. Do not hard-code an API key in any .rs file.
Break it deliberately
Section titled “Break it deliberately”Use the bad JSON file you created on Day 2, or create it now. Make the primary color "green", then
run:
cargo run -- \ --source fixtures/site.md \ --profile fixtures/business-profile-bad.jsonThe error should say the model produced an invalid profile. Next remove a closing brace so the file is malformed JSON. That error should instead say the model output is not valid profile JSON.
Those errors name different repair paths.
End-of-day checkpoint
Section titled “End-of-day checkpoint”cargo testcargo run -- \ --source fixtures/site.md \ --profile fixtures/business-profile.jsonYou now have three Rust files. Tomorrow you will replace src/profile.rs with the evidence-aware
checkpoint and replace src/main.rs with an audit command. src/model.rs remains unchanged.
Next: Day 4 — Ground Every Claim →.