bamboo_engine/runtime/context/
instruction.rs1use std::path::{Path, PathBuf};
4
5use bamboo_infrastructure::paths;
6
7pub const INSTRUCTION_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_INSTRUCTION_CONTEXT_START -->";
8pub const INSTRUCTION_CONTEXT_END_MARKER: &str = "<!-- BAMBOO_INSTRUCTION_CONTEXT_END -->";
9
10const INSTRUCTION_FILE_NAMES: [&str; 2] = ["AGENTS.md", "CLAUDE.md"];
11const MAX_INSTRUCTION_BYTES_PER_FILE: usize = 32 * 1024;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct InstructionFile {
15 pub path: PathBuf,
16 pub display_path: String,
17 pub content: String,
18}
19
20fn read_trimmed_file(path: &Path) -> Option<String> {
21 let bytes = std::fs::read(path).ok()?;
22 let truncated = if bytes.len() > MAX_INSTRUCTION_BYTES_PER_FILE {
23 &bytes[..MAX_INSTRUCTION_BYTES_PER_FILE]
24 } else {
25 &bytes
26 };
27 let content = String::from_utf8_lossy(truncated).trim().to_string();
28 (!content.is_empty()).then_some(content)
29}
30
31fn canonical_dir_or_self(path: &Path) -> Option<PathBuf> {
32 let candidate = if path.is_dir() {
33 path.to_path_buf()
34 } else {
35 path.parent()?.to_path_buf()
36 };
37 std::fs::canonicalize(candidate).ok()
38}
39
40fn ancestor_dirs(start: &Path) -> Vec<PathBuf> {
41 let mut dirs = Vec::new();
42 let Some(mut current) = canonical_dir_or_self(start) else {
43 return dirs;
44 };
45
46 loop {
47 dirs.push(current.clone());
48 let Some(parent) = current.parent() else {
49 break;
50 };
51 if parent == current {
52 break;
53 }
54 current = parent.to_path_buf();
55 }
56
57 dirs
58}
59
60pub fn collect_instruction_files(workspace_path: &Path) -> Vec<InstructionFile> {
61 let mut files = Vec::new();
62
63 for dir in ancestor_dirs(workspace_path) {
64 for file_name in INSTRUCTION_FILE_NAMES {
65 let candidate = dir.join(file_name);
66 let Some(content) = read_trimmed_file(&candidate) else {
67 continue;
68 };
69 files.push(InstructionFile {
70 display_path: paths::path_to_display_string(&candidate),
71 path: candidate,
72 content,
73 });
74 }
75 }
76
77 files
78}
79
80pub fn build_instruction_prompt_context(workspace_path: &str) -> Option<String> {
81 let workspace_path = workspace_path.trim();
82 if workspace_path.is_empty() {
83 return None;
84 }
85
86 let files = collect_instruction_files(Path::new(workspace_path));
87 if files.is_empty() {
88 return None;
89 }
90
91 let mut sections = Vec::new();
92 sections.push(
93 "Repository instruction layer loaded from workspace policy files. Follow these instructions in addition to the base system prompt unless they conflict with higher-priority system/developer directives.".to_string(),
94 );
95
96 for file in files {
97 sections.push(format!(
98 "## {}\nSource: {}\n\n{}",
99 file.path
100 .file_name()
101 .and_then(|value| value.to_str())
102 .unwrap_or("INSTRUCTION.md"),
103 file.display_path,
104 file.content
105 ));
106 }
107
108 let body = sections.join("\n\n");
109 Some(format!(
110 "{INSTRUCTION_CONTEXT_START_MARKER}\n{body}\n{INSTRUCTION_CONTEXT_END_MARKER}"
111 ))
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 #[test]
119 fn build_instruction_prompt_context_collects_workspace_and_ancestor_files() {
120 let root = tempfile::tempdir().expect("temp dir");
121 let nested = root.path().join("nested/project");
122 std::fs::create_dir_all(&nested).expect("nested dir");
123 std::fs::write(root.path().join("AGENTS.md"), "root agents").expect("agents");
124 std::fs::write(root.path().join("CLAUDE.md"), "root claude").expect("claude");
125
126 let context = build_instruction_prompt_context(nested.to_string_lossy().as_ref())
127 .expect("instruction context should exist");
128
129 assert!(context.contains(INSTRUCTION_CONTEXT_START_MARKER));
130 assert!(context.contains("root agents"));
131 assert!(context.contains("root claude"));
132 assert!(context.contains("AGENTS.md"));
133 assert!(context.contains("CLAUDE.md"));
134 }
135
136 #[test]
137 fn build_instruction_prompt_context_returns_none_when_no_files_exist() {
138 let root = tempfile::tempdir().expect("temp dir");
139 assert!(build_instruction_prompt_context(root.path().to_string_lossy().as_ref()).is_none());
140 }
141}