Skip to main content

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;