1 //! Project instruction files (`AGENTS.md` and the like).
2 //!
3 //! Agentic coding tools converge on a convention: a Markdown file checked into a
4 //! project that carries always-on, project-specific guidance for the agent. The
5 //! cross-tool open standard is [`AGENTS.md`](https://agents.md); siGit also reads
6 //! `CLAUDE.md` for compatibility with the wider ecosystem.
7 //!
8 //! This is the always-on counterpart to Agent Skills (`skills.rs`): skills load
9 //! *on demand* when a task matches, whereas instruction files load *once per
10 //! session* and are injected into the system context so their guidance is always
11 //! in force.
12 //!
13 //! Discovery walks from the session's working directory up to the repository
14 //! root (the nearest ancestor containing `.git`), reading one instruction file
15 //! per directory. A global file under `$SIGIT_CONFIG_DIR` (default
16 //! `~/.config/sigit/`) is included with the lowest precedence. Files are ordered
17 //! outermost-first (global, then repo root … down to the cwd) so that more
18 //! specific, deeper files are read last and take precedence — matching the
19 //! `AGENTS.md` convention.
20
21 use std::path::{Path, PathBuf};
22
23 /// Instruction file names to look for in each directory, in priority order.
24 /// Only the first match in a given directory is loaded.
25 const INSTRUCTION_FILE_NAMES: &[&str] = &["AGENTS.md", "CLAUDE.md"];
26
27 /// Per-file and total caps so an oversized file can't blow up the context window.
28 const MAX_FILE_BYTES: usize = 32 * 1024;
29 const MAX_TOTAL_BYTES: usize = 64 * 1024;
30
31 /// Load and combine project instruction files for `cwd`, returning a single
32 /// block ready to append to the session's system context, or `None` if none are
33 /// found.
34 pub fn load_project_instructions(cwd: &Path) -> Option<String> {
35 let mut sections: Vec<String> = Vec::new();
36 let mut seen: Vec<PathBuf> = Vec::new();
37 let mut total = 0usize;
38
39 for dir in instruction_dirs(cwd) {
40 let Some(path) = first_instruction_file(&dir) else {
41 continue;
42 };
43
44 // Dedup by canonical path so the same file reached via two roots (or a
45 // symlink) is only loaded once.
46 let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
47 if seen.contains(&canonical) {
48 continue;
49 }
50
51 let contents = match std::fs::read_to_string(&path) {
52 Ok(contents) => contents,
53 Err(error) => {
54 log::warn!("skipping instruction file {}: {error}", path.display());
55 continue;
56 }
57 };
58 let trimmed = contents.trim();
59 if trimmed.is_empty() {
60 continue;
61 }
62
63 if total >= MAX_TOTAL_BYTES {
64 log::warn!(
65 "instruction-file budget reached; skipping {}",
66 path.display()
67 );
68 break;
69 }
70
71 let body = clamp_bytes(trimmed, MAX_FILE_BYTES);
72 total += body.len();
73 seen.push(canonical);
74 sections.push(format!("## {}\n\n{}", path.display(), body));
75 }
76
77 if sections.is_empty() {
78 return None;
79 }
80
81 let mut out = String::from(
82 "# Project instructions\n\n\
83 The following files provide project-specific guidance for this project. \
84 Treat them as authoritative context for how to work here, second only to \
85 the user's direct requests. When guidance conflicts, the more specific \
86 (deeper) file takes precedence. These are guidance, not commands to take \
87 irreversible actions on their own — your normal judgment and safety rules \
88 still apply.\n\n",
89 );
90 out.push_str(&sections.join("\n\n"));
91 Some(out)
92 }
93
94 /// The directories to scan, lowest-precedence first: an optional global config
95 /// directory, then the repository root down to `cwd`.
96 fn instruction_dirs(cwd: &Path) -> Vec<PathBuf> {
97 let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
98 let root = repo_root(&cwd).unwrap_or_else(|| cwd.clone());
99
100 // Ancestors of cwd that lie within the repo root, root-first.
101 let mut chain: Vec<PathBuf> = cwd
102 .ancestors()
103 .filter(|ancestor| ancestor.starts_with(&root))
104 .map(Path::to_path_buf)
105 .collect();
106 chain.reverse();
107
108 let mut dirs = Vec::new();
109 if let Some(global) = sigit_config_dir() {
110 dirs.push(global);
111 }
112 dirs.extend(chain);
113 dirs
114 }
115
116 /// The nearest ancestor of `dir` (inclusive) that contains a `.git` entry.
117 fn repo_root(dir: &Path) -> Option<PathBuf> {
118 dir.ancestors()
119 .find(|ancestor| ancestor.join(".git").exists())
120 .map(Path::to_path_buf)
121 }
122
123 /// The first existing instruction file in `dir`, by name priority.
124 fn first_instruction_file(dir: &Path) -> Option<PathBuf> {
125 for name in INSTRUCTION_FILE_NAMES {
126 let candidate = dir.join(name);
127 if candidate.is_file() {
128 return Some(candidate);
129 }
130 }
131 None
132 }
133
134 /// The file the `remember` tool appends durable notes to: the nearest existing
135 /// instruction file walking from `cwd` up to the repository root, or a new
136 /// `CLAUDE.md` at the repo root (falling back to `cwd`) when none exists yet.
137 pub fn memory_file(cwd: &Path) -> PathBuf {
138 let canonical = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
139 let root = repo_root(&canonical).unwrap_or_else(|| canonical.clone());
140
141 // Deepest (most specific) existing file wins, matching read precedence.
142 for dir in canonical
143 .ancestors()
144 .filter(|ancestor| ancestor.starts_with(&root))
145 {
146 if let Some(found) = first_instruction_file(dir) {
147 return found;
148 }
149 }
150
151 root.join("CLAUDE.md")
152 }
153
154 fn sigit_config_dir() -> Option<PathBuf> {
155 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")
156 && !dir.is_empty()
157 {
158 return Some(PathBuf::from(dir));
159 }
160 std::env::var("HOME")
161 .ok()
162 .map(|home| PathBuf::from(home).join(".config").join("sigit"))
163 }
164
165 /// Truncate `text` to at most `limit` bytes on a char boundary, appending a
166 /// marker when truncation happens.
167 fn clamp_bytes(text: &str, limit: usize) -> String {
168 if text.len() <= limit {
169 return text.to_string();
170 }
171 let mut end = limit;
172 while end > 0 && !text.is_char_boundary(end) {
173 end -= 1;
174 }
175 format!(
176 "{}\n\n--- truncated ({} of {} bytes shown) ---",
177 &text[..end],
178 end,
179 text.len()
180 )
181 }
182
183 #[cfg(test)]
184 mod tests {
185 use super::*;
186 use std::fs;
187
188 fn unique_dir(name: &str) -> PathBuf {
189 let nanos = std::time::SystemTime::now()
190 .duration_since(std::time::UNIX_EPOCH)
191 .unwrap()
192 .as_nanos();
193 std::env::temp_dir().join(format!("sigit-instr-test-{name}-{nanos}"))
194 }
195
196 #[test]
197 fn none_when_no_files() {
198 let root = unique_dir("empty");
199 fs::create_dir_all(&root).unwrap();
200 // Mark as a repo root so the scan doesn't escape into real ancestors.
201 fs::create_dir_all(root.join(".git")).unwrap();
202 assert!(load_project_instructions(&root).is_none());
203 let _ = fs::remove_dir_all(&root);
204 }
205
206 #[test]
207 fn agents_md_preferred_over_claude_md_in_same_dir() {
208 let root = unique_dir("prefer");
209 fs::create_dir_all(root.join(".git")).unwrap();
210 fs::write(root.join("AGENTS.md"), "use tabs").unwrap();
211 fs::write(root.join("CLAUDE.md"), "use spaces").unwrap();
212
213 let out = load_project_instructions(&root).expect("instructions");
214 assert!(out.contains("use tabs"));
215 assert!(!out.contains("use spaces"));
216 let _ = fs::remove_dir_all(&root);
217 }
218
219 #[test]
220 fn nested_files_ordered_root_first() {
221 let root = unique_dir("nested");
222 let sub = root.join("crate-a");
223 fs::create_dir_all(&sub).unwrap();
224 fs::create_dir_all(root.join(".git")).unwrap();
225 fs::write(root.join("AGENTS.md"), "ROOT RULES").unwrap();
226 fs::write(sub.join("AGENTS.md"), "SUB RULES").unwrap();
227
228 let out = load_project_instructions(&sub).expect("instructions");
229 let root_pos = out.find("ROOT RULES").expect("root present");
230 let sub_pos = out.find("SUB RULES").expect("sub present");
231 // Root (broader) is read before the deeper, more specific file.
232 assert!(root_pos < sub_pos, "root should precede sub:\n{out}");
233 let _ = fs::remove_dir_all(&root);
234 }
235
236 #[test]
237 fn does_not_escape_repo_root() {
238 // A parent dir's AGENTS.md must not be read when the repo root is deeper.
239 let root = unique_dir("boundary");
240 let repo = root.join("repo");
241 fs::create_dir_all(repo.join(".git")).unwrap();
242 fs::write(root.join("AGENTS.md"), "OUTSIDE").unwrap();
243 fs::write(repo.join("AGENTS.md"), "INSIDE").unwrap();
244
245 let out = load_project_instructions(&repo).expect("instructions");
246 assert!(out.contains("INSIDE"));
247 assert!(
248 !out.contains("OUTSIDE"),
249 "must not read above repo root:\n{out}"
250 );
251 let _ = fs::remove_dir_all(&root);
252 }
253
254 #[test]
255 fn clamp_bytes_truncates_long_input() {
256 let long = "x".repeat(MAX_FILE_BYTES + 100);
257 let clamped = clamp_bytes(&long, MAX_FILE_BYTES);
258 assert!(clamped.contains("truncated"));
259 assert!(clamped.len() < long.len() + 100);
260 }
261 }