1use std::collections::HashSet;
15use std::path::{Path, PathBuf};
16
17const AGENTS_FILE: &str = "AGENTS.md";
19const OVERRIDE_FILE: &str = "AGENTS.override.md";
23
24#[derive(Debug, Clone, PartialEq, Eq, Default)]
27pub struct ProjectInstructions {
28 pub entries: Vec<InstructionEntry>,
30}
31
32impl ProjectInstructions {
33 #[must_use]
35 pub fn is_empty(&self) -> bool {
36 self.entries.is_empty()
37 }
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct InstructionEntry {
43 pub source_path: PathBuf,
46 pub content: String,
48}
49
50#[derive(Debug, Clone)]
53pub struct InstructionsConfig {
54 pub enabled: bool,
56 pub byte_budget: usize,
59 pub root_markers: Vec<String>,
61 pub root_stop_pattern: Option<String>,
68 pub extra_roots: Vec<PathBuf>,
71 pub global_file: bool,
73}
74
75impl Default for InstructionsConfig {
76 fn default() -> Self {
77 Self {
78 enabled: true,
79 byte_budget: 64 * 1024,
80 root_markers: vec![".git".to_string()],
81 root_stop_pattern: None,
82 extra_roots: Vec::new(),
83 global_file: true,
84 }
85 }
86}
87
88#[must_use]
99pub fn load_project_instructions(cwd: &Path, cfg: &InstructionsConfig) -> ProjectInstructions {
100 let global = (cfg.enabled && cfg.global_file)
103 .then(global_instruction_path)
104 .flatten();
105 load_impl(cwd, cfg, global.as_deref())
106}
107
108fn load_impl(cwd: &Path, cfg: &InstructionsConfig, global: Option<&Path>) -> ProjectInstructions {
111 if !cfg.enabled {
112 return ProjectInstructions::default();
113 }
114
115 let mut entries: Vec<InstructionEntry> = Vec::new();
116 let mut seen: HashSet<String> = HashSet::new();
117
118 if let Some(global) = global {
120 push_dir_entry(&global_dir_of(global), &mut entries, &mut seen, None);
121 }
122
123 let stop_pattern = cfg
127 .root_stop_pattern
128 .as_deref()
129 .and_then(|p| regex::Regex::new(p).ok());
130 let root = find_root_from_markers(cwd, &cfg.root_markers, stop_pattern.as_ref());
131 let ignore = build_gitignore(&root);
132 for dir in dirs_root_to_leaf(cwd, &root) {
133 push_dir_entry(&dir, &mut entries, &mut seen, ignore.as_ref());
134 }
135
136 for extra in &cfg.extra_roots {
138 let extra_root = find_root_from_markers(extra, &cfg.root_markers, stop_pattern.as_ref());
139 for dir in dirs_root_to_leaf(extra, &extra_root) {
140 push_dir_entry(&dir, &mut entries, &mut seen, None);
141 }
142 }
143
144 ProjectInstructions { entries }
145}
146
147fn global_dir_of(global_file: &Path) -> PathBuf {
150 global_file
151 .parent()
152 .map_or_else(|| global_file.to_path_buf(), Path::to_path_buf)
153}
154
155fn global_instruction_path() -> Option<PathBuf> {
160 crate::home::locode_home()
161 .ok()
162 .map(|dir| dir.join(AGENTS_FILE))
163}
164
165#[cfg(test)]
168fn global_path_from(
169 locode_home: Option<std::ffi::OsString>,
170 home: Option<std::ffi::OsString>,
171) -> Option<PathBuf> {
172 crate::home::resolve_home_from(locode_home, home)
173 .ok()
174 .map(|dir| dir.join(AGENTS_FILE))
175}
176
177pub(crate) fn find_root_from_markers(
185 start: &Path,
186 markers: &[String],
187 stop_pattern: Option<®ex::Regex>,
188) -> PathBuf {
189 let mut dir = Some(start);
190 while let Some(d) = dir {
191 if markers.iter().any(|m| d.join(m).exists())
192 || stop_pattern.is_some_and(|re| re.is_match(&d.to_string_lossy()))
193 {
194 return d.to_path_buf();
195 }
196 dir = d.parent();
197 }
198 start.to_path_buf()
199}
200
201fn dirs_root_to_leaf(leaf: &Path, root: &Path) -> Vec<PathBuf> {
204 let mut dirs = Vec::new();
205 let mut cur = Some(leaf);
206 while let Some(d) = cur {
207 dirs.push(d.to_path_buf());
208 if d == root {
209 break;
210 }
211 cur = d.parent();
212 }
213 dirs.reverse();
214 dirs
215}
216
217fn push_dir_entry(
221 dir: &Path,
222 entries: &mut Vec<InstructionEntry>,
223 seen: &mut HashSet<String>,
224 ignore: Option<&ignore::gitignore::Gitignore>,
225) {
226 for name in [OVERRIDE_FILE, AGENTS_FILE] {
227 let path = dir.join(name);
228 if !path.is_file() {
229 continue;
230 }
231 if let Some(gi) = ignore
234 && is_gitignored(gi, &path)
235 {
236 return;
237 }
238 let content = std::fs::read_to_string(&path).unwrap_or_default();
239 if content.trim().is_empty() {
240 return;
241 }
242 let key = canonical_key(&path);
243 if seen.insert(key) {
244 entries.push(InstructionEntry {
245 source_path: path,
246 content,
247 });
248 }
249 return;
250 }
251}
252
253fn build_gitignore(root: &Path) -> Option<ignore::gitignore::Gitignore> {
256 if !root.join(".git").exists() {
257 return None;
258 }
259 let base = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
260 let mut builder = ignore::gitignore::GitignoreBuilder::new(&base);
261 let _ = builder.add(base.join(".gitignore"));
262 let _ = builder.add(base.join(".git").join("info").join("exclude"));
263 builder.build().ok()
264}
265
266fn is_gitignored(gi: &ignore::gitignore::Gitignore, path: &Path) -> bool {
269 let canon = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
270 gi.matched_path_or_any_parents(&canon, false)
271 .is_ignore()
272}
273
274fn canonical_key(path: &Path) -> String {
276 std::fs::canonicalize(path)
277 .unwrap_or_else(|_| path.to_path_buf())
278 .to_string_lossy()
279 .to_lowercase()
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use std::fs;
286 use tempfile::TempDir;
287
288 fn tmp() -> (TempDir, PathBuf) {
291 let dir = tempfile::tempdir().unwrap();
292 let root = fs::canonicalize(dir.path()).unwrap();
293 (dir, root)
294 }
295
296 fn write(path: &Path, body: &str) {
297 if let Some(parent) = path.parent() {
298 fs::create_dir_all(parent).unwrap();
299 }
300 fs::write(path, body).unwrap();
301 }
302
303 fn cfg_no_global() -> InstructionsConfig {
306 InstructionsConfig {
307 global_file: false,
308 ..Default::default()
309 }
310 }
311
312 #[test]
313 fn walks_root_to_cwd_deepest_last() {
314 let (_g, root) = tmp();
315 fs::create_dir(root.join(".git")).unwrap();
316 write(&root.join(AGENTS_FILE), "root rules");
317 let sub = root.join("a/b");
318 write(&sub.join(AGENTS_FILE), "leaf rules");
319
320 let got = load_project_instructions(&sub, &cfg_no_global());
321 let paths: Vec<_> = got.entries.iter().map(|e| e.source_path.clone()).collect();
322 assert_eq!(
323 paths,
324 vec![root.join(AGENTS_FILE), sub.join(AGENTS_FILE)],
325 "root→cwd order, deepest last"
326 );
327 assert_eq!(got.entries[0].content, "root rules");
328 assert_eq!(got.entries[1].content, "leaf rules");
329 }
330
331 #[test]
332 fn no_git_falls_back_to_cwd_only() {
333 let (_g, root) = tmp();
334 write(&root.join(AGENTS_FILE), "parent rules");
336 let sub = root.join("child");
337 write(&sub.join(AGENTS_FILE), "cwd rules");
338
339 let got = load_project_instructions(&sub, &cfg_no_global());
340 assert_eq!(got.entries.len(), 1, "cwd-only: parent not walked");
341 assert_eq!(got.entries[0].source_path, sub.join(AGENTS_FILE));
342 }
343
344 #[test]
345 fn override_replaces_same_dir_agents_md_only() {
346 let (_g, root) = tmp();
347 fs::create_dir(root.join(".git")).unwrap();
348 write(&root.join(AGENTS_FILE), "root plain");
349 let sub = root.join("s");
350 write(&sub.join(AGENTS_FILE), "sub plain");
351 write(&sub.join(OVERRIDE_FILE), "sub override");
352
353 let got = load_project_instructions(&sub, &cfg_no_global());
354 assert_eq!(got.entries.len(), 2);
356 assert_eq!(got.entries[1].source_path, sub.join(OVERRIDE_FILE));
357 assert_eq!(got.entries[1].content, "sub override");
358 assert!(
359 got.entries
360 .iter()
361 .all(|e| e.source_path != sub.join(AGENTS_FILE)),
362 "sub's plain AGENTS.md is suppressed"
363 );
364 }
365
366 #[test]
367 fn empty_override_suppresses_agents_md_and_yields_nothing() {
368 let (_g, root) = tmp();
369 write(&root.join(AGENTS_FILE), "would-be content");
370 write(&root.join(OVERRIDE_FILE), " \n ");
371
372 let got = load_project_instructions(&root, &cfg_no_global());
373 assert!(
374 got.is_empty(),
375 "empty override wins the dir, yields no entry"
376 );
377 }
378
379 #[test]
380 fn empty_and_whitespace_files_dropped() {
381 let (_g, root) = tmp();
382 write(&root.join(AGENTS_FILE), "\n\t \n");
383 let got = load_project_instructions(&root, &cfg_no_global());
384 assert!(got.is_empty());
385 }
386
387 #[test]
388 fn gitignored_agents_md_skipped() {
389 let (_g, root) = tmp();
390 fs::create_dir(root.join(".git")).unwrap();
391 write(&root.join(".gitignore"), "AGENTS.md\n");
392 write(&root.join(AGENTS_FILE), "secret");
393 let got = load_project_instructions(&root, &cfg_no_global());
394 assert!(got.is_empty(), "gitignored AGENTS.md is filtered");
395 }
396
397 #[test]
398 fn dedup_by_canonical_path() {
399 let (_g, root) = tmp();
400 fs::create_dir(root.join(".git")).unwrap();
401 write(&root.join(AGENTS_FILE), "rules");
402 let got = load_project_instructions(&root, &cfg_no_global());
405 assert_eq!(got.entries.len(), 1);
406 assert_eq!(
408 canonical_key(&root.join(AGENTS_FILE)),
409 canonical_key(&root.join(AGENTS_FILE))
410 );
411 }
412
413 #[test]
414 fn extra_roots_appended_after_primary() {
415 let (_g, root) = tmp();
416 fs::create_dir(root.join(".git")).unwrap();
417 write(&root.join(AGENTS_FILE), "primary");
418 let (_g2, other) = tmp();
419 write(&other.join(AGENTS_FILE), "extra");
420
421 let cfg = InstructionsConfig {
422 extra_roots: vec![other.clone()],
423 ..cfg_no_global()
424 };
425 let got = load_project_instructions(&root, &cfg);
426 let paths: Vec<_> = got.entries.iter().map(|e| e.source_path.clone()).collect();
427 assert_eq!(paths, vec![root.join(AGENTS_FILE), other.join(AGENTS_FILE)]);
428 }
429
430 #[test]
431 fn disabled_returns_empty() {
432 let (_g, root) = tmp();
433 write(&root.join(AGENTS_FILE), "rules");
434 let cfg = InstructionsConfig {
435 enabled: false,
436 ..cfg_no_global()
437 };
438 assert!(load_project_instructions(&root, &cfg).is_empty());
439 }
440
441 #[test]
442 fn root_stop_pattern_stops_the_ascent() {
443 let (_g, root) = tmp();
446 fs::create_dir(root.join(".git")).unwrap();
447 write(&root.join(AGENTS_FILE), "top rules");
448 let x = root.join("x");
449 write(&x.join(AGENTS_FILE), "x rules");
450 let sub = x.join("y");
451 fs::create_dir_all(&sub).unwrap();
452
453 let got = load_project_instructions(&sub, &cfg_no_global());
455 assert_eq!(got.entries.len(), 2);
456
457 let mut cfg = cfg_no_global();
459 cfg.root_stop_pattern = Some("/x$".to_string());
460 let got = load_project_instructions(&sub, &cfg);
461 assert_eq!(got.entries.len(), 1);
462 assert_eq!(got.entries[0].source_path, x.join(AGENTS_FILE));
463 assert_eq!(got.entries[0].content, "x rules");
464 }
465
466 #[test]
467 fn invalid_root_stop_pattern_degrades_to_no_pattern() {
468 let (_g, root) = tmp();
469 fs::create_dir(root.join(".git")).unwrap();
470 write(&root.join(AGENTS_FILE), "rules");
471 let mut cfg = cfg_no_global();
472 cfg.root_stop_pattern = Some("[invalid".to_string());
473 let got = load_project_instructions(&root, &cfg);
474 assert_eq!(got.entries.len(), 1, "marker detection still works");
475 }
476
477 #[test]
478 fn global_file_is_lowest_precedence() {
479 let (_home_g, home) = tmp();
482 let global = home.join(".locode").join(AGENTS_FILE);
483 write(&global, "global");
484 let (_g, root) = tmp();
485 fs::create_dir(root.join(".git")).unwrap();
486 write(&root.join(AGENTS_FILE), "project");
487
488 let cfg = InstructionsConfig {
489 global_file: true,
490 ..Default::default()
491 };
492 let got = load_impl(&root, &cfg, Some(&global));
493 let paths: Vec<_> = got.entries.iter().map(|e| e.source_path.clone()).collect();
494 assert_eq!(
495 paths,
496 vec![global.clone(), root.join(AGENTS_FILE)],
497 "global first (lowest precedence), project after"
498 );
499 }
500
501 #[test]
502 fn global_file_disabled_passes_none() {
503 let (_g, root) = tmp();
506 write(&root.join(AGENTS_FILE), "project");
507 let got = load_project_instructions(&root, &cfg_no_global());
508 assert_eq!(got.entries.len(), 1);
509 assert_eq!(got.entries[0].source_path, root.join(AGENTS_FILE));
510 }
511
512 #[test]
513 fn global_instruction_path_shape() {
514 if let Some(p) = global_instruction_path() {
516 assert!(p.ends_with("AGENTS.md"));
517 }
518 }
519
520 #[test]
521 fn global_path_prefers_locode_home_over_home() {
522 use std::ffi::OsString;
523 let dir = tempfile::tempdir().unwrap();
526 let canon = fs::canonicalize(dir.path()).unwrap();
527 let p = global_path_from(Some(dir.path().as_os_str().to_owned()), None).unwrap();
528 assert_eq!(p, canon.join(AGENTS_FILE));
529 assert!(global_path_from(Some(OsString::from("/definitely/not/here")), None).is_none());
531 let p = global_path_from(Some(OsString::new()), Some(OsString::from("/home/u"))).unwrap();
534 assert_eq!(p, PathBuf::from("/home/u/.locode").join(AGENTS_FILE));
535 assert!(global_path_from(None, None).is_none());
537 assert!(global_path_from(None, Some(OsString::new())).is_none());
538 }
539}