use crate::agents::parser::AgentDefinition;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use tracing::{info, warn};
const LAYER_SEPARATOR: &str = "\n\n---\n\n";
pub struct PromptAssembler {
repo_root: PathBuf,
temp_files: Vec<PathBuf>,
}
impl PromptAssembler {
pub fn new(repo_root: impl AsRef<Path>) -> Self {
Self {
repo_root: repo_root.as_ref().to_path_buf(),
temp_files: Vec::new(),
}
}
fn read_layer_file(path: &Path) -> String {
match fs::read_to_string(path) {
Ok(content) => content.trim().to_string(),
Err(_) => {
warn!("Prompt layer file not found: {}", path.display());
String::new()
}
}
}
fn read_global_prompt(&self) -> Result<String, std::io::Error> {
let repo_path = self.repo_root.join(".githubclaw").join("global-prompt.md");
if repo_path.exists() {
let content = Self::read_layer_file(&repo_path);
if !content.is_empty() {
return Ok(content);
}
}
let default_path = crate::agents::parser::defaults_dir().join("global_prompt.md");
if default_path.exists() {
let content = Self::read_layer_file(&default_path);
if !content.is_empty() {
return Ok(content);
}
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!(
"global-prompt.md not found in {} or built-in defaults ({}). \
This file is critical -- it contains the agent roster and common rules. \
Run `githubclaw init` to generate it.",
repo_path.display(),
default_path.display(),
),
))
}
fn read_value(&self) -> String {
Self::read_layer_file(&self.repo_root.join(".githubclaw").join("VALUE.md"))
}
pub fn assemble(
&mut self,
agent_def: &AgentDefinition,
task_context: &str,
) -> std::io::Result<PathBuf> {
let mut layers: Vec<String> = Vec::new();
match self.read_global_prompt() {
Ok(gp) if !gp.is_empty() => layers.push(gp),
Ok(_) => {}
Err(e) => {
warn!("Global prompt unavailable: {}", e);
}
}
let value = self.read_value();
if !value.is_empty() {
layers.push(format!("# Project North Star (VALUE.md)\n\n{}", value));
}
if !agent_def.instruction_body.is_empty() {
layers.push(agent_def.instruction_body.clone());
}
if !task_context.is_empty() {
layers.push(format!("# Current Task\n\n{}", task_context));
}
let assembled = layers.join(LAYER_SEPARATOR);
let temp_dir = std::env::temp_dir();
let file_name = format!(
"githubclaw_prompt_{}.md",
uuid::Uuid::new_v4().as_hyphenated()
);
let temp_path = temp_dir.join(file_name);
let mut file = fs::File::create(&temp_path)?;
file.write_all(assembled.as_bytes())?;
self.temp_files.push(temp_path.clone());
info!(
"Assembled prompt ({} chars) written to {}",
assembled.len(),
temp_path.display()
);
Ok(temp_path)
}
pub fn cleanup(&mut self, path: Option<&Path>) {
match path {
Some(p) => {
let _ = fs::remove_file(p);
self.temp_files.retain(|tp| tp != p);
}
None => {
self.cleanup_all();
}
}
}
pub fn cleanup_all(&mut self) {
for p in self.temp_files.drain(..) {
let _ = fs::remove_file(&p);
}
}
}
impl Drop for PromptAssembler {
fn drop(&mut self) {
self.cleanup_all();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use tempfile::TempDir;
fn setup_repo(tmp: &TempDir) -> PathBuf {
let root = tmp.path().to_path_buf();
let gc_dir = root.join(".githubclaw");
fs::create_dir_all(&gc_dir).unwrap();
root
}
fn make_agent_def(instruction_body: &str) -> AgentDefinition {
AgentDefinition {
name: "test-agent".to_string(),
backend: "codex".to_string(),
git_author_name: "Test".to_string(),
git_author_email: "test@example.com".to_string(),
timeout: None,
tools: HashMap::new(),
instruction_body: instruction_body.to_string(),
}
}
#[test]
fn assemble_with_all_4_layers_present() {
let tmp = TempDir::new().unwrap();
let root = setup_repo(&tmp);
fs::write(
root.join(".githubclaw").join("global-prompt.md"),
"Global rules here.",
)
.unwrap();
fs::write(
root.join(".githubclaw").join("VALUE.md"),
"Ship fast, ship safe.",
)
.unwrap();
let agent_def = make_agent_def("You are a coder agent.");
let mut assembler = PromptAssembler::new(&root);
let path = assembler.assemble(&agent_def, "Fix bug #42").unwrap();
assert!(path.exists());
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("Global rules here."));
assert!(content.contains("Ship fast, ship safe."));
assert!(content.contains("You are a coder agent."));
assert!(content.contains("Fix bug #42"));
assert!(content.contains(LAYER_SEPARATOR));
}
#[test]
fn assemble_with_missing_value_md() {
let tmp = TempDir::new().unwrap();
let root = setup_repo(&tmp);
fs::write(
root.join(".githubclaw").join("global-prompt.md"),
"Global rules.",
)
.unwrap();
let agent_def = make_agent_def("Instructions.");
let mut assembler = PromptAssembler::new(&root);
let path = assembler.assemble(&agent_def, "Task context").unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("Global rules."));
assert!(!content.contains("Project North Star"));
assert!(content.contains("Instructions."));
assert!(content.contains("Task context"));
}
#[test]
fn assemble_with_empty_task_context() {
let tmp = TempDir::new().unwrap();
let root = setup_repo(&tmp);
fs::write(root.join(".githubclaw").join("global-prompt.md"), "Global.").unwrap();
let agent_def = make_agent_def("Agent body.");
let mut assembler = PromptAssembler::new(&root);
let path = assembler.assemble(&agent_def, "").unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(!content.contains("Current Task"));
}
#[test]
fn global_prompt_missing_is_non_fatal() {
let tmp = TempDir::new().unwrap();
let root = setup_repo(&tmp);
let agent_def = make_agent_def("Just instructions.");
let mut assembler = PromptAssembler::new(&root);
let path = assembler.assemble(&agent_def, "Do it").unwrap();
let content = fs::read_to_string(&path).unwrap();
assert!(content.contains("Just instructions."));
assert!(content.contains("Do it"));
}
#[test]
fn temp_file_created_and_cleaned_up() {
let tmp = TempDir::new().unwrap();
let root = setup_repo(&tmp);
let agent_def = make_agent_def("Body.");
let mut assembler = PromptAssembler::new(&root);
let path = assembler.assemble(&agent_def, "").unwrap();
assert!(path.exists());
assembler.cleanup(Some(&path));
assert!(!path.exists());
}
#[test]
fn cleanup_all_removes_all_tracked_temp_files() {
let tmp = TempDir::new().unwrap();
let root = setup_repo(&tmp);
let agent_def = make_agent_def("Body.");
let mut assembler = PromptAssembler::new(&root);
let path1 = assembler.assemble(&agent_def, "Task 1").unwrap();
let path2 = assembler.assemble(&agent_def, "Task 2").unwrap();
assert!(path1.exists());
assert!(path2.exists());
assembler.cleanup_all();
assert!(!path1.exists());
assert!(!path2.exists());
}
#[test]
fn layer_separator_is_correct() {
assert_eq!(LAYER_SEPARATOR, "\n\n---\n\n");
}
}