use crate::context::ToolContext;
use crate::path::AllowedPathResolver;
struct ContextEntry {
name: &'static str,
context: &'static str,
}
#[derive(Default)]
pub struct SystemPromptBuilder {
entries: Vec<ContextEntry>,
working_directory: Option<String>,
allowed_paths: Option<Vec<String>>,
supplemental: Vec<(&'static str, &'static str)>,
system_prompt: Option<String>,
}
impl SystemPromptBuilder {
#[inline]
pub fn new() -> Self {
Self::default()
}
pub fn track<T: ToolContext>(&mut self, tool: T) -> T {
self.entries.push(ContextEntry {
name: T::NAME,
context: tool.context(),
});
tool
}
#[inline]
pub fn add_context(mut self, name: &'static str, context: &'static str) -> Self {
self.supplemental.push((name, context));
self
}
#[inline]
pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
self.system_prompt = Some(prompt.into());
self
}
}
impl SystemPromptBuilder {
#[inline]
pub fn working_directory(mut self, path: impl Into<String>) -> Self {
self.working_directory = Some(path.into());
self
}
#[inline]
pub fn allowed_paths(mut self, resolver: &AllowedPathResolver) -> Self {
self.allowed_paths = Some(
resolver
.allowed_paths()
.iter()
.map(|p| p.display().to_string())
.collect(),
);
self
}
}
#[inline]
fn section_separator(s: &str) -> &'static str {
if s.ends_with("\n\n") {
""
} else if s.ends_with('\n') {
"\n"
} else {
"\n\n"
}
}
impl SystemPromptBuilder {
pub fn build(self) -> String {
const ENV_HEADER_SIZE: usize = 50;
const ALLOWED_DIR_PER_ITEM: usize = 25;
let system_prompt_size = self.system_prompt.as_ref().map_or(0, |p| p.len() + 2);
let env_size = if self.working_directory.is_some() || self.allowed_paths.is_some() {
ENV_HEADER_SIZE + self.working_directory.as_ref().map_or(0, |d| d.len())
} else if self.system_prompt.is_some()
|| !self.entries.is_empty()
|| !self.supplemental.is_empty()
{
ENV_HEADER_SIZE
} else {
0
};
let allowed_size = self.allowed_paths.as_ref().map_or(0, |paths| {
paths.iter().map(|p| p.len() + ALLOWED_DIR_PER_ITEM).sum()
});
let tools_size: usize = self
.entries
.iter()
.map(|e| e.context.len() + e.name.len() + 20)
.sum();
let supplemental_size: usize = self
.supplemental
.iter()
.map(|(n, c)| c.len() + n.len() + 20)
.sum();
let has_tools = !self.entries.is_empty();
let has_supplemental = !self.supplemental.is_empty();
let has_system_prompt = self.system_prompt.is_some();
let has_env_content = self.working_directory.is_some() || self.allowed_paths.is_some();
let total_size =
system_prompt_size + env_size + allowed_size + tools_size + supplemental_size + 90;
let mut output = String::with_capacity(total_size);
if !has_tools && !has_supplemental && !has_system_prompt && !has_env_content {
return String::new();
}
if let Some(ref prompt) = self.system_prompt {
output.push_str(prompt);
output.push_str(section_separator(prompt));
}
if has_env_content || has_system_prompt || has_tools || has_supplemental {
output.push_str("# Environment\n\n");
if let Some(ref dir) = self.working_directory {
output.push_str("Working directory: ");
output.push_str(dir);
output.push('\n');
}
if let Some(ref paths) = self.allowed_paths {
output.push_str("Allowed directories:\n");
for path in paths {
output.push_str("- ");
output.push_str(path);
output.push('\n');
}
}
if (has_tools || has_supplemental) && has_env_content {
if !output.ends_with('\n') {
output.push('\n');
}
output.push('\n');
}
}
if has_tools {
output.push_str("# Tool Usage Guidelines\n\n");
for entry in self.entries {
output.push_str("## `");
let mut chars = entry.name.chars();
if let Some(first) = chars.next() {
output.push(first.to_ascii_uppercase());
output.push_str(chars.as_str());
} else {
output.push_str(entry.name);
}
output.push_str("` Tool\n");
output.push_str(entry.context);
if !entry.context.ends_with('\n') {
output.push('\n');
}
}
}
if has_supplemental {
output.push_str("\n# Supplemental Context\n");
for (name, context) in self.supplemental {
output.push_str("## ");
output.push_str(name);
output.push('\n');
output.push_str(context);
if !context.ends_with('\n') {
output.push('\n');
}
}
}
output.truncate(output.trim_end().len());
output
}
}
pub trait Substitute {
fn substitute(self, key: &str, value: &str) -> String;
fn substitute_all<'a>(
self,
substitutions: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> String;
}
impl Substitute for String {
#[inline]
fn substitute(self, key: &str, value: &str) -> String {
let placeholder = format!("{{{}}}", key);
self.replace(&placeholder, value)
}
fn substitute_all<'a>(
mut self,
substitutions: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> String {
for (key, value) in substitutions {
let placeholder = format!("{{{}}}", key);
self = self.replace(&placeholder, value);
}
self
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MockTool {
id: u32,
}
impl ToolContext for MockTool {
const NAME: &'static str = "mock";
fn context(&self) -> &'static str {
"Mock tool context."
}
}
struct OtherTool;
impl ToolContext for OtherTool {
const NAME: &'static str = "other";
fn context(&self) -> &'static str {
"Other context."
}
}
#[test]
fn empty_builder_returns_empty_string() {
let preamble = SystemPromptBuilder::new().build();
assert!(preamble.is_empty());
}
#[test]
fn track_returns_tool_unchanged() {
let mut pb = SystemPromptBuilder::new();
let tool = MockTool { id: 42 };
let returned = pb.track(tool);
assert_eq!(returned.id, 42);
}
#[test]
fn single_tool_formats_correctly() {
let mut pb = SystemPromptBuilder::new().working_directory("/home/user");
let _ = pb.track(MockTool { id: 1 });
let preamble = pb.build();
assert!(preamble.contains("# Environment"));
assert!(preamble.contains("Working directory: /home/user"));
assert!(preamble.contains("# Tool Usage Guidelines"));
assert!(preamble.contains("## `Mock` Tool"));
assert!(preamble.contains("Mock tool context."));
}
#[test]
fn multiple_tools_preserve_order() {
let mut pb = SystemPromptBuilder::new().working_directory("/home/user");
let _ = pb.track(MockTool { id: 1 });
let _ = pb.track(OtherTool);
let preamble = pb.build();
let mock_pos = preamble.find("## `Mock` Tool").unwrap();
let other_pos = preamble.find("## `Other` Tool").unwrap();
assert!(
mock_pos < other_pos,
"Tools should appear in insertion order"
);
}
#[test]
fn multiple_tools_have_single_newline_between() {
let mut pb = SystemPromptBuilder::new().working_directory("/home/user");
let _ = pb.track(MockTool { id: 1 });
let _ = pb.track(OtherTool);
let preamble = pb.build();
assert!(
preamble.contains("Mock tool context.\n## `Other` Tool"),
"Expected single newline between tool sections.\nGot:\n{preamble}"
);
assert!(
preamble.contains("## `Mock` Tool\nMock tool context."),
"Expected single newline after tool header.\nGot:\n{preamble}"
);
assert!(
preamble.contains("# Tool Usage Guidelines\n\n## `Mock` Tool"),
"Expected blank line after section header.\nGot:\n{preamble}"
);
assert_eq!(
preamble,
preamble.trim_end(),
"Preamble has trailing whitespace"
);
}
#[test]
fn multiple_tools_with_working_dir_have_single_newline_between() {
let mut pb = SystemPromptBuilder::new().working_directory("/test");
let _ = pb.track(MockTool { id: 1 });
let _ = pb.track(OtherTool);
let preamble = pb.build();
assert!(
preamble.contains("Mock tool context.\n## `Other` Tool"),
"Expected single newline between tool sections.\nGot:\n{preamble}"
);
assert!(
preamble.contains("## `Mock` Tool\nMock tool context."),
"Expected single newline after tool header.\nGot:\n{preamble}"
);
assert!(
preamble.contains("# Environment\n\nWorking directory:"),
"Expected blank line after Environment header.\nGot:\n{preamble}"
);
assert!(
preamble.contains("# Tool Usage Guidelines\n\n## `Mock` Tool"),
"Expected blank line after section header.\nGot:\n{preamble}"
);
assert_eq!(
preamble,
preamble.trim_end(),
"Preamble has trailing whitespace"
);
}
#[test]
fn builder_includes_environment_section() {
let mut pb = SystemPromptBuilder::new().working_directory("/home/user/project");
let _ = pb.track(MockTool { id: 1 });
let preamble = pb.build();
assert!(preamble.contains("# Environment"));
assert!(preamble.contains("Working directory: /home/user/project"));
let env_pos = preamble.find("# Environment").unwrap();
let tools_pos = preamble.find("# Tool Usage Guidelines").unwrap();
assert!(env_pos < tools_pos);
}
#[test]
fn builder_without_env_data_and_tools_returns_empty() {
let pb = SystemPromptBuilder::new();
let preamble = pb.build();
assert!(preamble.is_empty());
}
#[test]
fn builder_with_working_dir_but_no_tools() {
let pb = SystemPromptBuilder::new().working_directory("/home/user/project");
let preamble = pb.build();
assert!(preamble.contains("# Environment"));
assert!(preamble.contains("Working directory: /home/user/project"));
assert!(!preamble.contains("# Tool Usage Guidelines"));
}
#[test]
fn working_directory_accepts_runtime_string() {
let runtime_path = String::from("/runtime/computed/path");
let pb = SystemPromptBuilder::new().working_directory(runtime_path);
let preamble = pb.build();
assert!(preamble.contains("Working directory: /runtime/computed/path"));
}
#[test]
fn working_directory_accepts_str() {
let pb = SystemPromptBuilder::new().working_directory("/static/path");
let preamble = pb.build();
assert!(preamble.contains("Working directory: /static/path"));
}
#[test]
fn substitute_replaces_single_placeholder() {
use super::Substitute;
let text = "Hello {name}!".to_string();
let result = text.substitute("name", "World");
assert_eq!(result, "Hello World!");
}
#[test]
fn substitute_leaves_unmatched_placeholders() {
use super::Substitute;
let text = "Hello {name}, welcome to {place}!".to_string();
let result = text.substitute("name", "Alice");
assert_eq!(result, "Hello Alice, welcome to {place}!");
}
#[test]
fn substitute_handles_empty_value() {
use super::Substitute;
let text = "Prefix{middle}Suffix".to_string();
let result = text.substitute("middle", "");
assert_eq!(result, "PrefixSuffix");
}
#[test]
fn substitute_all_replaces_multiple() {
use super::Substitute;
let text = "Hello {name}, welcome to {place}!".to_string();
let result = text.substitute_all([("name", "Alice"), ("place", "Wonderland")]);
assert_eq!(result, "Hello Alice, welcome to Wonderland!");
}
#[test]
fn substitute_no_placeholder_returns_unchanged() {
use super::Substitute;
let text = "No placeholders here".to_string();
let result = text.substitute("missing", "value");
assert_eq!(result, "No placeholders here");
}
#[test]
fn default_builder_compiles() {
let _pb_default: SystemPromptBuilder = SystemPromptBuilder::new();
}
#[test]
fn backwards_compatibility_existing_api() {
let mut pb = SystemPromptBuilder::new();
let _ = pb.track(MockTool { id: 1 });
let preamble = pb.build();
assert!(preamble.contains("# Tool Usage Guidelines"));
assert!(preamble.contains("## `Mock` Tool"));
}
#[test]
fn builder_with_allowed_paths_shows_paths() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let resolver = AllowedPathResolver::new(vec![dir.path()]).unwrap();
let pb = SystemPromptBuilder::new()
.working_directory("/home/user")
.allowed_paths(&resolver);
let preamble = pb.build();
assert!(preamble.contains("# Environment"));
assert!(preamble.contains("Working directory: /home/user"));
assert!(preamble.contains("Allowed directories:"));
assert!(preamble.contains(&dir.path().canonicalize().unwrap().display().to_string()));
}
#[test]
fn builder_with_only_allowed_paths_no_working_dir() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let resolver = AllowedPathResolver::new(vec![dir.path()]).unwrap();
let pb = SystemPromptBuilder::new().allowed_paths(&resolver);
let preamble = pb.build();
assert!(preamble.contains("# Environment"));
assert!(!preamble.contains("Working directory:"));
assert!(preamble.contains("Allowed directories:"));
}
#[test]
fn allowed_paths_format_is_bulleted_absolute_paths() {
use std::path::Path;
use tempfile::TempDir;
let dir1 = TempDir::new().unwrap();
let dir2 = TempDir::new().unwrap();
let resolver = AllowedPathResolver::new(vec![dir1.path(), dir2.path()]).unwrap();
let pb = SystemPromptBuilder::new().allowed_paths(&resolver);
let preamble = pb.build();
let lines: Vec<&str> = preamble.lines().collect();
let allowed_idx = lines
.iter()
.position(|l| l.contains("Allowed directories"))
.unwrap();
for i in 1..=2 {
let line = lines[allowed_idx + i];
assert!(
line.starts_with("- "),
"Line should start with '- ': {}",
line
);
let path_str = line.strip_prefix("- ").unwrap();
assert!(
Path::new(path_str).is_absolute(),
"Path should be absolute: {}",
path_str
);
}
}
#[test]
fn allowed_paths_appears_after_working_directory() {
use tempfile::TempDir;
let dir = TempDir::new().unwrap();
let resolver = AllowedPathResolver::new(vec![dir.path()]).unwrap();
let pb = SystemPromptBuilder::new()
.working_directory("/home/user")
.allowed_paths(&resolver);
let preamble = pb.build();
let working_dir_pos = preamble.find("Working directory:").unwrap();
let allowed_pos = preamble.find("Allowed directories:").unwrap();
assert!(
working_dir_pos < allowed_pos,
"Working directory should appear before allowed paths"
);
}
#[test]
fn builder_with_only_working_dir_no_allowed_paths() {
let pb = SystemPromptBuilder::new().working_directory("/home/user/project");
let preamble = pb.build();
assert!(preamble.contains("# Environment"));
assert!(preamble.contains("Working directory: /home/user/project"));
assert!(
!preamble.contains("Allowed directories:"),
"Should not render Allowed directories when not explicitly set"
);
}
#[test]
fn add_context_includes_supplemental_section() {
let pb = SystemPromptBuilder::new()
.working_directory("/home/user")
.add_context("Git Workflow", "Git guidance content.");
let preamble = pb.build();
assert!(preamble.contains("# Supplemental Context"));
assert!(preamble.contains("## Git Workflow"));
assert!(preamble.contains("Git guidance content."));
}
#[test]
fn add_context_appears_after_tools() {
let mut pb = SystemPromptBuilder::new().add_context("Git Workflow", "Git guidance.");
let _ = pb.track(MockTool { id: 1 });
let preamble = pb.build();
let tools_pos = preamble.find("# Tool Usage Guidelines").unwrap();
let supplemental_pos = preamble.find("# Supplemental Context").unwrap();
assert!(
tools_pos < supplemental_pos,
"Tools should appear before supplemental context"
);
}
#[test]
fn add_context_multiple_sections_preserve_order() {
let pb = SystemPromptBuilder::new()
.working_directory("/home/user")
.add_context("Git Workflow", "Git content.")
.add_context("GitHub CLI", "GitHub content.");
let preamble = pb.build();
let git_pos = preamble.find("## Git Workflow").unwrap();
let github_pos = preamble.find("## GitHub CLI").unwrap();
assert!(
git_pos < github_pos,
"Contexts should appear in insertion order"
);
}
#[test]
fn add_context_only_no_tools() {
let pb = SystemPromptBuilder::new()
.working_directory("/home/user")
.add_context("Git Workflow", "Git guidance.");
let preamble = pb.build();
assert!(!preamble.contains("# Tool Usage Guidelines"));
assert!(preamble.contains("# Supplemental Context"));
assert!(preamble.contains("## Git Workflow"));
}
#[test]
fn add_context_with_env_section() {
let pb = SystemPromptBuilder::new()
.working_directory("/home/user")
.add_context("Git Workflow", "Git guidance.");
let preamble = pb.build();
let env_pos = preamble.find("# Environment").unwrap();
let supplemental_pos = preamble.find("# Supplemental Context").unwrap();
assert!(env_pos < supplemental_pos);
}
#[test]
fn add_context_with_env_and_tools() {
let mut pb = SystemPromptBuilder::new()
.working_directory("/home/user")
.add_context("Git Workflow", "Git guidance.");
let _ = pb.track(MockTool { id: 1 });
let preamble = pb.build();
let env_pos = preamble.find("# Environment").unwrap();
let tools_pos = preamble.find("# Tool Usage Guidelines").unwrap();
let supplemental_pos = preamble.find("# Supplemental Context").unwrap();
assert!(env_pos < tools_pos);
assert!(tools_pos < supplemental_pos);
}
#[test]
fn add_context_no_triple_newlines() {
let mut pb = SystemPromptBuilder::new()
.working_directory("/home/user")
.add_context("Git Workflow", "Git guidance.\n");
let _ = pb.track(MockTool { id: 1 });
let preamble = pb.build();
assert!(
!preamble.contains("\n\n\n"),
"Found triple newline in preamble.\nGot:\n{preamble}"
);
}
#[test]
fn add_context_chains_fluently() {
let pb = SystemPromptBuilder::new()
.add_context("A", "a")
.add_context("B", "b")
.add_context("C", "c");
let preamble = pb.build();
assert!(preamble.contains("## A"));
assert!(preamble.contains("## B"));
assert!(preamble.contains("## C"));
}
#[test]
fn add_context_with_actual_git_workflow_constant() {
use crate::context::GIT_WORKFLOW;
let pb = SystemPromptBuilder::new()
.working_directory("/home/user")
.add_context("Git Workflow", GIT_WORKFLOW);
let preamble = pb.build();
assert!(preamble.contains("# Supplemental Context"));
assert!(preamble.contains("## Git Workflow"));
assert!(
preamble.contains("Only create commits when requested"),
"Should contain git commit workflow content"
);
assert!(
preamble.contains("Git Safety Protocol"),
"Should contain safety protocol section"
);
}
#[test]
fn add_context_with_actual_github_cli_constant() {
use crate::context::GITHUB_CLI;
let pb = SystemPromptBuilder::new()
.working_directory("/home/user")
.add_context("GitHub CLI", GITHUB_CLI);
let preamble = pb.build();
assert!(preamble.contains("# Supplemental Context"));
assert!(preamble.contains("## GitHub CLI"));
assert!(
preamble.contains("gh pr create"),
"Should contain gh pr create example"
);
}
#[test]
fn add_context_selective_inclusion_git_only() {
use crate::context::{GITHUB_CLI, GIT_WORKFLOW};
let pb = SystemPromptBuilder::new()
.working_directory("/home/user")
.add_context("Git Workflow", GIT_WORKFLOW);
let preamble = pb.build();
assert!(preamble.contains("## Git Workflow"));
assert!(!preamble.contains("## GitHub CLI"));
assert!(!preamble.contains(GITHUB_CLI));
}
#[test]
fn add_context_both_git_and_github() {
use crate::context::{GITHUB_CLI, GIT_WORKFLOW};
let pb = SystemPromptBuilder::new()
.working_directory("/home/user")
.add_context("Git Workflow", GIT_WORKFLOW)
.add_context("GitHub CLI", GITHUB_CLI);
let preamble = pb.build();
assert!(preamble.contains("## Git Workflow"));
assert!(preamble.contains("## GitHub CLI"));
let git_pos = preamble.find("## Git Workflow").unwrap();
let github_pos = preamble.find("## GitHub CLI").unwrap();
assert!(
git_pos < github_pos,
"Git Workflow should appear before GitHub CLI"
);
}
#[test]
fn system_prompt_appears_first() {
let pb = SystemPromptBuilder::new()
.system_prompt("# System Instructions\n\nYou are a helpful assistant.")
.working_directory("/home/user");
let preamble = pb.build();
assert!(
preamble.starts_with("# System Instructions"),
"System prompt should appear first.\nGot:\n{preamble}"
);
let system_pos = preamble.find("# System Instructions").unwrap();
let env_pos = preamble.find("# Environment").unwrap();
assert!(
system_pos < env_pos,
"System prompt should appear before environment section"
);
}
#[test]
fn system_prompt_appears_before_tools() {
let mut pb =
SystemPromptBuilder::new().system_prompt("# Custom Header\n\nMy custom instructions.");
let _ = pb.track(MockTool { id: 1 });
let preamble = pb.build();
let system_pos = preamble.find("# Custom Header").unwrap();
let tools_pos = preamble.find("# Tool Usage Guidelines").unwrap();
assert!(
system_pos < tools_pos,
"System prompt should appear before tools section"
);
}
#[test]
fn system_prompt_no_modification() {
let custom = "My custom content without header";
let pb = SystemPromptBuilder::new().system_prompt(custom);
let preamble = pb.build();
assert!(
preamble.starts_with("My custom content without header"),
"System prompt should not be modified.\nGot:\n{preamble}"
);
}
#[test]
fn system_prompt_optional_default_behavior() {
let mut pb = SystemPromptBuilder::new();
let _ = pb.track(MockTool { id: 1 });
let preamble = pb.build();
assert!(
preamble.starts_with("# Environment"),
"Without system prompt, should start with Environment.\nGot:\n{preamble}"
);
}
#[test]
fn system_prompt_only_produces_output() {
let pb = SystemPromptBuilder::new()
.system_prompt("# Just Instructions\n\nOnly system prompt, no tools.");
let preamble = pb.build();
assert!(!preamble.is_empty());
assert!(preamble.contains("# Just Instructions"));
assert!(!preamble.contains("# Tool Usage Guidelines"));
}
#[test]
fn system_prompt_with_env_and_tools_and_supplemental() {
let mut pb = SystemPromptBuilder::new()
.system_prompt("# System\n\nInstructions.")
.working_directory("/home/user")
.add_context("Git Workflow", "Git guidance.");
let _ = pb.track(MockTool { id: 1 });
let preamble = pb.build();
let system_pos = preamble.find("# System").unwrap();
let env_pos = preamble.find("# Environment").unwrap();
let tools_pos = preamble.find("# Tool Usage Guidelines").unwrap();
let supplemental_pos = preamble.find("# Supplemental Context").unwrap();
assert!(system_pos < env_pos);
assert!(env_pos < tools_pos);
assert!(tools_pos < supplemental_pos);
}
#[test]
fn system_prompt_no_trailing_newline_gets_separator() {
let mut pb = SystemPromptBuilder::new().system_prompt("# System\n\nNo trailing newline");
let _ = pb.track(MockTool { id: 1 });
let preamble = pb.build();
assert!(
preamble.contains("No trailing newline\n\n# Environment"),
"Expected one blank line after system prompt.\nGot:\n{preamble}"
);
assert!(
!preamble.contains("\n\n\n"),
"Found triple newline in preamble.\nGot:\n{preamble}"
);
}
#[test]
fn system_prompt_single_trailing_newline_gets_one_more() {
let mut pb =
SystemPromptBuilder::new().system_prompt("# System\n\nEnds with single newline\n");
let _ = pb.track(MockTool { id: 1 });
let preamble = pb.build();
assert!(
preamble.contains("Ends with single newline\n\n# Environment"),
"Expected one blank line after system prompt.\nGot:\n{preamble}"
);
assert!(
!preamble.contains("\n\n\n"),
"Found triple newline in preamble.\nGot:\n{preamble}"
);
}
#[test]
fn system_prompt_double_trailing_newline_no_extra() {
let mut pb =
SystemPromptBuilder::new().system_prompt("# System\n\nEnds with double newline\n\n");
let _ = pb.track(MockTool { id: 1 });
let preamble = pb.build();
assert!(
preamble.contains("Ends with double newline\n\n# Environment"),
"Expected one blank line after system prompt.\nGot:\n{preamble}"
);
assert!(
!preamble.contains("\n\n\n"),
"Found triple newline in preamble.\nGot:\n{preamble}"
);
}
#[test]
fn system_prompt_trailing_newlines_with_environment() {
let pb = SystemPromptBuilder::new()
.system_prompt("# System\n\nEnds with single newline\n")
.working_directory("/home/user");
let preamble = pb.build();
assert!(
preamble.contains("Ends with single newline\n\n# Environment"),
"Expected one blank line after system prompt.\nGot:\n{preamble}"
);
assert!(
!preamble.contains("\n\n\n"),
"Found triple newline in preamble.\nGot:\n{preamble}"
);
}
#[test]
fn system_prompt_chains_fluently() {
let pb = SystemPromptBuilder::new()
.system_prompt("# System\n\nContent.")
.working_directory("/home/user")
.add_context("A", "a");
let preamble = pb.build();
assert!(preamble.contains("# System"));
assert!(preamble.contains("# Environment"));
assert!(preamble.contains("# Supplemental Context"));
}
#[test]
fn section_separator_returns_correct_suffix() {
assert_eq!(section_separator("no newline"), "\n\n");
assert_eq!(section_separator("single newline\n"), "\n");
assert_eq!(section_separator("double newline\n\n"), "");
assert_eq!(section_separator("triple newline\n\n\n"), "");
assert_eq!(section_separator(""), "\n\n");
}
#[test]
fn preamble_preview_structure_has_correct_section_order() {
let resolver = AllowedPathResolver::from_canonical(["/home/user/project", "/tmp"]);
let mut pb = SystemPromptBuilder::new()
.system_prompt("# System Instructions\n\nYou are helpful.")
.working_directory("/home/user/project")
.allowed_paths(&resolver)
.add_context("Git Workflow", "Git guidance content.")
.add_context("GitHub CLI", "GitHub guidance content.");
let _ = pb.track(MockTool { id: 1 });
let _ = pb.track(OtherTool);
let preamble = pb.build();
assert!(
preamble.contains("# System Instructions"),
"Missing system prompt"
);
assert!(
preamble.contains("# Environment"),
"Missing environment section"
);
assert!(
preamble.contains("Working directory:"),
"Missing working directory"
);
assert!(
preamble.contains("Allowed directories:"),
"Missing allowed directories"
);
assert!(
preamble.contains("# Tool Usage Guidelines"),
"Missing tools section"
);
assert!(
preamble.contains("# Supplemental Context"),
"Missing supplemental section"
);
let system_pos = preamble.find("# System Instructions").unwrap();
let env_pos = preamble.find("# Environment").unwrap();
let tools_pos = preamble.find("# Tool Usage Guidelines").unwrap();
let supplemental_pos = preamble.find("# Supplemental Context").unwrap();
assert!(
system_pos < env_pos,
"System prompt should come before environment"
);
assert!(env_pos < tools_pos, "Environment should come before tools");
assert!(
tools_pos < supplemental_pos,
"Tools should come before supplemental"
);
assert!(
!preamble.contains("\n\n\n"),
"Found triple newline (double blank line)"
);
assert_eq!(
preamble,
preamble.trim_end(),
"Preamble has trailing whitespace"
);
}
#[test]
fn preamble_preview_allowed_paths_rendered_correctly() {
let resolver = AllowedPathResolver::from_canonical(["/home/user/project", "/tmp"]);
let pb = SystemPromptBuilder::new()
.working_directory("/home/user/project")
.allowed_paths(&resolver);
let preamble = pb.build();
assert!(
preamble.contains("- /home/user/project"),
"Missing project path"
);
assert!(preamble.contains("- /tmp"), "Missing tmp path");
}
}