opendev_context/subdir_instructions.rs
1//! Lazy per-subdirectory instruction injection.
2//!
3//! When the agent reads a file, this module checks parent directories for
4//! instruction files (AGENTS.md, CLAUDE.md) that haven't been injected yet
5//! and returns their content for injection into the conversation.
6//!
7//! Mirrors OpenCode's `InstructionPrompt.resolve()` behavior.
8
9use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11
12use tracing::debug;
13
14/// Recognized instruction file names (same order as environment.rs).
15const INSTRUCTION_FILENAMES: &[&str] = &["AGENTS.md", "CLAUDE.md", "CONTEXT.md"];
16
17/// Additional instruction files from other AI tools.
18const COMPAT_INSTRUCTION_FILES: &[&str] = &[".cursorrules", ".github/copilot-instructions.md"];
19
20/// Maximum instruction file size to inject (50 KB).
21const MAX_INSTRUCTION_SIZE: usize = 50 * 1024;
22
23/// Tracks which subdirectory instruction files have been injected into the
24/// conversation, and discovers new ones when files are read.
25#[derive(Debug, Clone)]
26pub struct SubdirInstructionTracker {
27 /// Canonical paths of instruction files already injected (at startup or
28 /// during the session).
29 injected: HashSet<PathBuf>,
30 /// The project root (git root or working dir). We don't walk above this.
31 project_root: PathBuf,
32}
33
34/// An instruction file discovered from a subdirectory.
35#[derive(Debug, Clone)]
36pub struct SubdirInstruction {
37 /// Path to the instruction file.
38 pub path: PathBuf,
39 /// Relative path from project root for display.
40 pub relative_path: String,
41 /// File contents.
42 pub content: String,
43}
44
45impl SubdirInstructionTracker {
46 /// Create a new tracker, pre-populating with instruction files already
47 /// injected at startup (from the system prompt).
48 pub fn new(project_root: PathBuf, startup_files: &[PathBuf]) -> Self {
49 let mut injected = HashSet::new();
50 for path in startup_files {
51 if let Ok(canonical) = path.canonicalize() {
52 injected.insert(canonical);
53 }
54 }
55 Self {
56 injected,
57 project_root,
58 }
59 }
60
61 /// Check if a file path triggers any new subdirectory instruction injection.
62 ///
63 /// Walks from the directory containing `file_path` up toward the project root,
64 /// looking for AGENTS.md / CLAUDE.md files that haven't been injected yet.
65 /// Returns any new instruction files found (and marks them as injected).
66 pub fn check_file_read(&mut self, file_path: &Path) -> Vec<SubdirInstruction> {
67 let dir = if file_path.is_dir() {
68 file_path.to_path_buf()
69 } else {
70 match file_path.parent() {
71 Some(p) => p.to_path_buf(),
72 None => return Vec::new(),
73 }
74 };
75
76 let canonical_root = self
77 .project_root
78 .canonicalize()
79 .unwrap_or_else(|_| self.project_root.clone());
80 let mut results = Vec::new();
81 let mut current = dir;
82
83 loop {
84 // Check each instruction filename in this directory
85 for filename in INSTRUCTION_FILENAMES {
86 let candidate = current.join(filename);
87 if let Ok(canonical) = candidate.canonicalize() {
88 if self.injected.contains(&canonical) {
89 continue; // Already injected
90 }
91
92 // Read the file
93 if let Ok(content) = std::fs::read_to_string(&canonical) {
94 let content = if content.len() > MAX_INSTRUCTION_SIZE {
95 content[..MAX_INSTRUCTION_SIZE].to_string()
96 } else {
97 content
98 };
99
100 let relative = canonical
101 .strip_prefix(&canonical_root)
102 .unwrap_or(&canonical)
103 .display()
104 .to_string();
105
106 debug!(path = %relative, "Injecting subdirectory instruction file");
107
108 self.injected.insert(canonical.clone());
109 results.push(SubdirInstruction {
110 path: canonical,
111 relative_path: relative,
112 content,
113 });
114 }
115 }
116 }
117
118 // Also check .opendev/instructions.md
119 for subdir in &[".opendev"] {
120 let candidate = current.join(subdir).join("instructions.md");
121 if let Ok(canonical) = candidate.canonicalize() {
122 if self.injected.contains(&canonical) {
123 continue;
124 }
125 if let Ok(content) = std::fs::read_to_string(&canonical) {
126 let content = if content.len() > MAX_INSTRUCTION_SIZE {
127 content[..MAX_INSTRUCTION_SIZE].to_string()
128 } else {
129 content
130 };
131 let relative = canonical
132 .strip_prefix(&canonical_root)
133 .unwrap_or(&canonical)
134 .display()
135 .to_string();
136 debug!(path = %relative, "Injecting subdirectory instruction file");
137 self.injected.insert(canonical.clone());
138 results.push(SubdirInstruction {
139 path: canonical,
140 relative_path: relative,
141 content,
142 });
143 }
144 }
145 }
146
147 // Check compatibility instruction files (.cursorrules, copilot, etc.)
148 for compat_path in COMPAT_INSTRUCTION_FILES {
149 let candidate = current.join(compat_path);
150 if let Ok(canonical) = candidate.canonicalize() {
151 if self.injected.contains(&canonical) {
152 continue;
153 }
154 if let Ok(content) = std::fs::read_to_string(&canonical) {
155 let content = if content.len() > MAX_INSTRUCTION_SIZE {
156 content[..MAX_INSTRUCTION_SIZE].to_string()
157 } else {
158 content
159 };
160 let relative = canonical
161 .strip_prefix(&canonical_root)
162 .unwrap_or(&canonical)
163 .display()
164 .to_string();
165 debug!(path = %relative, "Injecting compatibility instruction file");
166 self.injected.insert(canonical.clone());
167 results.push(SubdirInstruction {
168 path: canonical,
169 relative_path: relative,
170 content,
171 });
172 }
173 }
174 }
175
176 // Stop at project root
177 let canonical_current = current.canonicalize().unwrap_or_else(|_| current.clone());
178 if canonical_current == canonical_root {
179 break;
180 }
181
182 // Move up
183 if !current.pop() {
184 break;
185 }
186 }
187
188 results
189 }
190
191 /// After compaction removes middle messages, allow subdirectory instructions
192 /// to be re-discovered on the next file read.
193 ///
194 /// Preserves startup files (root-level instructions in system prompt) and
195 /// any instructions whose content is still present in the remaining messages.
196 pub fn reset_after_compaction(
197 &mut self,
198 startup_files: &[PathBuf],
199 remaining_messages: &[serde_json::Value],
200 ) {
201 // Collect paths of instructions still present in remaining messages
202 let mut still_present = HashSet::new();
203 for msg in remaining_messages {
204 if let Some(content) = msg.get("content").and_then(|v| v.as_str()) {
205 for path in &self.injected {
206 let path_str = path.display().to_string();
207 if content.contains(&path_str)
208 || content
209 .contains(path.file_name().unwrap_or_default().to_str().unwrap_or(""))
210 {
211 still_present.insert(path.clone());
212 }
213 }
214 }
215 }
216
217 self.injected = still_present;
218
219 // Always keep startup files marked as injected (they live in system prompt)
220 for path in startup_files {
221 if let Ok(canonical) = path.canonicalize() {
222 self.injected.insert(canonical);
223 }
224 }
225 }
226
227 /// Return the number of instruction files currently tracked.
228 pub fn injected_count(&self) -> usize {
229 self.injected.len()
230 }
231}
232
233#[cfg(test)]
234#[path = "subdir_instructions_tests.rs"]
235mod tests;