Skip to main content

bamboo_engine/runtime/context/
instruction.rs

1//! Instruction layer: loads instruction files (AGENTS.md, CLAUDE.md) from workspace.
2
3use 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}