Skip to content

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.

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

Do not continue until the Day 2 tests pass.

ActionFilePurpose
CREATEsrc/model.rsDefine ProfileModel and the offline scripted adapter
REPLACEsrc/main.rsAsk the adapter for a typed profile
Do not editsrc/profile.rsYesterday’s data contract remains unchanged

After the edits:

src/
├── main.rs # replace
├── model.rs # create
└── profile.rs # unchanged

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 — 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.rs
mod profile; // compiles src/profile.rs

The use line brings the trait into scope. Without ProfileModel, Rust will not let you call the trait’s extract method on ScriptedProfileModel.

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

Expected result:

model adapter: scripted fixture
accepted profile: Northstar Gardens
services: 3

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.

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 ProfileModel
src/config.rs # create configuration that reads an environment variable
src/main.rs # select scripted or live adapter from a CLI flag
tests/provider_contract.rs # create a small ignored live contract test

A 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.

Use the bad JSON file you created on Day 2, or create it now. Make the primary color "green", then run:

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

The 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.

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

You 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 →.