1use std::collections::BTreeSet;
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17pub const BUDGET: usize = 32 * 1024;
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct Instruction {
23 pub path: String,
25 pub sha256: String,
27 pub bytes: usize,
29 pub cut: bool,
31 pub text: String,
33}
34
35fn chain(leaf: &Path) -> Vec<PathBuf> {
38 let mut out = vec![PathBuf::new()];
39 let mut at = PathBuf::new();
40 for c in leaf.components() {
41 if let std::path::Component::Normal(part) = c {
42 at.push(part);
43 out.push(at.clone());
44 }
45 }
46 out
47}
48
49#[must_use]
53pub fn collect(
54 repo: &Path,
55 project: &Path,
56 named: &[String],
57 names: &[String],
58) -> Vec<Instruction> {
59 if names.is_empty() {
60 return Vec::new();
61 }
62 let Ok(repo) = std::fs::canonicalize(repo) else {
63 return Vec::new();
64 };
65 let project = std::fs::canonicalize(project).unwrap_or_else(|_| repo.clone());
66 let rel = project
67 .strip_prefix(&repo)
68 .map(Path::to_path_buf)
69 .unwrap_or_default();
70 let leaves: Vec<PathBuf> = if named.is_empty() {
71 vec![rel.clone()]
72 } else {
73 named
74 .iter()
75 .map(|p| {
76 rel.join(p)
77 .parent()
78 .map(Path::to_path_buf)
79 .unwrap_or_default()
80 })
81 .collect()
82 };
83 let mut dirs: BTreeSet<(usize, PathBuf)> = BTreeSet::new();
84 for leaf in &leaves {
85 for d in chain(leaf) {
86 dirs.insert((d.components().count(), d));
87 }
88 }
89 let mut found: Vec<(usize, String, String)> = Vec::new();
90 for (depth, dir) in &dirs {
91 for name in names {
92 let file = repo.join(dir).join(name);
93 let Ok(real) = std::fs::canonicalize(&file) else {
95 continue;
96 };
97 if !real.starts_with(&repo) || !real.is_file() {
98 continue;
99 }
100 let Ok(text) = std::fs::read_to_string(&real) else {
101 continue; };
103 if text.trim().is_empty() {
104 continue;
105 }
106 let path = dir.join(name).to_string_lossy().replace('\\', "/");
107 found.push((*depth, path, text));
108 break;
109 }
110 }
111 fit(found)
112}
113
114fn fit(mut found: Vec<(usize, String, String)>) -> Vec<Instruction> {
116 found.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
117 let mut left = BUDGET;
118 let mut out: Vec<Instruction> = Vec::with_capacity(found.len());
119 for (_, path, text) in found.into_iter().rev() {
120 let sha256 = crate::integrity::sha256_hex(text.as_bytes());
121 let bytes = text.len();
122 let (text, cut) = if bytes <= left {
123 left -= bytes;
124 (text, false)
125 } else {
126 let mut end = left;
127 while end > 0 && !text.is_char_boundary(end) {
128 end -= 1;
129 }
130 left = 0;
131 (
132 format!(
133 "{}\n\n[… cut: the repository's instructions are limited to {} KiB, and nearer files come first]\n",
134 &text[..end],
135 BUDGET / 1024
136 ),
137 true,
138 )
139 };
140 out.push(Instruction {
141 path,
142 sha256,
143 bytes,
144 cut,
145 text,
146 });
147 }
148 out.reverse();
149 out
150}
151
152#[must_use]
154pub fn section(instructions: &[Instruction]) -> Option<String> {
155 if instructions.is_empty() {
156 return None;
157 }
158 let mut out = String::from(
159 "# Repository instructions\n\nThe repository's own conventions for agents (AGENTS.md), root first, the nearest last — a nearer file overrides a farther one. Follow them; where they conflict with the task, the spec, the checks or the rules above, those win.\n",
160 );
161 for i in instructions {
162 out.push_str(&format!("\n## {}\n\n{}\n", i.path, i.text.trim_end()));
163 }
164 Some(out)
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 fn tree(files: &[(&str, &str)]) -> tempfile::TempDir {
172 let dir = tempfile::tempdir().unwrap();
173 for (p, text) in files {
174 let f = dir.path().join(p);
175 std::fs::create_dir_all(f.parent().unwrap()).unwrap();
176 std::fs::write(f, text).unwrap();
177 }
178 dir
179 }
180
181 fn paths(v: &[Instruction]) -> Vec<&str> {
182 v.iter().map(|i| i.path.as_str()).collect()
183 }
184
185 const AGENTS: &[&str] = &[];
186
187 #[test]
188 fn the_nearest_file_comes_last_and_others_are_not_read() {
189 let t = tree(&[
190 ("AGENTS.md", "root"),
191 ("services/api/AGENTS.md", "api"),
192 ("web/AGENTS.md", "web"),
193 ]);
194 let names = vec!["AGENTS.md".to_owned()];
195 let got = collect(
196 t.path(),
197 t.path(),
198 &["services/api/src/handler.rs".to_owned()],
199 &names,
200 );
201 assert_eq!(paths(&got), ["AGENTS.md", "services/api/AGENTS.md"]);
202 let none = collect(t.path(), t.path(), &[], &names);
203 assert_eq!(paths(&none), ["AGENTS.md"]);
204 let both = collect(
205 t.path(),
206 t.path(),
207 &["web/x.ts".to_owned(), "services/api/y.rs".to_owned()],
208 &names,
209 );
210 assert_eq!(
211 paths(&both),
212 ["AGENTS.md", "web/AGENTS.md", "services/api/AGENTS.md"],
213 "by depth"
214 );
215 assert!(
216 collect(
217 t.path(),
218 t.path(),
219 &[],
220 &AGENTS.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>()
221 )
222 .is_empty()
223 );
224 }
225
226 #[test]
227 fn fallback_names_are_read_one_per_directory() {
228 let t = tree(&[
229 ("CLAUDE.md", "claude"),
230 ("pkg/AGENTS.md", "a"),
231 ("pkg/CLAUDE.md", "c"),
232 ]);
233 let names = vec!["AGENTS.md".to_owned(), "CLAUDE.md".to_owned()];
234 let got = collect(t.path(), t.path(), &["pkg/lib.rs".to_owned()], &names);
235 assert_eq!(paths(&got), ["CLAUDE.md", "pkg/AGENTS.md"]);
236 }
237
238 #[test]
239 fn a_project_in_a_subdirectory_gets_the_root_and_its_own() {
240 let t = tree(&[
241 ("AGENTS.md", "root"),
242 ("app/AGENTS.md", "app"),
243 ("app/specs/x", ""),
244 ]);
245 let got = collect(
246 t.path(),
247 &t.path().join("app"),
248 &[],
249 &["AGENTS.md".to_owned()],
250 );
251 assert_eq!(paths(&got), ["AGENTS.md", "app/AGENTS.md"]);
252 }
253
254 #[test]
255 fn the_budget_keeps_the_nearest_and_says_what_was_cut() {
256 let big = "x".repeat(BUDGET);
257 let t = tree(&[("AGENTS.md", &big), ("a/AGENTS.md", "near")]);
258 let got = collect(
259 t.path(),
260 t.path(),
261 &["a/f.rs".to_owned()],
262 &["AGENTS.md".to_owned()],
263 );
264 assert_eq!(paths(&got), ["AGENTS.md", "a/AGENTS.md"]);
265 assert!(!got[1].cut && got[1].text == "near");
266 assert!(got[0].cut && got[0].text.contains("cut:"));
267 assert_eq!(got[0].bytes, BUDGET);
268 let total: usize = got.iter().map(|i| i.text.len()).sum();
269 assert!(total <= BUDGET + 200, "{total}");
270 }
271
272 #[test]
273 fn symlinks_out_of_the_repository_and_binary_files_are_not_read() {
274 let outside = tree(&[("secret.md", "outside")]);
275 let t = tree(&[("pkg/AGENTS.md", "")]);
276 std::os::unix::fs::symlink(outside.path().join("secret.md"), t.path().join("AGENTS.md"))
277 .unwrap();
278 std::fs::write(t.path().join("pkg/AGENTS.md"), [0xff, 0xfe, 0x00]).unwrap();
279 assert!(
280 collect(
281 t.path(),
282 t.path(),
283 &["pkg/x".to_owned()],
284 &["AGENTS.md".to_owned()]
285 )
286 .is_empty()
287 );
288 }
289
290 #[test]
291 fn the_section_ranks_the_instructions_below_the_task() {
292 let i = Instruction {
293 path: "AGENTS.md".to_owned(),
294 sha256: String::new(),
295 bytes: 3,
296 cut: false,
297 text: "use nextest".to_owned(),
298 };
299 let s = section(&[i]).unwrap();
300 assert!(s.contains("## AGENTS.md\n\nuse nextest") && s.contains("those win"));
301 assert!(section(&[]).is_none());
302 }
303}