use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Recipe {
pub from: String,
#[serde(default)]
pub system: Option<String>,
#[serde(default)]
pub template: Option<String>,
#[serde(default)]
pub parameters: Option<RecipeParameters>,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RecipeParameters {
#[serde(default)]
pub temperature: Option<f32>,
#[serde(default)]
pub top_p: Option<f32>,
#[serde(default)]
pub top_k: Option<u32>,
#[serde(default)]
pub num_ctx: Option<u32>,
#[serde(default)]
pub stop: Option<Vec<String>>,
}
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum RecipeError {
#[error("recipe YAML parse error: {0}")]
Parse(String),
#[error("recipe.from is required and must be a non-empty string")]
MissingFrom,
}
pub fn parse_recipe(src: &str) -> Result<Recipe, RecipeError> {
let r: Recipe = serde_yaml::from_str(src).map_err(|e| RecipeError::Parse(e.to_string()))?;
if r.from.trim().is_empty() {
return Err(RecipeError::MissingFrom);
}
Ok(r)
}
#[derive(Debug, Clone, PartialEq)]
pub struct RenderedMessage {
pub role: String,
pub content: String,
}
pub fn render_messages(
recipe: &Recipe,
user_prompt: &str,
cli_system_override: Option<&str>,
) -> Vec<RenderedMessage> {
let mut out = Vec::with_capacity(2);
let effective_system = cli_system_override
.filter(|s| !s.is_empty())
.or(recipe.system.as_deref())
.filter(|s| !s.is_empty());
if let Some(system) = effective_system {
out.push(RenderedMessage {
role: "system".to_string(),
content: system.to_string(),
});
}
out.push(RenderedMessage {
role: "user".to_string(),
content: user_prompt.to_string(),
});
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn minimal_recipe_parses() {
let r = parse_recipe("from: hf://Qwen/Qwen2.5-Coder-7B").unwrap();
assert_eq!(r.from, "hf://Qwen/Qwen2.5-Coder-7B");
assert_eq!(r.system, None);
assert_eq!(r.template, None);
assert_eq!(r.parameters, None);
}
#[test]
fn full_recipe_parses() {
let src = r#"
from: "hf://unsloth/Qwen2.5-Coder-7B-Instruct-GGUF"
system: "You are a Rust expert. Always answer in one sentence."
template: "{{ messages }}"
parameters:
temperature: 0.2
top_p: 0.9
top_k: 40
num_ctx: 8192
stop:
- "</s>"
- "<|im_end|>"
"#;
let r = parse_recipe(src).unwrap();
assert_eq!(r.from, "hf://unsloth/Qwen2.5-Coder-7B-Instruct-GGUF");
assert_eq!(
r.system.as_deref(),
Some("You are a Rust expert. Always answer in one sentence.")
);
let p = r.parameters.unwrap();
assert_eq!(p.temperature, Some(0.2));
assert_eq!(p.top_p, Some(0.9));
assert_eq!(p.top_k, Some(40));
assert_eq!(p.num_ctx, Some(8192));
assert_eq!(
p.stop.unwrap(),
vec!["</s>".to_string(), "<|im_end|>".to_string()]
);
}
#[test]
fn unknown_top_level_key_rejected() {
let src = "from: x\ntemprature: 0.5\n";
let err = parse_recipe(src).unwrap_err();
assert!(
matches!(err, RecipeError::Parse(_)),
"expected Parse err for unknown key, got {err:?}"
);
}
#[test]
fn unknown_parameters_key_rejected() {
let src = "from: x\nparameters:\n temprature: 0.5\n";
let err = parse_recipe(src).unwrap_err();
assert!(
matches!(err, RecipeError::Parse(_)),
"expected Parse err for unknown parameters key, got {err:?}"
);
}
#[test]
fn missing_from_rejected() {
let src = "system: You are helpful.\n";
assert!(matches!(
parse_recipe(src),
Err(RecipeError::Parse(_)) | Err(RecipeError::MissingFrom)
));
}
#[test]
fn empty_from_rejected() {
let src = "from: ''\nsystem: x\n";
assert_eq!(parse_recipe(src).unwrap_err(), RecipeError::MissingFrom);
}
#[test]
fn wrong_type_for_temperature_rejected() {
let src = "from: x\nparameters:\n temperature: hot\n";
assert!(matches!(parse_recipe(src), Err(RecipeError::Parse(_))));
}
fn recipe_with_system(system: Option<&str>) -> Recipe {
Recipe {
from: "hf://x/y".to_string(),
system: system.map(str::to_string),
template: None,
parameters: None,
}
}
#[test]
fn system_prompt_injected_at_position_zero() {
let r = recipe_with_system(Some("You are a Rust expert."));
let msgs = render_messages(&r, "What is ownership?", None);
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].role, "system");
assert_eq!(msgs[0].content, "You are a Rust expert.");
assert_eq!(msgs[1].role, "user");
assert_eq!(msgs[1].content, "What is ownership?");
}
#[test]
fn no_system_means_no_system_message() {
let r = recipe_with_system(None);
let msgs = render_messages(&r, "hi", None);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].role, "user");
assert_eq!(msgs[0].content, "hi");
}
#[test]
fn empty_recipe_system_treated_as_none() {
let r = recipe_with_system(Some(""));
let msgs = render_messages(&r, "hi", None);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].role, "user");
}
#[test]
fn cli_override_wins_over_recipe_system() {
let r = recipe_with_system(Some("recipe says be verbose"));
let msgs = render_messages(&r, "hi", Some("CLI says be terse"));
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].role, "system");
assert_eq!(msgs[0].content, "CLI says be terse");
}
#[test]
fn empty_cli_override_falls_back_to_recipe_system() {
let r = recipe_with_system(Some("recipe system"));
let msgs = render_messages(&r, "hi", Some(""));
assert_eq!(msgs[0].content, "recipe system");
}
#[test]
fn cli_override_with_no_recipe_system_still_injects() {
let r = recipe_with_system(None);
let msgs = render_messages(&r, "hi", Some("cli system"));
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].role, "system");
assert_eq!(msgs[0].content, "cli system");
}
#[test]
fn rendered_messages_preserve_user_prompt_verbatim() {
let r = recipe_with_system(Some("sys"));
let weird = "line1\nline2\twith tab\n🎉 emoji";
let msgs = render_messages(&r, weird, None);
assert_eq!(msgs.last().unwrap().content, weird);
}
#[test]
fn render_is_deterministic() {
let r = recipe_with_system(Some("sys"));
let a = render_messages(&r, "hi", None);
let b = render_messages(&r, "hi", None);
assert_eq!(a, b);
}
#[test]
fn render_never_emits_more_than_two_messages() {
let cases = [
(None, None),
(Some("sys"), None),
(None, Some("cli")),
(Some("sys"), Some("cli")),
];
for (recipe_sys, cli) in cases {
let r = recipe_with_system(recipe_sys);
let msgs = render_messages(&r, "hi", cli);
assert!(msgs.len() <= 2, "too many messages: {msgs:?}");
}
}
#[test]
fn system_message_always_precedes_user_message_when_present() {
for sys in [Some("s"), None] {
let r = recipe_with_system(sys);
let msgs = render_messages(&r, "u", None);
if let Some(system_pos) = msgs.iter().position(|m| m.role == "system") {
let user_pos = msgs
.iter()
.position(|m| m.role == "user")
.expect("user message always present");
assert!(system_pos < user_pos, "system must precede user: {msgs:?}");
}
}
}
}