harness/openai_compatible/
instructions.rs1use std::collections::HashSet;
17use std::path::{Path, PathBuf};
18
19const FILENAMES: &[&str] = &["AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
24
25pub(crate) const DEFAULT_MAX_BYTES: usize = 32 * 1024;
29
30#[derive(Clone, Debug)]
39pub struct InstructionSources {
40 pub global: Vec<PathBuf>,
43 pub max_bytes: usize,
46}
47
48impl Default for InstructionSources {
49 fn default() -> Self {
50 Self { global: Vec::new(), max_bytes: DEFAULT_MAX_BYTES }
51 }
52}
53
54impl InstructionSources {
55 pub fn discover_global() -> Self {
62 let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
63 return Self::default();
64 };
65 Self {
66 global: vec![
67 home.join(".config/AGENTS.md"),
68 home.join(".codex/AGENTS.md"),
69 home.join(".claude/CLAUDE.md"),
70 ],
71 ..Self::default()
72 }
73 }
74}
75
76pub(crate) fn gather(cwd: &Path, sources: &InstructionSources) -> Option<String> {
83 let mut sections = Vec::new();
84 let mut remaining = sources.max_bytes;
85
86 for path in resolve(cwd, sources).into_iter().rev() {
87 if remaining == 0 {
88 break;
89 }
90 let Ok(content) = std::fs::read_to_string(&path) else { continue };
91 let trimmed = content.trim();
92 if trimmed.is_empty() {
93 continue;
94 }
95 sections.push(take_within(trimmed, &mut remaining));
96 }
97
98 if sections.is_empty() {
99 return None;
100 }
101 sections.reverse(); Some(sections.join("\n\n"))
103}
104
105fn take_within(text: &str, remaining: &mut usize) -> String {
108 if text.len() <= *remaining {
109 *remaining -= text.len();
110 return text.to_owned();
111 }
112 let mut end = *remaining;
116 while !text.is_char_boundary(end) {
117 end -= 1;
118 }
119 *remaining = 0;
120 text[..end].to_owned()
121}
122
123fn resolve(cwd: &Path, sources: &InstructionSources) -> Vec<PathBuf> {
127 let mut files = Vec::new();
128 let mut seen = HashSet::new();
129
130 if let Some(global) = sources.global.iter().find(|path| path.is_file()) {
131 files.push(global.clone());
132 seen.insert(global.clone());
133 }
134 for dir in project_dirs(cwd) {
135 if let Some(found) = first_in(&dir) {
136 if seen.insert(found.clone()) {
137 files.push(found);
138 }
139 }
140 }
141 files
142}
143
144fn first_in(dir: &Path) -> Option<PathBuf> {
146 FILENAMES.iter().map(|name| dir.join(name)).find(|path| path.is_file())
147}
148
149fn project_dirs(cwd: &Path) -> Vec<PathBuf> {
153 let mut chain = Vec::new();
154 let mut cur = cwd;
155 loop {
156 chain.push(cur.to_path_buf());
157 if cur.join(".git").exists() {
158 chain.reverse(); return chain;
160 }
161 match cur.parent() {
162 Some(parent) => cur = parent,
163 None => return vec![cwd.to_path_buf()], }
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use proptest::prelude::*;
172
173 proptest! {
174 #[test]
182 fn what_is_taken_is_a_prefix_of_what_was_offered(
183 text in "\\PC{0,64}",
184 budget in 0usize..192,
185 ) {
186 let mut remaining = budget;
187 let taken = take_within(&text, &mut remaining);
188
189 prop_assert!(text.starts_with(&taken), "{taken:?} is not a prefix of {text:?}");
190 prop_assert!(taken.len() <= budget, "over budget");
191 prop_assert!(
192 budget - remaining >= taken.len(),
193 "charged less than it took",
194 );
195 }
196
197 #[test]
200 fn text_that_fits_is_taken_whole(text in "\\PC{0,64}", slack in 0usize..32) {
201 let budget = text.len() + slack;
202 let mut remaining = budget;
203 prop_assert_eq!(take_within(&text, &mut remaining), text.clone());
204 prop_assert_eq!(remaining, slack, "charged for more than the text");
205 }
206
207 #[test]
211 fn trimming_gives_up_no_more_than_one_character(
212 text in "\\PC{1,64}",
213 budget in 0usize..192,
214 ) {
215 prop_assume!(text.len() > budget);
216 let mut remaining = budget;
217 let taken = take_within(&text, &mut remaining);
218 let widest = text.chars().map(char::len_utf8).max().unwrap_or(1);
219 prop_assert!(
220 taken.len() + widest > budget,
221 "took {} of a {budget} byte budget",
222 taken.len(),
223 );
224 }
225 }
226
227 fn scratch(tag: &str) -> PathBuf {
228 let dir = std::env::temp_dir().join(format!("hl-instr-{tag}-{}", std::process::id()));
229 let _ = std::fs::remove_dir_all(&dir);
230 std::fs::create_dir_all(&dir).unwrap();
231 dir
232 }
233
234 fn project_only() -> InstructionSources {
236 InstructionSources::default()
237 }
238
239 #[test]
240 fn gathers_project_files_root_to_cwd() {
241 let root = scratch("proj");
242 std::fs::create_dir_all(root.join(".git")).unwrap();
243 std::fs::write(root.join("AGENTS.md"), "root rules").unwrap();
244 let sub = root.join("crate-a");
245 std::fs::create_dir_all(&sub).unwrap();
246 std::fs::write(sub.join("CLAUDE.md"), "crate rules").unwrap();
247
248 let text = gather(&sub, &project_only()).expect("found instructions");
249 assert!(text.contains("root rules") && text.contains("crate rules"));
250 assert!(text.find("root rules") < text.find("crate rules"));
252 let _ = std::fs::remove_dir_all(&root);
253 }
254
255 #[test]
256 fn a_directory_contributes_one_file_not_both() {
257 let root = scratch("both");
260 std::fs::create_dir_all(root.join(".git")).unwrap();
261 std::fs::write(root.join("AGENTS.md"), "the standard").unwrap();
262 std::fs::write(root.join("CLAUDE.md"), "the fallback").unwrap();
263
264 let text = gather(&root, &project_only()).expect("found instructions");
265 assert!(text.contains("the standard"), "AGENTS.md is preferred: {text}");
266 assert!(!text.contains("the fallback"), "CLAUDE.md must not stack: {text}");
267 let _ = std::fs::remove_dir_all(&root);
268 }
269
270 #[test]
271 fn discover_global_names_the_conventional_files() {
272 let Some(home) = std::env::var_os("HOME").map(PathBuf::from) else {
277 return; };
279 let sources = InstructionSources::discover_global();
280
281 assert_eq!(
282 sources.global,
283 vec![
284 home.join(".config/AGENTS.md"),
285 home.join(".codex/AGENTS.md"),
286 home.join(".claude/CLAUDE.md"),
287 ],
288 "order is precedence: our own convention first, then the agents that ship their own"
289 );
290 assert_eq!(sources.max_bytes, DEFAULT_MAX_BYTES, "opting in must not change the budget");
291 }
292
293 #[test]
294 fn the_default_budget_is_32_kib() {
295 assert_eq!(DEFAULT_MAX_BYTES, 32 * 1024);
299 assert_eq!(InstructionSources::default().max_bytes, DEFAULT_MAX_BYTES);
300 }
301
302 #[test]
303 fn a_file_exactly_on_budget_is_kept_whole() {
304 let root = scratch("exact");
307 std::fs::create_dir_all(root.join(".git")).unwrap();
308 std::fs::write(root.join("AGENTS.md"), "x".repeat(64)).unwrap();
309
310 let sources = InstructionSources { global: Vec::new(), max_bytes: 64 };
311 let text = gather(&root, &sources).expect("instructions");
312 assert_eq!(text.len(), 64, "a file the size of the budget is not truncated");
313 let _ = std::fs::remove_dir_all(&root);
314 }
315
316 #[test]
317 fn nothing_outside_the_working_tree_is_read_by_default() {
318 let home = scratch("home");
319 std::fs::write(home.join("global.md"), "global rules").unwrap();
320 let root = scratch("no-global");
321 std::fs::create_dir_all(root.join(".git")).unwrap();
322 std::fs::write(root.join("AGENTS.md"), "project rules").unwrap();
323
324 let text = gather(&root, &InstructionSources::default()).expect("instructions");
325 assert!(!text.contains("global rules"), "default must not reach outside: {text}");
326
327 let opted_in = InstructionSources {
329 global: vec![home.join("global.md")],
330 ..Default::default()
331 };
332 let text = gather(&root, &opted_in).expect("instructions");
333 assert!(text.contains("global rules"), "host opt-in must be honoured: {text}");
334 assert!(text.find("global rules") < text.find("project rules"), "global is least specific");
335 let _ = std::fs::remove_dir_all(&home);
336 let _ = std::fs::remove_dir_all(&root);
337 }
338
339 #[test]
340 fn the_first_global_candidate_that_exists_wins() {
341 let home = scratch("globals");
342 std::fs::write(home.join("second.md"), "second choice").unwrap();
343 std::fs::write(home.join("third.md"), "third choice").unwrap();
344 let root = scratch("g-proj");
345 std::fs::create_dir_all(root.join(".git")).unwrap();
346
347 let sources = InstructionSources {
348 global: vec![home.join("first.md"), home.join("second.md"), home.join("third.md")],
349 ..Default::default()
350 };
351 let text = gather(&root, &sources).expect("instructions");
352 assert!(text.contains("second choice"), "first existing candidate: {text}");
353 assert!(!text.contains("third choice"), "later candidates are ignored: {text}");
354 let _ = std::fs::remove_dir_all(&home);
355 let _ = std::fs::remove_dir_all(&root);
356 }
357
358 #[test]
359 fn the_budget_truncates_and_spends_it_on_the_nearest_file() {
360 let home = scratch("budget-home");
364 std::fs::write(home.join("global.md"), "G".repeat(500)).unwrap();
365 let root = scratch("budget-proj");
366 std::fs::create_dir_all(root.join(".git")).unwrap();
367 std::fs::write(root.join("AGENTS.md"), "P".repeat(100)).unwrap();
368
369 let sources = InstructionSources { global: vec![home.join("global.md")], max_bytes: 300 };
370 let text = gather(&root, &sources).expect("instructions");
371
372 assert_eq!(text.matches('P').count(), 100, "the nearest file is kept whole: {}", text.len());
373 assert_eq!(text.matches('G').count(), 200, "the global file takes only what is left");
374 assert!(text.len() <= 300 + 2, "budget honoured, plus the joining separator");
375 let _ = std::fs::remove_dir_all(&home);
376 let _ = std::fs::remove_dir_all(&root);
377 }
378
379 #[test]
380 fn truncation_never_splits_a_character() {
381 let root = scratch("utf8");
382 std::fs::create_dir_all(root.join(".git")).unwrap();
383 std::fs::write(root.join("AGENTS.md"), "é".repeat(10)).unwrap(); let sources = InstructionSources { global: Vec::new(), max_bytes: 5 };
387 let text = gather(&root, &sources).expect("instructions");
388 assert_eq!(text, "éé", "cut back to the boundary rather than panicking");
389 let _ = std::fs::remove_dir_all(&root);
390 }
391
392 #[test]
393 fn none_when_absent() {
394 let dir = scratch("empty");
395 std::fs::create_dir_all(dir.join(".git")).unwrap();
396 assert!(gather(&dir, &project_only()).is_none());
397 let _ = std::fs::remove_dir_all(&dir);
398 }
399}