use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContentRole {
Instruction,
Context,
Focus,
Constraint,
Example,
Schema,
}
impl ContentRole {
fn default_priority(self) -> u8 {
match self {
Self::Instruction => 255,
Self::Schema => 240,
Self::Focus => 192,
Self::Constraint => 200,
Self::Example => 100,
Self::Context => 128,
}
}
fn claude_order(self) -> u8 {
match self {
Self::Instruction => 0,
Self::Focus => 1,
Self::Context => 2,
Self::Example => 3,
Self::Constraint => 4,
Self::Schema => 5,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Block {
pub role: ContentRole,
pub content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(default = "Block::default_priority_value")]
pub priority: u8,
}
impl Block {
fn default_priority_value() -> u8 {
128
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Prompt {
pub blocks: Vec<Block>,
}
impl Prompt {
pub fn new() -> Self {
Self::default()
}
pub fn instruction(self, content: impl Into<String>) -> Self {
self.push_block(ContentRole::Instruction, content, None)
}
pub fn context(self, label: impl Into<String>, content: impl Into<String>) -> Self {
self.push_block(ContentRole::Context, content, Some(label.into()))
}
pub fn focus(self, label: impl Into<String>, content: impl Into<String>) -> Self {
self.push_block(ContentRole::Focus, content, Some(label.into()))
}
pub fn constraint(self, content: impl Into<String>) -> Self {
self.push_block(ContentRole::Constraint, content, None)
}
pub fn example(self, label: impl Into<String>, content: impl Into<String>) -> Self {
self.push_block(ContentRole::Example, content, Some(label.into()))
}
pub fn schema(self, schema: serde_json::Value) -> Self {
let json = serde_json::to_string_pretty(&schema).unwrap_or_default();
self.push_block(ContentRole::Schema, json, None)
}
pub fn schema_with_note(self, schema: serde_json::Value, note: impl Into<String>) -> Self {
let json = serde_json::to_string_pretty(&schema).unwrap_or_default();
let mut this = self.push_block(ContentRole::Schema, json, None);
if let Some(last) = this.blocks.last_mut() {
last.label = Some(note.into());
}
this
}
pub fn block(self, role: ContentRole, content: impl Into<String>) -> Self {
self.push_block(role, content, None)
}
pub fn labeled_block(
self,
role: ContentRole,
label: impl Into<String>,
content: impl Into<String>,
) -> Self {
self.push_block(role, content, Some(label.into()))
}
pub fn tag(mut self, tag: impl Into<String>) -> Self {
if let Some(last) = self.blocks.last_mut() {
last.tag = Some(tag.into());
}
self
}
pub fn priority(mut self, priority: u8) -> Self {
if let Some(last) = self.blocks.last_mut() {
last.priority = priority;
}
self
}
pub fn remove(mut self, tag: &str) -> Self {
self.blocks.retain(|b| b.tag.as_deref() != Some(tag));
self
}
pub fn merge(mut self, other: Prompt) -> Self {
self.blocks.extend(other.blocks);
self
}
pub fn trim_to_budget(&mut self, max_chars: usize) {
while self.estimated_len() > max_chars {
let candidate = self
.blocks
.iter()
.enumerate()
.filter(|(_, b)| {
!b.content.is_empty()
&& b.role != ContentRole::Instruction
&& b.role != ContentRole::Schema
})
.min_by(|(i_a, a), (i_b, b)| a.priority.cmp(&b.priority).then(i_b.cmp(i_a)))
.map(|(i, _)| i);
match candidate {
Some(idx) => {
self.blocks.remove(idx);
}
None => break, }
}
}
pub fn estimated_len(&self) -> usize {
let mut len = 0usize;
for block in &self.blocks {
if block.content.is_empty() {
continue;
}
len += block.content.len();
if let Some(ref label) = block.label {
len += label.len() + 6; }
len += 2; }
len
}
pub fn render(&self, strategy: &dyn PromptStrategy) -> String {
strategy.render(self)
}
pub fn render_default(&self) -> String {
CLAUDE_STRATEGY.render(self)
}
fn push_block(
mut self,
role: ContentRole,
content: impl Into<String>,
label: Option<String>,
) -> Self {
let content = content.into();
if content.trim().is_empty() {
return self; }
let priority = role.default_priority();
self.blocks.push(Block {
role,
content,
label,
tag: None,
priority,
});
self
}
}
impl fmt::Display for Prompt {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.render_default())
}
}
pub trait PromptStrategy: Send + Sync {
fn render(&self, prompt: &Prompt) -> String;
}
pub struct ClaudeStrategy;
static CLAUDE_STRATEGY: ClaudeStrategy = ClaudeStrategy;
impl PromptStrategy for ClaudeStrategy {
fn render(&self, prompt: &Prompt) -> String {
if prompt.blocks.is_empty() {
return String::new();
}
let mut sorted: Vec<&Block> = prompt
.blocks
.iter()
.filter(|b| !b.content.trim().is_empty())
.collect();
sorted.sort_by_key(|b| b.role.claude_order());
let mut parts: Vec<String> = Vec::new();
let mut constraints: Vec<&str> = Vec::new();
for block in &sorted {
match block.role {
ContentRole::Instruction => {
parts.push(block.content.clone());
}
ContentRole::Context => {
if let Some(ref label) = block.label {
parts.push(format!("## {}\n\n{}", label, block.content));
} else {
parts.push(block.content.clone());
}
}
ContentRole::Focus => {
if let Some(ref label) = block.label {
parts.push(format!("## {}\n\n{}", label, block.content));
} else {
parts.push(block.content.clone());
}
}
ContentRole::Example => {
if let Some(ref label) = block.label {
parts.push(format!("### Example: {}\n\n{}", label, block.content));
} else {
parts.push(format!("### Example\n\n{}", block.content));
}
}
ContentRole::Constraint => {
constraints.push(&block.content);
}
ContentRole::Schema => {
}
}
}
if !constraints.is_empty() {
let list = constraints
.iter()
.map(|c| format!("- {}", c))
.collect::<Vec<_>>()
.join("\n");
parts.push(format!("Rules:\n{}", list));
}
for block in &sorted {
if block.role == ContentRole::Schema {
let header = block
.label
.as_deref()
.unwrap_or("Return ONLY valid JSON (no markdown fences):");
parts.push(format!("{}\n{}", header, block.content));
}
}
parts.join("\n\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_prompt_renders_empty() {
assert_eq!(Prompt::new().to_string(), "");
}
#[test]
fn instruction_only() {
let p = Prompt::new().instruction("You are a helpful assistant.");
assert_eq!(p.to_string(), "You are a helpful assistant.");
}
#[test]
fn ordering_instructions_first_schema_last() {
let p = Prompt::new()
.schema(serde_json::json!({"answer": "string"}))
.context("Data", "Some data here")
.instruction("Be concise.");
let rendered = p.to_string();
let inst_pos = rendered.find("Be concise.").unwrap();
let ctx_pos = rendered.find("Some data here").unwrap();
let schema_pos = rendered.find("\"answer\"").unwrap();
assert!(inst_pos < ctx_pos, "instruction should come before context");
assert!(ctx_pos < schema_pos, "context should come before schema");
}
#[test]
fn constraints_collected_into_rules_block() {
let p = Prompt::new()
.constraint("Do not hallucinate")
.constraint("Be concise");
let rendered = p.to_string();
assert!(rendered.contains("Rules:"));
assert!(rendered.contains("- Do not hallucinate"));
assert!(rendered.contains("- Be concise"));
}
#[test]
fn context_with_label() {
let p = Prompt::new().context("User Profile", "Name: Alice");
assert!(p.to_string().contains("## User Profile\n\nName: Alice"));
}
#[test]
fn focus_with_label() {
let p = Prompt::new().focus("Current Task", "Debug the auth module");
assert!(p
.to_string()
.contains("## Current Task\n\nDebug the auth module"));
}
#[test]
fn example_with_label() {
let p = Prompt::new().example("Good response", "Here is how...");
assert!(p
.to_string()
.contains("### Example: Good response\n\nHere is how..."));
}
#[test]
fn schema_renders_json() {
let p = Prompt::new().schema(serde_json::json!({"score": "float"}));
let rendered = p.to_string();
assert!(rendered.contains("Return ONLY valid JSON"));
assert!(rendered.contains("\"score\": \"float\""));
}
#[test]
fn schema_with_note() {
let p = Prompt::new().schema_with_note(serde_json::json!({"x": 1}), "Output format:");
let rendered = p.to_string();
assert!(rendered.starts_with("Output format:"));
assert!(rendered.contains("\"x\": 1"));
}
#[test]
fn empty_content_skipped() {
let p = Prompt::new()
.instruction("Hello")
.context("Empty", "")
.context("Also empty", " ");
assert_eq!(p.to_string(), "Hello");
}
#[test]
fn tag_and_remove() {
let p = Prompt::new()
.context("Keep", "important")
.tag("keep")
.context("Drop", "unimportant")
.tag("drop");
let p = p.remove("drop");
let rendered = p.to_string();
assert!(rendered.contains("important"));
assert!(!rendered.contains("unimportant"));
}
#[test]
fn priority_override() {
let p = Prompt::new()
.context("Low", "low priority data")
.priority(10)
.context("High", "high priority data")
.priority(250);
let mut p = p;
p.trim_to_budget(40);
let rendered = p.to_string();
assert!(!rendered.contains("low priority data") || rendered.contains("high priority data"));
}
#[test]
fn trim_to_budget_never_trims_instructions() {
let mut p = Prompt::new()
.instruction("Critical instruction")
.context("Verbose context", &"x".repeat(1000));
p.trim_to_budget(100);
let rendered = p.to_string();
assert!(rendered.contains("Critical instruction"));
assert!(!rendered.contains(&"x".repeat(1000)));
}
#[test]
fn trim_to_budget_never_trims_schema() {
let mut p = Prompt::new()
.schema(serde_json::json!({"required": "field"}))
.context("Verbose", &"y".repeat(1000));
p.trim_to_budget(100);
let rendered = p.to_string();
assert!(rendered.contains("required"));
assert!(!rendered.contains(&"y".repeat(1000)));
}
#[test]
fn merge_combines_blocks() {
let a = Prompt::new().instruction("A");
let b = Prompt::new().instruction("B");
let merged = a.merge(b);
assert_eq!(merged.blocks.len(), 2);
}
#[test]
fn serde_roundtrip() {
let p = Prompt::new()
.instruction("Test")
.context("Data", "value")
.constraint("No bad things");
let json = serde_json::to_string(&p).unwrap();
let p2: Prompt = serde_json::from_str(&json).unwrap();
assert_eq!(p.blocks.len(), p2.blocks.len());
assert_eq!(p.to_string(), p2.to_string());
}
#[test]
fn plain_string_fallback() {
let result = serde_json::from_str::<Prompt>("\"just a string\"");
assert!(result.is_err());
}
#[test]
fn python_json_roundtrip() {
let python_json = r#"{"blocks": [{"role": "instruction", "content": "You are a test system.", "priority": 255}, {"role": "context", "content": "Some context data here", "label": "Data", "priority": 128}, {"role": "focus", "content": "Do the thing", "label": "Task", "priority": 192}, {"role": "constraint", "content": "Be concise", "priority": 200}, {"role": "schema", "content": "{\n \"result\": \"string\"\n}", "priority": 240}]}"#;
let prompt: Prompt =
serde_json::from_str(python_json).expect("Rust must deserialize Python's Prompt JSON");
assert_eq!(prompt.blocks.len(), 5);
let rendered = prompt.to_string();
assert!(
rendered.contains("You are a test system."),
"instruction missing"
);
assert!(
rendered.contains("Some context data here"),
"context missing"
);
assert!(rendered.contains("Do the thing"), "focus missing");
assert!(rendered.contains("Be concise"), "constraint missing");
assert!(
rendered.contains("\"result\": \"string\""),
"schema missing"
);
let inst = rendered.find("You are a test system.").unwrap();
let focus = rendered.find("Do the thing").unwrap();
let ctx = rendered.find("Some context data here").unwrap();
let rules = rendered.find("Rules:").unwrap();
let schema = rendered.find("Return ONLY valid JSON").unwrap();
assert!(inst < focus, "instruction should precede focus");
assert!(focus < ctx, "focus should precede context");
assert!(ctx < rules, "context should precede rules");
assert!(rules < schema, "rules should precede schema");
let expected = "You are a test system.\n\n\
## Task\n\n\
Do the thing\n\n\
## Data\n\n\
Some context data here\n\n\
Rules:\n\
- Be concise\n\n\
Return ONLY valid JSON (no markdown fences):\n\
{\n \"result\": \"string\"\n}";
assert_eq!(
rendered, expected,
"Rust rendering must match Python fallback"
);
}
#[test]
fn full_prompt_example() {
let p = Prompt::new()
.instruction("You are a code reviewer.")
.context("File", "src/main.rs: fn main() { ... }")
.focus("Issue", "Potential null pointer dereference on line 42")
.constraint("Do not suggest rewriting the entire function")
.constraint("Keep suggestions under 3 lines")
.example(
"Good review",
"Line 42: Add a null check before dereferencing",
)
.schema(serde_json::json!({
"severity": "low|medium|high",
"line": "integer",
"suggestion": "string",
"reasoning": "string"
}));
let rendered = p.to_string();
let positions: Vec<usize> = [
"You are a code reviewer",
"Potential null pointer",
"src/main.rs",
"Good review",
"Rules:",
"Return ONLY valid JSON",
]
.iter()
.map(|s| rendered.find(s).expect(&format!("missing: {}", s)))
.collect();
for i in 1..positions.len() {
assert!(
positions[i - 1] < positions[i],
"ordering violated at index {}",
i
);
}
}
}